From 5616c6af6a2e2a4a5e01124be8d82f48570d3b17 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 18:39:46 +0200 Subject: [PATCH] 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. --- docs/data-model.md | 4 + .../src/server/persisted-config.test.ts | 36 ++++++++ .../server/src/server/persisted-config.ts | 90 ++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/data-model.md b/docs/data-model.md index 7becabdc72..8fec50dbe6 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -195,6 +195,10 @@ 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. + `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. diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index da846342d1..ad07721682 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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)?.futureDaemonSetting).toBeUndefined(); + expect((config.daemon?.relay as Record)?.futureRelaySetting).toBeUndefined(); + expect((config as Record).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"); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5bc4f3cc96..9b99c9b8f6 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -345,6 +345,10 @@ 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); } @@ -392,6 +396,90 @@ 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); @@ -430,7 +518,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const migrated = stripRemovedConfigFields(parsed); + const migrated = stripUnrecognizedConfigFields(stripRemovedConfigFields(parsed)); const result = PersistedConfigSchema.safeParse(migrated); if (!result.success) { const issues = result.error.issues