From 663e2a22e0b60f8f1948d80e070915f945adc139 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:46:14 +0900 Subject: [PATCH] fix(config): back up non-object configs instead of repairing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-level JSON array satisfies typeof object, so mergeConfigDefaults spread it into defaults and manufactured a schema-valid config — silently discarding the original file with no backup. Guard the repair path so only object-shaped configs are merged; every other JSON value takes the invalid-file backup path. --- src/config.ts | 7 +++++++ tests/server/config.test.ts | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/config.ts b/src/config.ts index 9788f252144..ba8cb74e644 100644 --- a/src/config.ts +++ b/src/config.ts @@ -247,6 +247,13 @@ export function loadConfig(): OcxConfig { warnDegradedCredentialGroups(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } + // Only object-shaped configs are repairable. Spreading another JSON value + // into defaults can manufacture a valid config and bypass the invalid-file + // backup. + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + warnAndBackupInvalidConfig(configPath, result.error); + return getDefaultConfig(); + } // Schema validation failed — merge defaults into the raw object instead of // discarding it entirely, so pool accounts and providers survive a missing // field like defaultProvider. diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index e69e932bbbe..83cbe3dce59 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -1918,6 +1918,27 @@ describe("opencodex config defaults", () => { } }); + test.each([ + ["number", "123"], + ["boolean", "true"], + ["string", JSON.stringify("not-an-object")], + ["array", "[]"], + ["null", "null"], + ])("backs up a top-level %s instead of repairing it", (_kind, raw) => { + writeConfig(raw); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + const backups = backupNames(); + expect(backups).toHaveLength(1); + expect(readFileSync(join(testDir, backups[0]), "utf-8")).toBe(raw); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Could not load opencodex config")); + } finally { + errorSpy.mockRestore(); + } + }); + test("repairs structurally incomplete config by merging defaults instead of rejecting", () => { writeConfig({ port: 10100 }); const errorSpy = spyOn(console, "error").mockImplementation(() => {});