Compare commits

...

2 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

View File

@@ -195,6 +195,9 @@ Single file, validated with `PersistedConfigSchema`.
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.
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.

View File

@@ -587,8 +587,8 @@ describe("PersistedConfigSchema logging config", () => {
expect(parsed.log?.format).toBe("json");
});
test("rejects unknown logging config fields", () => {
const result = PersistedConfigSchema.safeParse({
test("strips unknown logging config fields", () => {
const parsed = PersistedConfigSchema.parse({
log: {
console: {
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", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");

View File

@@ -25,7 +25,7 @@ const LogConfigSchema = z
level: LogLevelSchema.optional(),
format: LogFormatSchema.optional(),
})
.strict()
.strip()
.optional(),
file: z
@@ -37,20 +37,20 @@ const LogConfigSchema = z
maxSize: z.string().min(1).optional(),
maxFiles: z.number().int().positive().optional(),
})
.strict()
.strip()
.optional(),
})
.strict()
.strip()
.optional(),
})
.strict();
.strip();
const OpenAiSpeechEndpointSchema = z
.object({
apiKey: z.string().trim().min(1).optional(),
baseUrl: z.string().trim().min(1).optional(),
})
.strict();
.strip();
const OpenAiProviderSchema = z
.object({
@@ -59,26 +59,26 @@ const OpenAiProviderSchema = z
stt: OpenAiSpeechEndpointSchema.optional(),
tts: OpenAiSpeechEndpointSchema.optional(),
})
.strict();
.strip();
const LocalSpeechProviderSchema = z
.object({
modelsDir: z.string().min(1).optional(),
})
.strict();
.strip();
const ProvidersSchema = z
.object({
openai: OpenAiProviderSchema.optional(),
local: LocalSpeechProviderSchema.optional(),
})
.strict();
.strip();
const WorktreesConfigSchema = z
.object({
root: z.string().min(1).optional(),
})
.strict();
.strip();
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
message: "Expected a bcrypt hash",
@@ -88,7 +88,7 @@ const DaemonAuthSchema = z
.object({
password: BcryptHashSchema.optional(),
})
.strict();
.strip();
const SpeechProviderIdSchema = z
.string()
@@ -106,10 +106,10 @@ const FeatureDictationSchema = z
language: z.string().trim().min(1).optional(),
confidenceThreshold: z.number().optional(),
})
.strict()
.strip()
.optional(),
})
.strict();
.strip();
const FeatureVoiceModeSchema = z
.object({
@@ -119,7 +119,7 @@ const FeatureVoiceModeSchema = z
provider: z.string().optional(),
model: z.string().min(1).optional(),
})
.strict()
.strip()
.optional(),
stt: z
.object({
@@ -127,13 +127,13 @@ const FeatureVoiceModeSchema = z
model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(),
})
.strict()
.strip()
.optional(),
turnDetection: z
.object({
provider: SpeechProviderIdSchema.optional(),
})
.strict()
.strip()
.optional(),
tts: z
.object({
@@ -143,17 +143,17 @@ const FeatureVoiceModeSchema = z
speakerId: z.number().int().optional(),
speed: z.number().optional(),
})
.strict()
.strip()
.optional(),
})
.strict();
.strip();
const FeatureWebUiSchema = z
.object({
enabled: z.boolean().optional(),
distDir: z.string().min(1).optional(),
})
.strict();
.strip();
const StructuredGenerationProviderConfigSchema = z
.object({
@@ -161,13 +161,13 @@ const StructuredGenerationProviderConfigSchema = z
model: z.string().min(1).optional(),
thinkingOptionId: z.string().min(1).optional(),
})
.strict();
.strip();
const AgentMetadataGenerationSchema = z
.object({
providers: z.array(StructuredGenerationProviderConfigSchema).optional(),
})
.strict();
.strip();
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
@@ -254,7 +254,7 @@ export const PersistedConfigSchema = z
.object({
allowedOrigins: z.array(z.string()).optional(),
})
.strict()
.strip()
.optional(),
relay: z
.object({
@@ -264,7 +264,7 @@ export const PersistedConfigSchema = z
useTls: z.boolean().optional(),
publicUseTls: z.boolean().optional(),
})
.strict()
.strip()
.optional(),
serviceProxy: z
.object({
@@ -275,11 +275,11 @@ export const PersistedConfigSchema = z
listen: z.string().optional(),
publicBaseUrl: z.url().optional(),
})
.strict()
.strip()
.optional(),
auth: DaemonAuthSchema.optional(),
})
.strict()
.strip()
.transform(({ allowedHosts, ...daemon }) => {
const hostnames = daemon.hostnames ?? allowedHosts;
return hostnames === undefined ? daemon : { ...daemon, hostnames };
@@ -290,7 +290,7 @@ export const PersistedConfigSchema = z
.object({
baseUrl: z.string().optional(),
})
.strict()
.strip()
.optional(),
providers: ProvidersSchema.optional(),
@@ -300,7 +300,7 @@ export const PersistedConfigSchema = z
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(),
})
.strict()
.strip()
.optional(),
features: z
.object({
@@ -308,12 +308,12 @@ export const PersistedConfigSchema = z
voiceMode: FeatureVoiceModeSchema.optional(),
webUi: FeatureWebUiSchema.optional(),
})
.strict()
.strip()
.optional(),
log: LogConfigSchema.optional(),
})
.strict();
.strip();
type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>;