refactor(config): use schema stripping for persisted fields

This commit is contained in:
Mohamed Boudra
2026-07-02 18:44:17 +02:00
parent 5616c6af6a
commit 9eec33773a
3 changed files with 34 additions and 123 deletions
+2 -3
View File
@@ -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.
@@ -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" });
});
});
+29 -117
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>;
@@ -345,10 +345,6 @@ 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);
}
@@ -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