Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/codex/admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
CODEX_PROFILE_PATH,
DEFAULT_CATALOG_PATH,
getCodexHome,
resolveCodexStateDbPath,
} from "./paths";

export type CodexAdmission =
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 6 additions & 11 deletions src/codex/history-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,30 @@
*/
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { join } from "node:path";

import type {
CodexHistoryWorkerOperation,
HistoryWorkerResult,
} 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;
readonly canonicalStateDbPath: string;
readonly canonicalBackupPath: string;
} {
const home = getCodexHome();
const stateDb = join(home, STATE_DB_FILE);
const stateDb = resolveCodexStateDbPath({ codexHome: home });
return {
canonicalCodexHome: home,
canonicalStateDbPath: stateDb,
Expand Down
19 changes: 10 additions & 9 deletions src/codex/history-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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.
*
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 14 additions & 2 deletions src/codex/native-residue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
DEFAULT_CATALOG_PATH,
getCodexHome,
readRootTomlString,
resolveCodexStateDbPath,
} from "./paths";

export type NativeResidueSurface =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 75 additions & 1 deletion src/codex/paths.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
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);
}
Expand Down
10 changes: 8 additions & 2 deletions src/lib/winsw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -103,6 +108,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
env.CODEX_SQLITE_HOME?.trim() ? ` <env name="CODEX_SQLITE_HOME" value="${xmlEscape(windowsServicePathAbsolute(env.CODEX_SQLITE_HOME.trim()))}"/>` : null,
` <env name="OPENCODEX_HOME" value="${xmlEscape(getConfigDir())}"/>`,
aclTimeout ? ` <env name="OPENCODEX_ACL_TIMEOUT_MS" value="${xmlEscape(aclTimeout)}"/>` : null,
].filter((line): line is string => Boolean(line));
Expand Down Expand Up @@ -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") };
}
}
19 changes: 18 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -366,13 +378,15 @@ 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 = [
` <key>OCX_SERVICE</key><string>1</string>`,
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
` <key>PATH</key><string>${plistString(path)}</string>`,
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
].filter((line): line is string => Boolean(line)).join("\n");
const command = buildServiceShellCommand(bun, cli);
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -2049,13 +2064,15 @@ 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"),
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
systemdEnvironmentAssignment("PATH", path),
codexHome,
codexSqliteHome,
opencodexHome,
].filter((line): line is string => Boolean(line)).join("\n");
return `[Unit]
Expand Down
Loading
Loading