Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 9eec33773a refactor(config): use schema stripping for persisted fields 2026-07-02 18:44:17 +02:00
Mohamed Boudra 5616c6af6a fix(config): tolerate unknown persisted fields
Config files can be shared across checkouts with different schema versions. Strip unrecognized keys on load so read paths like daemon status do not fail before they can report state; known malformed fields still fail validation.
2026-07-02 18:39:46 +02:00
3 changed files with 70 additions and 31 deletions
+3
View File
@@ -195,6 +195,9 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults. All fields are optional with sensible defaults.
Config parsing strips unrecognized object keys so a config written by a newer daemon does not brick
older read paths such as `paseo daemon status`. Malformed known fields still fail validation.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. `agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
@@ -587,8 +587,8 @@ describe("PersistedConfigSchema logging config", () => {
expect(parsed.log?.format).toBe("json"); expect(parsed.log?.format).toBe("json");
}); });
test("rejects unknown logging config fields", () => { test("strips unknown logging config fields", () => {
const result = PersistedConfigSchema.safeParse({ const parsed = PersistedConfigSchema.parse({
log: { log: {
console: { console: {
level: "info", level: "info",
@@ -597,7 +597,7 @@ describe("PersistedConfigSchema logging config", () => {
}, },
}); });
expect(result.success).toBe(false); expect(parsed.log?.console).toEqual({ level: "info" });
}); });
}); });
@@ -669,6 +669,42 @@ describe("loadPersistedConfig", () => {
} }
}); });
test("loads a config with unrecognized persisted fields", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
futureDaemonSetting: { enabled: true },
relay: {
enabled: true,
futureRelaySetting: "enabled",
},
},
futureRootSetting: true,
},
null,
2,
)}\n`,
);
const config = loadPersistedConfig(home);
expect(config.daemon?.listen).toBe("127.0.0.1:6767");
expect(config.daemon?.relay?.enabled).toBe(true);
expect((config.daemon as Record<string, unknown>)?.futureDaemonSetting).toBeUndefined();
expect((config.daemon?.relay as Record<string, unknown>)?.futureRelaySetting).toBeUndefined();
expect((config as Record<string, unknown>).futureRootSetting).toBeUndefined();
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("loads a config that still uses the removed providers.openai.voice block", () => { test("loads a config that still uses the removed providers.openai.voice block", () => {
const home = createTempHome(); const home = createTempHome();
const configPath = path.join(home, "config.json"); const configPath = path.join(home, "config.json");
+28 -28
View File
@@ -25,7 +25,7 @@ const LogConfigSchema = z
level: LogLevelSchema.optional(), level: LogLevelSchema.optional(),
format: LogFormatSchema.optional(), format: LogFormatSchema.optional(),
}) })
.strict() .strip()
.optional(), .optional(),
file: z file: z
@@ -37,20 +37,20 @@ const LogConfigSchema = z
maxSize: z.string().min(1).optional(), maxSize: z.string().min(1).optional(),
maxFiles: z.number().int().positive().optional(), maxFiles: z.number().int().positive().optional(),
}) })
.strict() .strip()
.optional(), .optional(),
}) })
.strict() .strip()
.optional(), .optional(),
}) })
.strict(); .strip();
const OpenAiSpeechEndpointSchema = z const OpenAiSpeechEndpointSchema = z
.object({ .object({
apiKey: z.string().trim().min(1).optional(), apiKey: z.string().trim().min(1).optional(),
baseUrl: z.string().trim().min(1).optional(), baseUrl: z.string().trim().min(1).optional(),
}) })
.strict(); .strip();
const OpenAiProviderSchema = z const OpenAiProviderSchema = z
.object({ .object({
@@ -59,26 +59,26 @@ const OpenAiProviderSchema = z
stt: OpenAiSpeechEndpointSchema.optional(), stt: OpenAiSpeechEndpointSchema.optional(),
tts: OpenAiSpeechEndpointSchema.optional(), tts: OpenAiSpeechEndpointSchema.optional(),
}) })
.strict(); .strip();
const LocalSpeechProviderSchema = z const LocalSpeechProviderSchema = z
.object({ .object({
modelsDir: z.string().min(1).optional(), modelsDir: z.string().min(1).optional(),
}) })
.strict(); .strip();
const ProvidersSchema = z const ProvidersSchema = z
.object({ .object({
openai: OpenAiProviderSchema.optional(), openai: OpenAiProviderSchema.optional(),
local: LocalSpeechProviderSchema.optional(), local: LocalSpeechProviderSchema.optional(),
}) })
.strict(); .strip();
const WorktreesConfigSchema = z const WorktreesConfigSchema = z
.object({ .object({
root: z.string().min(1).optional(), root: z.string().min(1).optional(),
}) })
.strict(); .strip();
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, { const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
message: "Expected a bcrypt hash", message: "Expected a bcrypt hash",
@@ -88,7 +88,7 @@ const DaemonAuthSchema = z
.object({ .object({
password: BcryptHashSchema.optional(), password: BcryptHashSchema.optional(),
}) })
.strict(); .strip();
const SpeechProviderIdSchema = z const SpeechProviderIdSchema = z
.string() .string()
@@ -106,10 +106,10 @@ const FeatureDictationSchema = z
language: z.string().trim().min(1).optional(), language: z.string().trim().min(1).optional(),
confidenceThreshold: z.number().optional(), confidenceThreshold: z.number().optional(),
}) })
.strict() .strip()
.optional(), .optional(),
}) })
.strict(); .strip();
const FeatureVoiceModeSchema = z const FeatureVoiceModeSchema = z
.object({ .object({
@@ -119,7 +119,7 @@ const FeatureVoiceModeSchema = z
provider: z.string().optional(), provider: z.string().optional(),
model: z.string().min(1).optional(), model: z.string().min(1).optional(),
}) })
.strict() .strip()
.optional(), .optional(),
stt: z stt: z
.object({ .object({
@@ -127,13 +127,13 @@ const FeatureVoiceModeSchema = z
model: z.string().min(1).optional(), model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(), language: z.string().trim().min(1).optional(),
}) })
.strict() .strip()
.optional(), .optional(),
turnDetection: z turnDetection: z
.object({ .object({
provider: SpeechProviderIdSchema.optional(), provider: SpeechProviderIdSchema.optional(),
}) })
.strict() .strip()
.optional(), .optional(),
tts: z tts: z
.object({ .object({
@@ -143,17 +143,17 @@ const FeatureVoiceModeSchema = z
speakerId: z.number().int().optional(), speakerId: z.number().int().optional(),
speed: z.number().optional(), speed: z.number().optional(),
}) })
.strict() .strip()
.optional(), .optional(),
}) })
.strict(); .strip();
const FeatureWebUiSchema = z const FeatureWebUiSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
distDir: z.string().min(1).optional(), distDir: z.string().min(1).optional(),
}) })
.strict(); .strip();
const StructuredGenerationProviderConfigSchema = z const StructuredGenerationProviderConfigSchema = z
.object({ .object({
@@ -161,13 +161,13 @@ const StructuredGenerationProviderConfigSchema = z
model: z.string().min(1).optional(), model: z.string().min(1).optional(),
thinkingOptionId: z.string().min(1).optional(), thinkingOptionId: z.string().min(1).optional(),
}) })
.strict(); .strip();
const AgentMetadataGenerationSchema = z const AgentMetadataGenerationSchema = z
.object({ .object({
providers: z.array(StructuredGenerationProviderConfigSchema).optional(), providers: z.array(StructuredGenerationProviderConfigSchema).optional(),
}) })
.strict(); .strip();
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const; const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
@@ -254,7 +254,7 @@ export const PersistedConfigSchema = z
.object({ .object({
allowedOrigins: z.array(z.string()).optional(), allowedOrigins: z.array(z.string()).optional(),
}) })
.strict() .strip()
.optional(), .optional(),
relay: z relay: z
.object({ .object({
@@ -264,7 +264,7 @@ export const PersistedConfigSchema = z
useTls: z.boolean().optional(), useTls: z.boolean().optional(),
publicUseTls: z.boolean().optional(), publicUseTls: z.boolean().optional(),
}) })
.strict() .strip()
.optional(), .optional(),
serviceProxy: z serviceProxy: z
.object({ .object({
@@ -275,11 +275,11 @@ export const PersistedConfigSchema = z
listen: z.string().optional(), listen: z.string().optional(),
publicBaseUrl: z.url().optional(), publicBaseUrl: z.url().optional(),
}) })
.strict() .strip()
.optional(), .optional(),
auth: DaemonAuthSchema.optional(), auth: DaemonAuthSchema.optional(),
}) })
.strict() .strip()
.transform(({ allowedHosts, ...daemon }) => { .transform(({ allowedHosts, ...daemon }) => {
const hostnames = daemon.hostnames ?? allowedHosts; const hostnames = daemon.hostnames ?? allowedHosts;
return hostnames === undefined ? daemon : { ...daemon, hostnames }; return hostnames === undefined ? daemon : { ...daemon, hostnames };
@@ -290,7 +290,7 @@ export const PersistedConfigSchema = z
.object({ .object({
baseUrl: z.string().optional(), baseUrl: z.string().optional(),
}) })
.strict() .strip()
.optional(), .optional(),
providers: ProvidersSchema.optional(), providers: ProvidersSchema.optional(),
@@ -300,7 +300,7 @@ export const PersistedConfigSchema = z
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(),
}) })
.strict() .strip()
.optional(), .optional(),
features: z features: z
.object({ .object({
@@ -308,12 +308,12 @@ export const PersistedConfigSchema = z
voiceMode: FeatureVoiceModeSchema.optional(), voiceMode: FeatureVoiceModeSchema.optional(),
webUi: FeatureWebUiSchema.optional(), webUi: FeatureWebUiSchema.optional(),
}) })
.strict() .strip()
.optional(), .optional(),
log: LogConfigSchema.optional(), log: LogConfigSchema.optional(),
}) })
.strict(); .strip();
type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>; type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>;