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.
This commit is contained in:
Mohamed Boudra
2026-07-02 18:39:46 +02:00
parent 58fce6622b
commit 5616c6af6a
3 changed files with 129 additions and 1 deletions
+4
View File
@@ -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.
@@ -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");
+89 -1
View File
@@ -345,6 +345,10 @@ interface LoggerLike {
info(...args: unknown[]): void;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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