diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 2c243a3fd..39e33fc0c 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -154,6 +154,16 @@ checks for a single Windows Codex Desktop home at `/mnt/c/Users/*/.codex/config. one candidate exists, it uses that directory so WSL app-server mode and Windows Codex Desktop share the same config and auth files. Set `CODEX_HOME` explicitly to override this detection. +Codex can keep SQLite-backed thread state in a separate directory. OpenCodex history operations use +the same precedence as Codex: root `sqlite_home` in `config.toml`, then `CODEX_SQLITE_HOME`, then the +effective `CODEX_HOME`. Relative SQLite homes resolve from the current working directory. When an +explicit `CODEX_SQLITE_HOME` is present during service installation or repair, the durable launcher +stores its install-time absolute path so the background proxy continues to address the same database. +If `config.toml` or its root `sqlite_home` key is absent, OpenCodex continues to the +environment/home fallback. If the file cannot be read or parsed, or the key is present but blank or +not a string, SQLite-home resolution stops instead of risking history operations against a different +database. + On Windows, an Orca shell can set both `CODEX_HOME` and `ORCA_CODEX_HOME` to Orca's bundled runtime home while the ChatGPT/Codex app still reads `%USERPROFILE%\\.codex`. `ocx status` and `ocx doctor` warn about this exact mismatch and print redacted target paths. If a background service was installed diff --git a/src/codex/admission.ts b/src/codex/admission.ts index ca26e2204..a4b43dafe 100644 --- a/src/codex/admission.ts +++ b/src/codex/admission.ts @@ -33,6 +33,7 @@ import { CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, + resolveCodexStateDbPath, } from "./paths"; export type CodexAdmission = @@ -162,7 +163,7 @@ export function admitCodexWrite(deps: AdmissionDeps = {}): CodexAdmission { const config = diagnostics.config; const opencodexHome = getConfigDir(); const integrationRecord = join(opencodexHome, "integrations", "codex.json"); - const historyDb = join(codexHome, "state_5.sqlite"); + const historyDb = resolveCodexStateDbPath({ codexHome }); const canonicalTargets = { codexHome, diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index 908f7ef45..1ffd51f1a 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -18,7 +18,6 @@ */ import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; -import { join } from "node:path"; import type { CodexHistoryWorkerOperation, @@ -26,19 +25,15 @@ import type { } from "./history-worker"; import { historyBackupPathFor } from "./history-provider"; import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider"; -import { getCodexHome } from "./paths"; - -/** Where Codex keeps its resume history, and the manifest that shadows it. */ -const STATE_DB_FILE = "state_5.sqlite"; +import { getCodexHome, resolveCodexStateDbPath } from "./paths"; /** * Resolve the paths a history job needs, at CALL time. * - * `history-provider.ts` resolves its equivalents at module load (`:16`, `:22`), - * which is fine in one process and wrong for a Worker: the Worker does not - * inherit them, so anything derived from those constants would address a - * different home than the caller intended. Resolving here also means a test that - * moves `CODEX_HOME` is honoured rather than ignored. + * The SQLite root can differ from CODEX_HOME and both environment/config inputs + * can change between invocations. The parent resolves one exact target and hands + * those canonical paths to the Worker rather than asking the Worker to infer a + * possibly different environment. */ export function resolveCodexHistoryJobTarget(): { readonly canonicalCodexHome: string; @@ -46,7 +41,7 @@ export function resolveCodexHistoryJobTarget(): { readonly canonicalBackupPath: string; } { const home = getCodexHome(); - const stateDb = join(home, STATE_DB_FILE); + const stateDb = resolveCodexStateDbPath({ codexHome: home }); return { canonicalCodexHome: home, canonicalStateDbPath: stateDb, diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 5717e5710..f2150c841 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -3,7 +3,7 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re import { dirname, join, resolve } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { Database } from "bun:sqlite"; -import { CODEX_HOME } from "./paths"; +import { resolveCodexStateDbPath } from "./paths"; import { atomicWriteFile, getConfigDir } from "../config"; /** @@ -13,7 +13,6 @@ import { atomicWriteFile, getConfigDir } from "../config"; */ export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024; -const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite"); /** * The manifest that shadows one state database. * @@ -26,7 +25,6 @@ export function historyBackupPathFor(stateDbPath: string): string { const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16); return join(getConfigDir(), `codex-history-backup-${id}.json`); } -const HISTORY_BACKUP_PATH = historyBackupPathFor(STATE_DB_PATH); const RESUMABLE_SOURCES = ["cli", "vscode"] as const; /** @@ -690,8 +688,8 @@ function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean { export function syncCodexHistoryProvider( provider: CodexHistoryProvider, - stateDbPath = STATE_DB_PATH, - backupPath = HISTORY_BACKUP_PATH, + stateDbPath = resolveCodexStateDbPath(), + backupPath = historyBackupPathFor(stateDbPath), opts: { skipWhenProvablyNoop?: boolean } = {}, ): CodexHistorySyncResult { // Opt-in steady-state gate (Design B loopback callers only): default semantics of @@ -824,7 +822,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C } } -export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): CodexHistorySyncResult { +export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath()): CodexHistorySyncResult { if (!existsSync(stateDbPath)) return { rows: 0, files: 0 }; const retried = withHistoryRetryResult(() => { const db = openStateDb(stateDbPath); @@ -844,8 +842,8 @@ export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): CodexHi * per tick so a locked DB never stalls the event loop beyond one sqlite busy wait. */ export function migrateHistoryToOpenai( - stateDbPath = STATE_DB_PATH, - backupPath = HISTORY_BACKUP_PATH, + stateDbPath = resolveCodexStateDbPath(), + backupPath = historyBackupPathFor(stateDbPath), opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {}, ): CodexHistorySyncResult { if (!existsSync(stateDbPath)) return { rows: 0, files: 0 }; @@ -948,7 +946,10 @@ export interface PendingHistoryCount { * pending predicate mirrors ejectRemainingOpencodexHistory exactly — rows eject ignores * (empty first_user_message) are not counted, so 0 really means "migration done". */ -export function countPendingOpencodexHistory(stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): PendingHistoryCount { +export function countPendingOpencodexHistory( + stateDbPath = resolveCodexStateDbPath(), + backupPath = historyBackupPathFor(stateDbPath), +): PendingHistoryCount { let backupEntries = 0; try { const manifest = readBackup(backupPath, stateDbPath); diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index cc5425215..c7db494de 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -30,6 +30,7 @@ import { DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, + resolveCodexStateDbPath, } from "./paths"; export type NativeResidueSurface = @@ -77,7 +78,6 @@ const PROFILE_FILE_NAME = basename(CODEX_PROFILE_PATH); const CATALOG_FILE_NAME = basename(DEFAULT_CATALOG_PATH); const MODELS_CACHE_FILE_NAME = basename(CODEX_MODELS_CACHE_PATH); const JOURNAL_FILE_NAME = "opencodex-journal.json"; -const HISTORY_DATABASE_FILE_NAME = "state_5.sqlite"; const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → "; const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024; const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024; @@ -640,8 +640,20 @@ export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { return indeterminate("partial-write", unresolved, `CODEX_HOME cannot be resolved: ${errorReason(error)}`); } - const stateDatabasePath = join(codexHome, HISTORY_DATABASE_FILE_NAME); const configPath = join(codexHome, CONFIG_FILE_NAME); + let stateDatabasePath: string; + try { + stateDatabasePath = resolveCodexStateDbPath({ codexHome }); + } catch (error) { + // Residue classification is a total, read-only safety boundary. An + // indeterminate SQLite authority must refuse coordination without escaping + // as an exception or falling through to a different state database. + return indeterminate( + "config", + configPath, + `SQLite home cannot be resolved: ${errorReason(error)}`, + ); + } const profilePath = join(codexHome, PROFILE_FILE_NAME); const modelsCachePath = join(codexHome, MODELS_CACHE_FILE_NAME); const journalPath = join(codexHome, JOURNAL_FILE_NAME); diff --git a/src/codex/paths.ts b/src/codex/paths.ts index 362bf09f1..503c57e55 100644 --- a/src/codex/paths.ts +++ b/src/codex/paths.ts @@ -1,4 +1,4 @@ -import { realpathSync, statSync } from "node:fs"; +import { readFileSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { expandUserPath } from "../config"; import { defaultCodexHome } from "./home"; @@ -34,6 +34,80 @@ export function getCodexHome(): string { return resolveCodexHome(); } +export interface CodexSqliteHomeDeps { + env?: NodeJS.ProcessEnv; + cwd?: () => string; + codexHome?: string; + readConfig?: (path: string) => string; +} + +type RootTomlStringState = + | { kind: "absent" } + | { kind: "value"; value: string } + | { kind: "invalid" }; + +/** + * Parse the authoritative SQLite setting without changing the tolerant helper + * used by injection/catalog readers. History ownership must distinguish a + * missing key from a present value that Codex cannot interpret as a path. + */ +function readAuthoritativeRootTomlString(content: string, key: string): RootTomlStringState { + let parsed: unknown; + try { + parsed = Bun.TOML.parse(content); + } catch { + return { kind: "invalid" }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "invalid" }; + const root = parsed as Record; + if (!Object.prototype.hasOwnProperty.call(root, key)) return { kind: "absent" }; + const value = root[key]; + if (typeof value !== "string" || value.trim() === "") return { kind: "invalid" }; + return { kind: "value", value: value.trim() }; +} + +/** + * Resolve Codex's SQLite state root at call time. + * + * Codex permits SQLite-backed state to live outside CODEX_HOME, especially for + * Windows Desktop sessions whose app-server runs in WSL. Keep the precedence + * identical to Codex: root config, then environment, then the effective home. + */ +export function resolveCodexSqliteHome(deps: CodexSqliteHomeDeps = {}): string { + const codexHome = deps.codexHome ?? getCodexHome(); + const readConfig = deps.readConfig ?? (path => readFileSync(path, "utf8")); + const configPath = join(codexHome, "config.toml"); + let configured: RootTomlStringState = { kind: "absent" }; + try { + configured = readAuthoritativeRootTomlString(readConfig(configPath), "sqlite_home"); + } catch (cause) { + if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + throw new Error( + `Codex config could not be read while resolving sqlite_home: ${configPath}`, + { cause }, + ); + } + // A genuinely absent config cannot contain the authoritative root override, + // so only ENOENT may continue to the documented environment/home fallback. + } + if (configured.kind === "invalid") { + throw new Error(`Codex config has an invalid sqlite_home setting: ${configPath}`); + } + const raw = configured.kind === "value" + ? configured.value + : (deps.env ?? process.env).CODEX_SQLITE_HOME?.trim() || ""; + if (!raw) return codexHome; + const expanded = expandUserPath(raw); + return isAbsolute(expanded) + ? resolve(expanded) + : resolve((deps.cwd ?? process.cwd)(), expanded); +} + +/** Active Codex thread-state database, derived from the call-time SQLite root. */ +export function resolveCodexStateDbPath(deps: CodexSqliteHomeDeps = {}): string { + return join(resolveCodexSqliteHome(deps), "state_5.sqlite"); +} + export function tomlString(value: string): string { return JSON.stringify(value); } diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index ea7558fb2..f1dc60ffb 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -17,7 +17,7 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir, loadConfig } from "../config"; import { recordOwnedConfigPath } from "./config-ownership"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime"; @@ -63,6 +63,11 @@ function currentCodexHomeAbsolute(): string { return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex"); } +function windowsServicePathAbsolute(raw: string): string { + const expanded = expandUserPath(raw); + return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded); +} + export interface WinswEntry { bun: string; /** Provenance of `bun`, resolved together with it so the two can never disagree. */ @@ -103,6 +108,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces ` `, ` `, env.CODEX_HOME?.trim() ? ` ` : null, + env.CODEX_SQLITE_HOME?.trim() ? ` ` : null, ` `, aclTimeout ? ` ` : null, ].filter((line): line is string => Boolean(line)); @@ -394,4 +400,4 @@ export function winswStatusSummary(): string { export function defaultWinswEntry(cliDir: string): WinswEntry { const runtime = durableBunRuntime(); return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") }; -} \ No newline at end of file +} diff --git a/src/service.ts b/src/service.ts index 2abdb29fb..d976df203 100644 --- a/src/service.ts +++ b/src/service.ts @@ -9,7 +9,7 @@ import { execFileSync, execSync, spawnSync } from "node:child_process"; import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness"; import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config"; import { loadConfig } from "./config"; import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject"; @@ -101,6 +101,18 @@ function currentCodexHome(deps: CodexHomeDeps = {}): string { return resolveCodexHomeDir(deps); } +function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined { + const raw = process.env.CODEX_SQLITE_HOME?.trim(); + if (!raw) return undefined; + const expanded = expandUserPath(raw); + // Windows service artifacts can be rendered by cross-platform tests and + // repair tooling. Preserve an already-absolute drive/UNC path instead of + // anchoring it beneath the current POSIX worktree. + return target === "windows" && win32.isAbsolute(expanded) + ? win32.normalize(expanded) + : resolve(expanded); +} + function currentOpenCodexHome(): string { // getConfigDir() already resolves OPENCODEX_HOME with ~ expansion; keep the // install-state comparison on the same normalization or `~/...` values falsely @@ -366,6 +378,7 @@ export function buildPlist(): string { const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = process.env.CODEX_HOME?.trim(); + const codexSqliteHome = currentCodexSqliteHomeAbsolute(); const opencodexHome = process.env.OPENCODEX_HOME?.trim(); const envLines = [ ` OCX_SERVICE1`, @@ -373,6 +386,7 @@ export function buildPlist(): string { ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, + codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, ].filter((line): line is string => Boolean(line)).join("\n"); const command = buildServiceShellCommand(bun, cli); @@ -1457,6 +1471,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"), windowsBatchSet("PATH", path, "pathList"), windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), + windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"), windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), @@ -2049,6 +2064,7 @@ export function buildUnit(): string { const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim()); + const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute()); const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()); const envLines = [ systemdEnvironmentAssignment("OCX_SERVICE", "1"), @@ -2056,6 +2072,7 @@ export function buildUnit(): string { systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), systemdEnvironmentAssignment("PATH", path), codexHome, + codexSqliteHome, opencodexHome, ].filter((line): line is string => Boolean(line)).join("\n"); return `[Unit] diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index c754164fc..9d3c8440c 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -34,6 +34,27 @@ records are never migrated implicitly. - 다른 대안 대신 이 방식을 선택한 이유: It preserves explicit overrides and the existing WSL ambiguity rules without rewriting user environment or foreign state. - 장점, 단점 및 영향: New installs and same-environment repairs agree with runtime targeting; genuinely foreign or ambiguous state remains fail-closed. +SQLite-backed thread state may live outside `CODEX_HOME`. The one resolver in `src/codex/paths.ts` +uses Codex's precedence: root `sqlite_home` in the effective `config.toml`, then +`CODEX_SQLITE_HOME`, then the effective `CODEX_HOME`; relative SQLite homes resolve from the current +working directory. History jobs resolve the database and its hashed backup identity together at +call time, and admission/residue checks consume the same database path. Storage retention still +owns the Codex-home tree separately and does not gain deletion authority over an external SQLite +root from this resolver alone. Durable service launchers preserve an explicitly supplied +`CODEX_SQLITE_HOME` so a background service resolves the same split state as the installing shell. +An absent `config.toml` or absent root `sqlite_home` permits the environment/home fallback. Any +other read failure, malformed TOML, wrong-typed or blank `sqlite_home` is indeterminate and fails +closed so history code cannot select a different database by accident. This strict parse is scoped +to SQLite ownership; the tolerant root-string helper used by injection and catalog reads is unchanged. + +[Decision Log] +- 목적과 의도: Make every history safety check and mutation address the SQLite database Codex actually opened. +- 기존 구현 및 제약 조건: History code rebuilt `CODEX_HOME/state_5.sqlite`, while Codex supports a config or environment-selected SQLite root for split Windows/WSL layouts. +- 검토한 주요 대안: Copy the database into CODEX_HOME, teach only the writer about the override, or centralize the call-time target. +- 선택한 방식: Add one Codex-compatible SQLite resolver, fail closed when its authoritative config is unreadable or its present `sqlite_home` cannot be parsed as a non-empty string, and share it across history jobs, provider defaults, admission, and residue classification. +- 다른 대안 대신 이 방식을 선택한 이유: A writer-only override would let ownership checks authorize one database while the mutation touched another. +- 장점, 단점 및 영향: Split-home history remains correct and backup identities stay database-specific; storage cleanup of an external root remains out of scope. + Native-main profile ownership is bound to the real `CODEX_HOME`, not to an OpenCodex instance. Its encrypted vault, transaction journal, recovery marker, and referenced quarantine files live in the owner-only `.opencodex-native-main-profiles` directory. The unchanged diff --git a/tests/codex-native-residue.test.ts b/tests/codex-native-residue.test.ts index b8d6e9ffa..604afb27f 100644 --- a/tests/codex-native-residue.test.ts +++ b/tests/codex-native-residue.test.ts @@ -546,6 +546,16 @@ test("duplicate configured catalog paths are indeterminate", () => { }); }); +test("an invalid sqlite_home is indeterminate instead of selecting a fallback database", () => { + writeFileSync(pathInCodexHome("config.toml"), "sqlite_home = 123\n"); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "config", + path: canonicalPathInCodexHome("config.toml"), + }); +}); + const arbitraryComboAlias = randomUUID(); test(`production-generated arbitrary bare combo alias ${arbitraryComboAlias} is routed residue`, async () => { diff --git a/tests/codex-sqlite-home.test.ts b/tests/codex-sqlite-home.test.ts new file mode 100644 index 000000000..e751a6db9 --- /dev/null +++ b/tests/codex-sqlite-home.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { resolveCodexHistoryJobTarget } from "../src/codex/history-job"; +import { historyBackupPathFor } from "../src/codex/history-provider"; +import { resolveCodexSqliteHome, resolveCodexStateDbPath } from "../src/codex/paths"; + +const originalCodexHome = process.env.CODEX_HOME; +const originalSqliteHome = process.env.CODEX_SQLITE_HOME; +const roots: string[] = []; + +afterEach(() => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + if (originalSqliteHome === undefined) delete process.env.CODEX_SQLITE_HOME; + else process.env.CODEX_SQLITE_HOME = originalSqliteHome; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex SQLite home resolution", () => { + test("uses config, environment, and Codex home precedence", () => { + const env = { CODEX_SQLITE_HOME: "/state/from-env" }; + expect(resolveCodexSqliteHome({ + codexHome: "/state/codex-home", + env, + readConfig: () => 'sqlite_home = "/state/from-config"\n', + })).toBe(resolve("/state/from-config")); + + expect(resolveCodexSqliteHome({ + codexHome: "/state/codex-home", + env, + readConfig: () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }, + })).toBe(resolve("/state/from-env")); + + expect(resolveCodexSqliteHome({ + codexHome: "/state/codex-home", + env: {}, + readConfig: () => "", + })).toBe("/state/codex-home"); + }); + + test("fails closed when the authoritative config cannot be read", () => { + const readError = Object.assign(new Error("permission denied"), { code: "EACCES" }); + expect(() => resolveCodexSqliteHome({ + codexHome: "/state/codex-home", + env: { CODEX_SQLITE_HOME: "/state/from-env" }, + readConfig: () => { throw readError; }, + })).toThrow("Codex config could not be read while resolving sqlite_home"); + }); + + test("fails closed when sqlite_home is present but invalid", () => { + for (const config of [ + "sqlite_home = 123\n", + "sqlite_home = [\"/state/a\"]\n", + "sqlite_home = \"\"\n", + "sqlite_home = \"unterminated\n", + ]) { + expect(() => resolveCodexSqliteHome({ + codexHome: "/state/codex-home", + env: { CODEX_SQLITE_HOME: "/state/from-env" }, + readConfig: () => config, + })).toThrow("Codex config has an invalid sqlite_home setting"); + } + }); + + test("accepts a valid quoted root sqlite_home key", () => { + expect(resolveCodexSqliteHome({ + codexHome: "/state/codex-home", + env: { CODEX_SQLITE_HOME: "/state/from-env" }, + readConfig: () => '"sqlite_home" = "/state/from-quoted-key"\n', + })).toBe(resolve("/state/from-quoted-key")); + }); + + test("resolves relative SQLite homes from the current working directory", () => { + const deps = { + codexHome: "/state/codex-home", + env: { CODEX_SQLITE_HOME: "../sqlite" }, + cwd: () => "/work/project", + readConfig: () => "", + }; + expect(resolveCodexSqliteHome(deps)).toBe("/work/sqlite"); + expect(resolveCodexStateDbPath(deps)).toBe("/work/sqlite/state_5.sqlite"); + }); + + test("history jobs resolve the selected database and backup identity at call time", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-sqlite-home-")); + roots.push(root); + const codexHome = join(root, "codex"); + const envSqliteHome = join(root, "env-sqlite"); + const configSqliteHome = join(root, "config-sqlite"); + mkdirSync(codexHome); + mkdirSync(envSqliteHome); + mkdirSync(configSqliteHome); + process.env.CODEX_HOME = codexHome; + process.env.CODEX_SQLITE_HOME = envSqliteHome; + writeFileSync(join(codexHome, "config.toml"), `sqlite_home = ${JSON.stringify(configSqliteHome)}\n`); + + const configured = resolveCodexHistoryJobTarget(); + expect(configured.canonicalStateDbPath).toBe(join(configSqliteHome, "state_5.sqlite")); + expect(configured.canonicalBackupPath).toBe(historyBackupPathFor(configured.canonicalStateDbPath)); + + unlinkSync(join(codexHome, "config.toml")); + const fromEnv = resolveCodexHistoryJobTarget(); + expect(fromEnv.canonicalStateDbPath).toBe(join(envSqliteHome, "state_5.sqlite")); + expect(fromEnv.canonicalBackupPath).toBe(historyBackupPathFor(fromEnv.canonicalStateDbPath)); + }); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index a60ec8f7b..bb900aa16 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -108,14 +108,17 @@ describe("systemd service unit", () => { test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; + const oldCodexSqliteHome = process.env.CODEX_SQLITE_HOME; const oldOpenCodexHome = process.env.OPENCODEX_HOME; const oldApiAuthToken = process.env.OPENCODEX_API_AUTH_TOKEN; try { process.env.CODEX_HOME = "/tmp/codex-home"; + process.env.CODEX_SQLITE_HOME = "/tmp/codex-sqlite-home"; process.env.OPENCODEX_HOME = "/tmp/opencodex-home"; process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const unit = buildUnit(); expect(unit).toContain('Environment="CODEX_HOME=/tmp/codex-home"'); + expect(unit).toContain('Environment="CODEX_SQLITE_HOME=/tmp/codex-sqlite-home"'); expect(unit).toContain('Environment="OPENCODEX_HOME=/tmp/opencodex-home"'); expectTextToContainPath(unit, serviceApiTokenFilePath()); expect(unit).not.toContain("local-secret"); @@ -123,6 +126,8 @@ describe("systemd service unit", () => { } finally { if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexSqliteHome === undefined) delete process.env.CODEX_SQLITE_HOME; + else process.env.CODEX_SQLITE_HOME = oldCodexSqliteHome; if (oldOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOpenCodexHome; if (oldApiAuthToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; @@ -539,10 +544,12 @@ describe("Windows service task", () => { test("writes token-safe startup identity and child output to the service log", () => { const oldCodexHome = process.env.CODEX_HOME; + const oldCodexSqliteHome = process.env.CODEX_SQLITE_HOME; const oldOpenCodexHome = process.env.OPENCODEX_HOME; const oldApiAuthToken = process.env.OPENCODEX_API_AUTH_TOKEN; try { process.env.CODEX_HOME = "C:\\codex-home"; + process.env.CODEX_SQLITE_HOME = "C:\\codex-sqlite-home"; process.env.OPENCODEX_HOME = TEST_DIR; process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const script = buildWindowsServiceScript({ @@ -559,6 +566,7 @@ describe("Windows service task", () => { expect(script).toContain('echo cli="%OCX_CLI%"'); expect(script).toContain('echo opencodex_home="%OPENCODEX_HOME%"'); expect(script).toContain('echo codex_home="%CODEX_HOME%"'); + expect(script).toContain('set "CODEX_SQLITE_HOME=C:\\codex-sqlite-home"'); expect(script).toContain('echo token_file="%OCX_API_TOKEN_FILE%"'); expect(script).toMatch(/"%OCX_BUN%" "%OCX_CLI%" start --port \d+ >>"%OCX_SERVICE_LOG%" 2>&1/); expect(script).toContain("child exited with code %ERRORLEVEL%"); @@ -567,6 +575,8 @@ describe("Windows service task", () => { } finally { if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexSqliteHome === undefined) delete process.env.CODEX_SQLITE_HOME; + else process.env.CODEX_SQLITE_HOME = oldCodexSqliteHome; if (oldOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOpenCodexHome; if (oldApiAuthToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; @@ -623,14 +633,17 @@ describe("launchd service plist", () => { test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; + const oldCodexSqliteHome = process.env.CODEX_SQLITE_HOME; const oldOpenCodexHome = process.env.OPENCODEX_HOME; const oldApiAuthToken = process.env.OPENCODEX_API_AUTH_TOKEN; try { process.env.CODEX_HOME = "/tmp/codex-home"; + process.env.CODEX_SQLITE_HOME = "/tmp/codex-sqlite-home"; process.env.OPENCODEX_HOME = "/tmp/opencodex-home"; process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const plist = buildPlist(); expect(plist).toContain("CODEX_HOME/tmp/codex-home"); + expect(plist).toContain("CODEX_SQLITE_HOME/tmp/codex-sqlite-home"); expect(plist).toContain("OPENCODEX_HOME/tmp/opencodex-home"); expectTextToContainPath(plist, serviceApiTokenFilePath()); expect(plist).not.toContain("local-secret"); @@ -638,6 +651,8 @@ describe("launchd service plist", () => { } finally { if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexSqliteHome === undefined) delete process.env.CODEX_SQLITE_HOME; + else process.env.CODEX_SQLITE_HOME = oldCodexSqliteHome; if (oldOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOpenCodexHome; if (oldApiAuthToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index 40fb435e0..f42ed31c4 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -20,7 +20,13 @@ function winswEnvValue(xml: string, name: string): string | null { } describe("winsw xml", () => { - const env = { USERDOMAIN: "WORKGROUP", USERNAME: "jun", PATH: "C:\\bin;C:\\tools & more" } as NodeJS.ProcessEnv; + const env = { + USERDOMAIN: "WORKGROUP", + USERNAME: "jun", + PATH: "C:\\bin;C:\\tools & more", + CODEX_HOME: "C:\\Users\\jun\\.codex", + CODEX_SQLITE_HOME: "C:\\Users\\jun\\.codex-sqlite", + } as NodeJS.ProcessEnv; test("registers the user service account (v2 schema), never LocalSystem", () => { const xml = buildWinswXml(entry, env); @@ -41,6 +47,7 @@ describe("winsw xml", () => { expect(xml).toContain(''); expect(xml).toContain(''); + expect(winswEnvValue(xml, "CODEX_SQLITE_HOME")).toBe("C:\\Users\\jun\\.codex-sqlite"); expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir()); // Token VALUES never land in the XML — only file pointers / non-secret budgets. expect(xml).not.toContain("OPENCODEX_API_AUTH_TOKEN");