diff --git a/src/codex-account-store.ts b/src/codex-account-store.ts index 1884af7ef36..d4602a84fc1 100644 --- a/src/codex-account-store.ts +++ b/src/codex-account-store.ts @@ -3,17 +3,21 @@ import { join } from "node:path"; import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "./config"; import type { CodexAccountCredentials } from "./types"; -const CODEX_ACCOUNTS_PATH = join(getConfigDir(), "codex-accounts.json"); type CodexAccountStore = Record; const REFRESH_SKEW_MS = 60_000; +function getCodexAccountsPath(): string { + return join(getConfigDir(), "codex-accounts.json"); +} + export function loadCodexAccountStore(): CodexAccountStore { + const path = getCodexAccountsPath(); hardenConfigDir(); - hardenExistingSecret(CODEX_ACCOUNTS_PATH); - if (!existsSync(CODEX_ACCOUNTS_PATH)) return {}; + hardenExistingSecret(path); + if (!existsSync(path)) return {}; try { - return JSON.parse(readFileSync(CODEX_ACCOUNTS_PATH, "utf-8")) as CodexAccountStore; + return JSON.parse(readFileSync(path, "utf-8")) as CodexAccountStore; } catch { return {}; } @@ -22,7 +26,7 @@ export function loadCodexAccountStore(): CodexAccountStore { function persist(store: CodexAccountStore): void { const dir = getConfigDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - atomicWriteFile(CODEX_ACCOUNTS_PATH, JSON.stringify(store, null, 2) + "\n"); + atomicWriteFile(getCodexAccountsPath(), JSON.stringify(store, null, 2) + "\n"); } export function getCodexAccountCredential(id: string): CodexAccountCredentials | null { diff --git a/src/config.ts b/src/config.ts index 4542665681f..93b64c6d480 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { z } from "zod"; import type { OcxConfig } from "./types"; let _atomicSeq = 0; @@ -15,8 +16,23 @@ export function atomicWriteFile(path: string, content: string): void { } const OCX_DIR = join(homedir(), ".opencodex"); -const CONFIG_PATH = join(OCX_DIR, "config.json"); -const PID_PATH = join(OCX_DIR, "ocx.pid"); +const CONFIG_FILE = "config.json"; +const PID_FILE = "ocx.pid"; +const warnedConfigFallbacks = new Set(); + +const providerConfigSchema = z.object({ + adapter: z.string().min(1), + baseUrl: z.string().min(1), +}).passthrough(); + +const configSchema = z.object({ + port: z.number().int().positive().default(10100), + providers: z.record(z.string(), providerConfigSchema).refine( + providers => Object.keys(providers).length > 0, + "providers must contain at least one provider", + ), + defaultProvider: z.string().min(1), +}).passthrough(); /** * Default featured subagent models (native GPT) seeded on a fresh install and when `subagentModels` @@ -28,20 +44,22 @@ const PID_PATH = join(OCX_DIR, "ocx.pid"); export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"]; export function getConfigDir(): string { - return OCX_DIR; + const override = process.env.OPENCODEX_HOME?.trim(); + return override || OCX_DIR; } export function getConfigPath(): string { - return CONFIG_PATH; + return join(getConfigDir(), CONFIG_FILE); } export function getPidPath(): string { - return PID_PATH; + return join(getConfigDir(), PID_FILE); } export function hardenConfigDir(): void { - if (existsSync(OCX_DIR)) { - try { chmodSync(OCX_DIR, 0o700); } catch { /* best-effort */ } + const dir = getConfigDir(); + if (existsSync(dir)) { + try { chmodSync(dir, 0o700); } catch { /* best-effort */ } } } @@ -52,27 +70,30 @@ export function hardenExistingSecret(path: string): void { } export function loadConfig(): OcxConfig { + const configPath = getConfigPath(); hardenConfigDir(); - hardenExistingSecret(CONFIG_PATH); - hardenExistingSecret(join(OCX_DIR, "auth.json")); - if (!existsSync(CONFIG_PATH)) { + hardenExistingSecret(configPath); + hardenExistingSecret(join(getConfigDir(), "auth.json")); + if (!existsSync(configPath)) { return getDefaultConfig(); } try { - const raw = readFileSync(CONFIG_PATH, "utf-8"); - return JSON.parse(raw) as OcxConfig; - } catch { + const raw = readFileSync(configPath, "utf-8"); + return configSchema.parse(JSON.parse(raw)) as OcxConfig; + } catch (error) { + warnAndBackupInvalidConfig(configPath, error); return getDefaultConfig(); } } export function saveConfig(config: OcxConfig): void { - if (!existsSync(OCX_DIR)) { - mkdirSync(OCX_DIR, { recursive: true, mode: 0o700 }); + const dir = getConfigDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); } else { - try { chmodSync(OCX_DIR, 0o700); } catch { /* best-effort on existing dir */ } + try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ } } - atomicWriteFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); + atomicWriteFile(getConfigPath(), JSON.stringify(config, null, 2) + "\n"); } export function websocketsEnabled(config: Pick): boolean { @@ -112,18 +133,20 @@ export function resolveEnvValue(value: string | undefined): string | undefined { } export function writePid(pid: number): void { - if (!existsSync(OCX_DIR)) { - mkdirSync(OCX_DIR, { recursive: true, mode: 0o700 }); + const dir = getConfigDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); } else { hardenConfigDir(); } - writeFileSync(PID_PATH, String(pid), "utf-8"); + writeFileSync(getPidPath(), String(pid), "utf-8"); } export function readPid(): number | null { - if (!existsSync(PID_PATH)) return null; + const pidPath = getPidPath(); + if (!existsSync(pidPath)) return null; try { - const raw = readFileSync(PID_PATH, "utf-8").trim(); + const raw = readFileSync(pidPath, "utf-8").trim(); const pid = parseInt(raw, 10); if (isNaN(pid)) return null; try { @@ -141,6 +164,32 @@ export function readPid(): number | null { export function removePid(): void { try { const { unlinkSync } = require("node:fs"); - unlinkSync(PID_PATH); + unlinkSync(getPidPath()); } catch { /* ignore */ } } + +function warnAndBackupInvalidConfig(configPath: string, error: unknown): void { + const key = configPath; + if (warnedConfigFallbacks.has(key)) return; + warnedConfigFallbacks.add(key); + + const backupPath = backupInvalidConfig(configPath); + const reason = error instanceof z.ZodError + ? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ") + : error instanceof Error ? error.message : String(error); + const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : ""; + console.error(`⚠️ Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`); +} + +function backupInvalidConfig(configPath: string): string | null { + if (!existsSync(configPath)) return null; + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = `${configPath}.invalid-${stamp}`; + try { + copyFileSync(configPath, backupPath); + try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ } + return backupPath; + } catch { + return null; + } +} diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index f67b369cacc..049d16646c8 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-accounts-test"); @@ -53,4 +53,21 @@ describe("codex-account-store CRUD", () => { const { getValidCodexToken } = await import("../src/codex-account-store"); expect(getValidCodexToken("nonexistent")).rejects.toThrow("not found"); }); + + test("resolves OPENCODEX_HOME at write time, not import time", async () => { + const store = await import("../src/codex-account-store"); + const otherDir = `${TEST_DIR}-other`; + if (existsSync(otherDir)) rmSync(otherDir, { recursive: true }); + mkdirSync(otherDir, { recursive: true }); + try { + process.env.OPENCODEX_HOME = otherDir; + + store.saveCodexAccountCredential("late", { accessToken: "t", refreshToken: "r", expiresAt: 0, chatgptAccountId: "c" }); + + expect(existsSync(join(otherDir, "codex-accounts.json"))).toBe(true); + expect(existsSync(ACCOUNTS_PATH)).toBe(false); + } finally { + rmSync(otherDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index f08bec2bbd5..ec3caf6062f 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1,7 +1,22 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota } from "../src/codex-auth-api"; +const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); + describe("codex-auth API", () => { + beforeEach(() => { + process.env.OPENCODEX_HOME = TEST_DIR; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + test("GET /api/codex-auth/accounts returns array with main", async () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); const url = new URL(req.url); diff --git a/tests/config.test.ts b/tests/config.test.ts index 1fd8cea9a4e..9910bca6407 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,21 @@ -import { describe, expect, test } from "bun:test"; -import { codexAutoStartEnabled, getDefaultConfig } from "../src/config"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { codexAutoStartEnabled, getConfigPath, getDefaultConfig, loadConfig } from "../src/config"; + +let testDir = ""; + +beforeEach(() => { + testDir = join(tmpdir(), `ocx-config-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + process.env.OPENCODEX_HOME = testDir; + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); +}); describe("opencodex config defaults", () => { test("Codex autostart is enabled by default", () => { @@ -11,4 +27,50 @@ describe("opencodex config defaults", () => { expect(codexAutoStartEnabled({ codexAutoStart: false })).toBe(false); expect(codexAutoStartEnabled({ codexAutoStart: true })).toBe(true); }); + + test("loads valid config from OPENCODEX_HOME", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + defaultProvider: "custom", + })); + + expect(loadConfig()).toMatchObject({ + port: 12345, + defaultProvider: "custom", + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + }); + }); + + test("backs up invalid JSON config before falling back to defaults", () => { + writeFileSync(getConfigPath(), "{ invalid json"); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + const loaded = loadConfig(); + + expect(loaded).toEqual(getDefaultConfig()); + const backups = readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-")); + expect(backups).toHaveLength(1); + expect(readFileSync(join(testDir, backups[0]), "utf-8")).toBe("{ invalid json"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Could not load opencodex config")); + errorSpy.mockRestore(); + }); + + test("backs up structurally invalid config before falling back to defaults", () => { + writeFileSync(getConfigPath(), JSON.stringify({ port: 10100 })); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + const loaded = loadConfig(); + + expect(loaded).toEqual(getDefaultConfig()); + const backups = readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-")); + expect(backups).toHaveLength(1); + expect(JSON.parse(readFileSync(join(testDir, backups[0]), "utf-8"))).toEqual({ port: 10100 }); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("providers")); + errorSpy.mockRestore(); + }); }); diff --git a/tests/session-affinity.test.ts b/tests/session-affinity.test.ts index 42f8b87cea3..ee4968ee291 100644 --- a/tests/session-affinity.test.ts +++ b/tests/session-affinity.test.ts @@ -1,8 +1,12 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; import { resolveCodexAccountForThread, clearThreadAccountMap } from "../src/server"; import { updateAccountQuota, clearAccountQuota } from "../src/codex-auth-api"; import type { OcxConfig } from "../src/types"; +const TEST_DIR = join(import.meta.dir, ".tmp-session-affinity-test"); + function makeConfig(overrides: Partial = {}): OcxConfig { return { providers: {}, @@ -15,11 +19,16 @@ function makeConfig(overrides: Partial = {}): OcxConfig { describe("resolveCodexAccountForThread", () => { beforeEach(() => { + process.env.OPENCODEX_HOME = TEST_DIR; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); clearThreadAccountMap(); clearAccountQuota(); }); afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); clearAccountQuota(); clearThreadAccountMap(); });