From 9eec33773ae42b686817cc6f62573891a1561ea3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 18:44:17 +0200 Subject: [PATCH] refactor(config): use schema stripping for persisted fields --- docs/data-model.md | 5 +- .../src/server/persisted-config.test.ts | 6 +- .../server/src/server/persisted-config.ts | 146 ++++-------------- 3 files changed, 34 insertions(+), 123 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 8fec50dbe6..7eaefabfdd 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -195,9 +195,8 @@ Single file, validated with `PersistedConfigSchema`. All fields are optional with sensible defaults. -Config loading strips unrecognized object keys before validation 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, and saving remains strict. +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. diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index ad07721682..363d175ffc 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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" }); }); }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 9b99c9b8f6..c958b79901 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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; @@ -345,10 +345,6 @@ interface LoggerLike { info(...args: unknown[]): void; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function getConfigPath(paseoHome: string): string { return path.join(paseoHome, CONFIG_FILENAME); } @@ -396,90 +392,6 @@ function stripRemovedConfigFields(parsed: unknown): unknown { return root; } -function cloneConfigValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(cloneConfigValue); - } - - if (!isRecord(value)) { - return value; - } - - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, cloneConfigValue(entry)]), - ); -} - -function getValueAtPath(value: unknown, pathSegments: readonly PropertyKey[]): unknown { - let current = value; - for (const segment of pathSegments) { - if (typeof segment === "symbol") { - return undefined; - } - if (Array.isArray(current)) { - if (typeof segment !== "number") { - return undefined; - } - current = current[segment]; - continue; - } - if (!isRecord(current)) { - return undefined; - } - current = current[String(segment)]; - } - return current; -} - -function deleteKeysAtPath( - config: unknown, - pathSegments: readonly PropertyKey[], - keys: readonly string[], -): boolean { - const target = getValueAtPath(config, pathSegments); - if (!isRecord(target)) { - return false; - } - - let deleted = false; - for (const key of keys) { - if (Object.hasOwn(target, key)) { - delete target[key]; - deleted = true; - } - } - return deleted; -} - -function stripUnrecognizedConfigFields(config: unknown): unknown { - let current = config; - - while (true) { - const result = PersistedConfigSchema.safeParse(current); - if (result.success) { - return current; - } - - const unrecognizedKeyIssues = result.error.issues.filter( - (issue) => issue.code === "unrecognized_keys", - ); - if (unrecognizedKeyIssues.length === 0) { - return current; - } - - const next = cloneConfigValue(current); - let deletedAny = false; - for (const issue of unrecognizedKeyIssues) { - deletedAny = deleteKeysAtPath(next, issue.path, issue.keys) || deletedAny; - } - if (!deletedAny) { - return current; - } - - current = next; - } -} - export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): PersistedConfig { const log = getLogger(logger); const configPath = getConfigPath(paseoHome); @@ -518,7 +430,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const migrated = stripUnrecognizedConfigFields(stripRemovedConfigFields(parsed)); + const migrated = stripRemovedConfigFields(parsed); const result = PersistedConfigSchema.safeParse(migrated); if (!result.success) { const issues = result.error.issues