diff --git a/src/config.ts b/src/config.ts index 3d0d1a239..bd6bd7652 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1816,6 +1816,12 @@ export function loadConfig(): OcxConfig { warnDegradedUpstreamHostCircuitThreshold(parsed); return 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/config.test.ts b/tests/config.test.ts index cca41a4d6..143e18754 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -882,6 +882,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("backs up config when defaultProvider is absent from providers", () => { writeConfig({ port: 10100,