From f62141d453546595a42370af68288557a4ec31c3 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 20 Feb 2026 11:34:27 -0500 Subject: [PATCH] fix: prevent Zod defaults from overwriting values during config merge When parsing partial configs, Zod applies .default() values to missing fields. During config merge (global + project + cli), these defaults would overwrite explicit values from earlier configs. Solution: strip defaults during parsing, apply them only to the final merged result. --- packages/opencode/src/config/config.ts | 69 ++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 31188471991..0ae7dc9cbe6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -39,6 +39,49 @@ export namespace Config { const log = Log.create({ service: "config" }) + function deepRemoveDefaults(schema: any): any { + if (schema instanceof z.ZodDefault) { + return deepRemoveDefaults(schema.removeDefault()) + } + if (schema instanceof z.ZodObject) { + const newShape: Record = {} + for (const [key, value] of Object.entries(schema.shape)) { + newShape[key] = deepRemoveDefaults(value) + } + let newObj = z.object(newShape) + const catchall = schema._def.catchall + if (catchall) { + newObj = newObj.catchall(catchall) + } + return newObj + } + if (schema instanceof z.ZodArray) { + return z.array(deepRemoveDefaults(schema.element)) + } + if (schema instanceof z.ZodOptional) { + return z.optional(deepRemoveDefaults(schema.unwrap())) + } + if (schema instanceof z.ZodNullable) { + return z.nullable(deepRemoveDefaults(schema.unwrap())) + } + if (schema instanceof z.ZodUnion) { + return z.union(schema.options.map(deepRemoveDefaults)) + } + if (schema instanceof z.ZodIntersection) { + return z.intersection(deepRemoveDefaults(schema._def.left), deepRemoveDefaults(schema._def.right)) + } + if (schema instanceof z.ZodRecord) { + return z.record(z.string(), deepRemoveDefaults(schema._def.valueType)) + } + if (schema instanceof z.ZodLazy) { + return z.lazy(() => deepRemoveDefaults(schema._def.getter())) + } + if (schema instanceof z.ZodCatch) { + return deepRemoveDefaults(schema._def.innerType) + } + return schema + } + // Managed settings directory for enterprise deployments (highest priority, admin-controlled) // These settings override all user and project settings function getManagedConfigDir(): string { @@ -252,6 +295,8 @@ export namespace Config { result.plugin = deduplicatePlugins(result.plugin ?? []) + result = Info.parse(result) + return { config: result, directories, @@ -1209,6 +1254,8 @@ export namespace Config { export type Info = z.output + const RawInfo = deepRemoveDefaults(Info) as z.ZodObject + export const global = lazy(async () => { let result: Info = pipe( {}, @@ -1317,23 +1364,23 @@ export namespace Config { }) } - const parsed = Info.safeParse(data) + const parsed = RawInfo.safeParse(data) if (parsed.success) { - if (!parsed.data.$schema && isFile) { - parsed.data.$schema = "https://opencode.ai/config.json" + const parsedData = parsed.data as Info + if (!parsedData.$schema && isFile) { + parsedData.$schema = "https://opencode.ai/config.json" const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') await Bun.write(options.path, updated).catch(() => {}) } - const data = parsed.data - if (data.plugin && isFile) { - for (let i = 0; i < data.plugin.length; i++) { - const plugin = data.plugin[i] + if (parsedData.plugin && isFile) { + for (let i = 0; i < parsedData.plugin.length; i++) { + const plugin = parsedData.plugin[i] try { - data.plugin[i] = import.meta.resolve!(plugin, options.path) + parsedData.plugin[i] = import.meta.resolve!(plugin, options.path) } catch (err) {} } } - return data + return parsedData } throw new InvalidError({ @@ -1438,8 +1485,8 @@ export namespace Config { }) } - const parsed = Info.safeParse(data) - if (parsed.success) return parsed.data + const parsed = RawInfo.safeParse(data) + if (parsed.success) return parsed.data as Info throw new InvalidError({ path: filepath,