Skip to content

Commit 4d9d9f9

Browse files
lidge-jun0disoft
andcommitted
fix: preserve invalid config before fallback
Rebuilt on current dev from PR #26 by 0disoft / ZeroDi. Co-authored-by: 0disoft <rodisoft1@gmail.com>
1 parent d0cf0aa commit 4d9d9f9

2 files changed

Lines changed: 171 additions & 5 deletions

File tree

src/config.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
1+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
22
import { homedir } from "node:os";
33
import { join } from "node:path";
4+
import * as z from "zod/v4";
45
import type { OcxConfig } from "./types";
56

67
let _atomicSeq = 0;
@@ -26,6 +27,27 @@ function resolvePidPath(): string {
2627
return join(resolveConfigDir(), "ocx.pid");
2728
}
2829

30+
const warnedConfigFallbacks = new Set<string>();
31+
32+
const providerConfigSchema = z.object({
33+
adapter: z.string().min(1),
34+
baseUrl: z.string().min(1),
35+
}).passthrough();
36+
37+
const configSchema = z.object({
38+
port: z.number().int().min(0).max(65535).default(10100),
39+
providers: z.record(z.string(), providerConfigSchema),
40+
defaultProvider: z.string().min(1),
41+
}).passthrough().superRefine((config, ctx) => {
42+
if (Object.keys(config.providers).length > 0 && !(config.defaultProvider in config.providers)) {
43+
ctx.addIssue({
44+
code: "custom",
45+
path: ["defaultProvider"],
46+
message: "defaultProvider must exist in providers",
47+
});
48+
}
49+
});
50+
2951
/**
3052
* Default featured subagent models (native GPT) seeded on a fresh install and when `subagentModels`
3153
* is unset. Codex's spawn_agent advertises the first 5 featured catalog entries; these are the GPT
@@ -71,8 +93,9 @@ export function loadConfig(): OcxConfig {
7193
}
7294
try {
7395
const raw = readFileSync(configPath, "utf-8");
74-
return JSON.parse(raw) as OcxConfig;
75-
} catch {
96+
return configSchema.parse(JSON.parse(raw)) as OcxConfig;
97+
} catch (error) {
98+
warnAndBackupInvalidConfig(configPath, error);
7699
return getDefaultConfig();
77100
}
78101
}
@@ -157,3 +180,27 @@ export function removePid(): void {
157180
unlinkSync(getPidPath());
158181
} catch { /* ignore */ }
159182
}
183+
184+
function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
185+
if (warnedConfigFallbacks.has(configPath)) return;
186+
warnedConfigFallbacks.add(configPath);
187+
188+
const backupPath = backupInvalidConfig(configPath);
189+
const reason = error instanceof z.ZodError
190+
? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ")
191+
: error instanceof Error ? error.message : String(error);
192+
const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : "";
193+
console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
194+
}
195+
196+
function backupInvalidConfig(configPath: string): string | null {
197+
if (!existsSync(configPath)) return null;
198+
const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
199+
try {
200+
copyFileSync(configPath, backupPath);
201+
try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ }
202+
return backupPath;
203+
} catch {
204+
return null;
205+
}
206+
}

tests/config.test.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1-
import { describe, expect, test } from "bun:test";
2-
import { codexAutoStartEnabled, getDefaultConfig } from "../src/config";
1+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { codexAutoStartEnabled, getConfigPath, getDefaultConfig, loadConfig } from "../src/config";
6+
7+
let testDir = "";
8+
9+
beforeEach(() => {
10+
testDir = mkdtempSync(join(tmpdir(), "ocx-config-"));
11+
process.env.OPENCODEX_HOME = testDir;
12+
});
13+
14+
afterEach(() => {
15+
delete process.env.OPENCODEX_HOME;
16+
if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
17+
testDir = "";
18+
});
19+
20+
function backupNames(): string[] {
21+
return readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-"));
22+
}
23+
24+
function writeConfig(content: unknown): void {
25+
writeFileSync(
26+
getConfigPath(),
27+
typeof content === "string" ? content : JSON.stringify(content),
28+
"utf-8",
29+
);
30+
}
331

432
describe("opencodex config defaults", () => {
533
test("Codex autostart is enabled by default", () => {
@@ -11,4 +39,95 @@ describe("opencodex config defaults", () => {
1139
expect(codexAutoStartEnabled({ codexAutoStart: false })).toBe(false);
1240
expect(codexAutoStartEnabled({ codexAutoStart: true })).toBe(true);
1341
});
42+
43+
test("loads valid config from OPENCODEX_HOME", () => {
44+
writeConfig({
45+
port: 12345,
46+
providers: {
47+
custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" },
48+
},
49+
defaultProvider: "custom",
50+
codexAutoStart: false,
51+
});
52+
53+
expect(loadConfig()).toMatchObject({
54+
port: 12345,
55+
defaultProvider: "custom",
56+
providers: {
57+
custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" },
58+
},
59+
codexAutoStart: false,
60+
});
61+
});
62+
63+
test("backs up invalid JSON config before falling back to defaults", () => {
64+
writeConfig("{ invalid json");
65+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
66+
67+
try {
68+
const loaded = loadConfig();
69+
70+
expect(loaded).toEqual(getDefaultConfig());
71+
const backups = backupNames();
72+
expect(backups).toHaveLength(1);
73+
expect(readFileSync(join(testDir, backups[0]), "utf-8")).toBe("{ invalid json");
74+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Could not load opencodex config"));
75+
} finally {
76+
errorSpy.mockRestore();
77+
}
78+
});
79+
80+
test("backs up structurally invalid config before falling back to defaults", () => {
81+
writeConfig({ port: 10100 });
82+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
83+
84+
try {
85+
const loaded = loadConfig();
86+
87+
expect(loaded).toEqual(getDefaultConfig());
88+
const backups = backupNames();
89+
expect(backups).toHaveLength(1);
90+
expect(JSON.parse(readFileSync(join(testDir, backups[0]), "utf-8"))).toEqual({ port: 10100 });
91+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("providers"));
92+
} finally {
93+
errorSpy.mockRestore();
94+
}
95+
});
96+
97+
test("backs up config when defaultProvider is absent from providers", () => {
98+
writeConfig({
99+
port: 10100,
100+
providers: {
101+
openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" },
102+
},
103+
defaultProvider: "missing",
104+
});
105+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
106+
107+
try {
108+
const loaded = loadConfig();
109+
110+
expect(loaded).toEqual(getDefaultConfig());
111+
const backups = backupNames();
112+
expect(backups).toHaveLength(1);
113+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("defaultProvider must exist in providers"));
114+
} finally {
115+
errorSpy.mockRestore();
116+
}
117+
});
118+
119+
test("warns and backs up once per invalid config path", () => {
120+
writeConfig("{ invalid json");
121+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
122+
123+
try {
124+
expect(loadConfig()).toEqual(getDefaultConfig());
125+
expect(loadConfig()).toEqual(getDefaultConfig());
126+
127+
expect(backupNames()).toHaveLength(1);
128+
expect(errorSpy).toHaveBeenCalledTimes(1);
129+
} finally {
130+
errorSpy.mockRestore();
131+
}
132+
});
14133
});

0 commit comments

Comments
 (0)