From 68d6e1ba49bbb81d10d75beb1e3a27d1d8b1eaeb Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 13:33:24 +0000 Subject: [PATCH 1/4] fix(codex): recover zero-byte coordinator remnants --- .../content/docs/guides/codex-integration.md | 25 ++ .../content/docs/reference/cli/lifecycle.md | 17 + src/cli/dispatch.ts | 4 +- src/cli/doctor.ts | 89 +++++ src/cli/help.ts | 2 + src/cli/registry.ts | 4 + src/codex/coordinator-doctor.ts | 332 ++++++++++++++++++ src/codex/inject-coordination.ts | 45 ++- src/codex/transition-state.ts | 24 +- structure/02_config-and-codex-home.md | 21 ++ tests/codex-coordinator-doctor.test.ts | 207 +++++++++++ tests/codex-inject-write-lock.test.ts | 43 ++- 12 files changed, 792 insertions(+), 21 deletions(-) create mode 100644 src/codex/coordinator-doctor.ts create mode 100644 tests/codex-coordinator-doctor.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1485d49b2c..80e1c152dd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -203,6 +203,31 @@ Routed catalog entries also get their GPT-5 identity rewritten to the real upstr Reasoning controls come from provider/model metadata across Codex's `low | medium | high | xhigh | max | ultra` ladder; unsupported values are mapped or clamped before the upstream request. +### Coordinator diagnosis and recovery + +Native config/history writes use a per-user SQLite coordinator keyed by the canonical `CODEX_HOME`. +If a process terminates in SQLite's initial creation window, a zero-byte coordinator can remain even +though it contains no authoritative transition row. `ocx doctor` reports the exact coordinator path +and distinguishes zero-byte, unversioned, rowless, valid, unsafe, and unreadable states without +creating SQLite sidecars. Automatic sync tolerates only an identity-stable zero-byte file that has +settled for at least one second and whose immutable SQLite snapshot has version zero with no tables; +a newly created zero-byte file remains on the locked coordinator path. + +For a state that doctor proves is a zero-byte creation remnant, stop the OpenCodex proxy/service +and run: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +Recovery moves the still-identical zero-byte file to a same-directory `.zero-byte-backup-*` path; +it does not delete the evidence or adopt legacy routed state. It refuses a running proxy, lock +contention, symlinks/reparse points, foreign ownership, changed files, every non-empty database, +and any coordinator that already has an authoritative row. Desktop renderer filtering is a +separate layer: a correct catalog and coordinator do not by themselves bypass the Codex App model +allowlist. + ### Routed local tools Non-native routed catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose its official diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 486d10321e..c56bfaa5cc 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -162,6 +162,23 @@ unreachable; and 64 for invalid arguments. ### `ocx doctor` +The default report includes the native-write coordinator state and exact path using immutable +read-only SQLite inspection. Zero-byte, empty-unversioned, and rowless states are shown separately +from catalog/app-server health, so a successful catalog refresh is not mistaken for successful +Codex config injection. + +After stopping the OpenCodex proxy/service, explicitly preserve and move a proven non-authoritative +coordinator, then retry sync: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +The recovery accepts only a proven zero-byte remnant. It refuses every non-empty, valid, unknown, +changed, unsafe, or busy database and creates a same-directory `.zero-byte-backup-*` file instead +of deleting anything. + Run read-only environment and connectivity diagnostics: state paths and filesystem type, WSL dual installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, and pending history migration. The Codex app-home targeting section also detects the narrow Windows diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b70de46548..217e2d8967 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -172,9 +172,9 @@ const commandRunners: Record = { }, doctor: async deps => { const doctorArgs = deps.args.slice(1); - const { runDoctor } = await import("./doctor"); + const { RECOVER_ZERO_BYTE_COORDINATOR_FLAG, runDoctor } = await import("./doctor"); await runDoctor(doctorArgs); - if (!doctorArgs.includes("--fix-codex-runtime")) { + if (!doctorArgs.includes("--fix-codex-runtime") && !doctorArgs.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) { console.log(""); const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor"); printCodexLogGuardDoctor(); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 8af24a2693..d40ba14f2e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback"; import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; import { countPendingOpencodexHistory } from "../codex/history-provider"; +import { + inspectCodexCoordinator, + recoverZeroByteCodexCoordinator, + type CodexCoordinatorDiagnostic, +} from "../codex/coordinator-doctor"; import { inspectAbandonedResponseStateTemps, reclaimAbandonedResponseStateTemps, @@ -684,6 +689,7 @@ export async function fetchServiceMemory( const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`; export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps"; +export const RECOVER_ZERO_BYTE_COORDINATOR_FLAG = "--recover-zero-byte-coordinator"; /** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */ const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096; /** Names the subsystem: other components mint temps with the same shape and are not covered. */ @@ -734,6 +740,60 @@ export function formatResponseTempLines( return lines; } +export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnostic): string[] { + const pathLine = diagnostic.path ? [` path: ${diagnostic.path}`] : []; + const evidenceLines = "evidence" in diagnostic && diagnostic.evidence + ? [ + ` size: ${diagnostic.evidence.sizeBytes} bytes; user_version: ${diagnostic.evidence.schemaVersion}`, + ` tables: ${diagnostic.evidence.tables.length === 0 ? "none" : diagnostic.evidence.tables.join(", ")}`, + ` transition rows: ${diagnostic.evidence.transitionRows ?? "not inspected"}; singleton=1 rows: ${diagnostic.evidence.singletonRows ?? "not inspected"}`, + ] + : []; + switch (diagnostic.kind) { + case "absent": + return [" ok native-write coordinator not created yet", ...pathLine]; + case "ready": + return [" ok native-write coordinator has an authoritative transition row", ...pathLine, ...evidenceLines]; + case "zero-byte": + return [ + " !! native-write coordinator is a zero-byte remnant and has no authority", + ...pathLine, + ...evidenceLines, + ` Action: stop the OpenCodex proxy/service, then run ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`, + ]; + case "unversioned-empty": + return [ + " !! native-write coordinator is a non-empty unversioned database; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "rowless": + return [ + " !! native-write coordinator has schema version 1 but no authoritative row; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "unversioned-nonempty": + return [ + " !! native-write coordinator is unversioned and contains unknown tables; automatic recovery is refused", + ...pathLine, + ...evidenceLines, + ]; + case "unsupported": + return [ + ` !! native-write coordinator schema version ${diagnostic.version} is unsupported; automatic recovery is refused`, + ...pathLine, + ...evidenceLines, + ]; + case "changed": + return [" -- native-write coordinator changed during diagnosis; re-run ocx doctor", ...pathLine]; + case "unsafe": + return [` !! native-write coordinator path is unsafe: ${diagnostic.reason}`, ...pathLine]; + case "unreadable": + return [` !! native-write coordinator is unreadable: ${diagnostic.reason}`, ...pathLine, ...evidenceLines]; + } +} + /** Render the doctor "Memory / runtime" section lines (testable without console capture). */ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] { const lines: string[] = []; @@ -846,6 +906,33 @@ export async function runDoctor(args: string[] = []): Promise { return; } + if (args.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) { + if (!args.includes("--yes")) { + console.log(`Recovery is explicit and creates a same-directory backup. Re-run: ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`); + process.exitCode = 1; + return; + } + const diagnostics = readConfigDiagnostics().config; + const live = await findLiveProxy({ + configFn: () => ({ port: diagnostics.port, hostname: diagnostics.hostname }), + }); + if (live) { + console.log(`Recovery refused: OpenCodex proxy pid ${live.pid} is still running. Stop the proxy/service and retry.`); + process.exitCode = 1; + return; + } + const recovered = recoverZeroByteCodexCoordinator(); + if (!recovered.ok) { + console.log(`Recovery refused: ${recovered.reason}.`); + process.exitCode = 1; + return; + } + console.log(`Moved the non-authoritative coordinator to ${recovered.backupPath}`); + console.log("Run `ocx sync` to retry Codex config injection. The backup was preserved and no Codex config/catalog file was changed by recovery."); + process.exitCode = 0; + return; + } + console.log("opencodex doctor\n"); // Ordering note: the memory/runtime section renders after "Running proxy @@ -1005,6 +1092,8 @@ export async function runDoctor(args: string[] = []): Promise { const reason = cause instanceof CodexUserIdentityRefusal ? cause.message : String(cause); console.log(` -- history coordinator namespace refused: ${reason}`); } + console.log("\nCodex native-write coordinator"); + for (const line of formatCoordinatorDoctorLines(inspectCodexCoordinator())) console.log(line); const pending = countPendingOpencodexHistory(); if (pending.failed) { console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown"); diff --git a/src/cli/help.ts b/src/cli/help.ts index ca1efe8c01..89e2a4edb2 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -38,6 +38,8 @@ Usage: ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) ocx doctor --reclaim-response-temps Reclaim abandoned response-state temp files (works without a running proxy) + ocx doctor --recover-zero-byte-coordinator --yes + Back up a proven zero-byte Codex coordinator after stopping the proxy ocx debug provider/usage/injection/claude on|off|status|reset ocx login OAuth or API-key provider login ocx logout Remove a stored OAuth login diff --git a/src/cli/registry.ts b/src/cli/registry.ts index c8c786b54e..844a644b86 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -108,6 +108,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ name: "doctor", usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability).", + details: [ + "Default mode is observe-only and reports the native-write coordinator state and exact path.", + "After stopping the proxy/service, `--recover-zero-byte-coordinator --yes` moves only a proven zero-byte coordinator to a same-directory backup.", + ], }, { name: "debug", diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts new file mode 100644 index 0000000000..1c238bd922 --- /dev/null +++ b/src/codex/coordinator-doctor.ts @@ -0,0 +1,332 @@ +/** + * Observe and explicitly quarantine non-authoritative native-write coordinators. + * + * Default doctor runs use immutable SQLite reads so diagnostics cannot create + * WAL/SHM sidecars. Recovery is deliberately opt-in and moves, never deletes, + * only a file that is still the same private regular file observed beforehand. + */ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + realpathSync, + renameSync, + type Stats, +} from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { Database, constants as sqliteConstants } from "bun:sqlite"; + +import { resolveCodexHomeDir } from "./home"; +import { + CodexUserIdentityRefusal, + probeCodexCoordinatorNamespace, + resolveEffectiveUserIdentity, + samePathIdentity, +} from "./user-identity"; +import { + CODEX_COORDINATOR_SCHEMA_VERSION, + readCodexCoordinatorState, +} from "./transition-state"; + +const IMMUTABLE_READONLY_FLAGS = + sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI; + +export type FileIdentity = Pick; + +export interface CodexCoordinatorDiagnosticEvidence { + sizeBytes: number; + schemaVersion: number; + tables: readonly string[]; + transitionRows: number | null; + singletonRows: number | null; +} + +export type CodexCoordinatorDiagnostic = + | { kind: "absent"; path: string | null } + | { kind: "zero-byte"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unversioned-empty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unversioned-nonempty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "rowless"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "ready"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "unsupported"; path: string; identity: FileIdentity; version: number; evidence: CodexCoordinatorDiagnosticEvidence } + | { kind: "changed"; path: string } + | { kind: "unsafe"; path: string | null; reason: string } + | { kind: "unreadable"; path: string; reason: string; evidence?: CodexCoordinatorDiagnosticEvidence }; + +export type CodexCoordinatorRecoveryResult = + | { ok: true; backupPath: string } + | { ok: false; reason: string }; + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size; +} + +function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } { + const identity = resolveEffectiveUserIdentity(); + const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir()); + const namespace = probeCodexCoordinatorNamespace(identity); + if (namespace.status === "missing") return { kind: "absent", path: null }; + + const locks = join(namespace.root, "native-write-locks"); + let locksEntry: Stats; + try { + locksEntry = lstatSync(locks); + } catch (cause) { + if (errorCode(cause) === "ENOENT") { + const digest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return { kind: "absent", path: join(locks, `${digest}.sqlite`) }; + } + throw new CodexUserIdentityRefusal("The coordinator lock directory cannot be inspected.", { cause }); + } + if (locksEntry.isSymbolicLink() || !locksEntry.isDirectory()) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace is not a real directory."); + } + if (identity.platform === "posix") { + if (locksEntry.uid !== identity.uid || (locksEntry.mode & 0o777) !== 0o700) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace has unsafe ownership or permissions."); + } + } else if (!samePathIdentity(realpathSync.native(locks), locks, "win32")) { + throw new CodexUserIdentityRefusal("The coordinator lock namespace is redirected by a junction or reparse point."); + } + + const digest = createHash("sha256").update(canonicalCodexHome).digest("hex"); + return { kind: "path", path: join(locks, `${digest}.sqlite`) }; +} + +function inspectTarget( + path: string, + options: { allowSqliteSidecars?: boolean } = {}, +): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } { + let entry: Stats; + try { + entry = lstatSync(path); + } catch (cause) { + if (errorCode(cause) === "ENOENT") return { kind: "absent" }; + return { kind: "unsafe", reason: "the coordinator file cannot be inspected" }; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + return { kind: "unsafe", reason: "the coordinator path is not a real file" }; + } + try { + if (!samePathIdentity(realpathSync.native(path), path)) { + return { kind: "unsafe", reason: "the coordinator path is redirected" }; + } + } catch { + return { kind: "unsafe", reason: "the coordinator path cannot be resolved" }; + } + if (process.platform !== "win32") { + const uid = process.getuid?.(); + if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) { + return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" }; + } + } + if (!options.allowSqliteSidecars) { + for (const suffix of ["-journal", "-wal", "-shm"]) { + if (existsSync(`${path}${suffix}`)) { + return { kind: "unsafe", reason: `the coordinator has an active SQLite ${suffix.slice(1)} sidecar` }; + } + } + } + return { kind: "file", identity: entry }; +} + +function classifyOpenedDatabase( + database: Database, + path: string, + identity: FileIdentity, +): CodexCoordinatorDiagnostic { + const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0; + const tables = database.query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).all().map(row => row.name); + const baseEvidence = { + sizeBytes: identity.size, + schemaVersion: version, + tables, + transitionRows: null, + singletonRows: null, + } satisfies CodexCoordinatorDiagnosticEvidence; + if (version === 0) { + const evidence = tables.length === 0 + ? { ...baseEvidence, transitionRows: 0, singletonRows: 0 } + : baseEvidence; + return tables.length === 0 + ? { kind: "unversioned-empty", path, identity, evidence } + : { kind: "unversioned-nonempty", path, identity, evidence }; + } + if (version !== CODEX_COORDINATOR_SCHEMA_VERSION) { + return { kind: "unsupported", path, identity, version, evidence: baseEvidence }; + } + if (tables.length !== 1 || tables[0] !== "codex_transition_state") { + return tables.length === 0 + ? { kind: "rowless", path, identity, evidence: baseEvidence } + : { kind: "unreadable", path, reason: "the coordinator contains unexpected tables", evidence: baseEvidence }; + } + let rowCounts: { total: number; singleton: number } | null; + try { + rowCounts = database.query<{ total: number; singleton: number }, []>( + "SELECT count(*) AS total, sum(CASE WHEN singleton = 1 THEN 1 ELSE 0 END) AS singleton FROM codex_transition_state", + ).get() ?? null; + } catch { + return { + kind: "unreadable", + path, + reason: "the transition table schema is not recognized", + evidence: baseEvidence, + }; + } + const evidence = { + ...baseEvidence, + transitionRows: rowCounts?.total ?? null, + singletonRows: rowCounts?.singleton ?? null, + }; + if (!rowCounts || rowCounts.total === 0) return { kind: "rowless", path, identity, evidence }; + if (rowCounts.total !== 1 || rowCounts.singleton !== 1) { + return { + kind: "unreadable", + path, + reason: "the coordinator does not contain exactly one singleton row", + evidence, + }; + } + try { + readCodexCoordinatorState(database); + } catch { + return { + kind: "unreadable", + path, + reason: "the authoritative transition row is malformed", + evidence, + }; + } + return { kind: "ready", path, identity, evidence }; +} + +export function inspectCodexCoordinator(): CodexCoordinatorDiagnostic { + let resolved: ReturnType; + try { + resolved = coordinatorPathWithoutCreation(); + } catch (cause) { + return { + kind: "unsafe", + path: null, + reason: cause instanceof Error ? cause.message : String(cause), + }; + } + if (resolved.kind === "absent") return resolved; + return inspectCodexCoordinatorPath(resolved.path); +} + +/** Inspect one already-resolved coordinator path without creating SQLite state. */ +export function inspectCodexCoordinatorPath(path: string): CodexCoordinatorDiagnostic { + const target = inspectTarget(path); + if (target.kind === "absent") return { kind: "absent", path }; + if (target.kind === "unsafe") return { kind: "unsafe", path, reason: target.reason }; + + let database: Database | undefined; + try { + const uri = `${pathToFileURL(path).href}?immutable=1`; + database = new Database(uri, IMMUTABLE_READONLY_FLAGS); + const result = classifyOpenedDatabase(database, path, target.identity); + const after = inspectTarget(path); + if (after.kind !== "file" || !sameIdentity(target.identity, after.identity)) { + return { kind: "changed", path }; + } + // Size alone is not evidence that this is a non-authoritative remnant. + // Query the immutable snapshot too, so the recovery label means all three + // facts were observed together: zero bytes, schema version zero, no tables. + if (target.identity.size === 0 && result.kind === "unversioned-empty") { + return { kind: "zero-byte", path, identity: target.identity, evidence: result.evidence }; + } + return result; + } catch (cause) { + return { kind: "unreadable", path, reason: cause instanceof Error ? cause.message : String(cause) }; + } finally { + try { database?.close(); } catch { /* diagnostics already completed */ } + } +} + +function recoverable(diagnostic: CodexCoordinatorDiagnostic): diagnostic is Extract< + CodexCoordinatorDiagnostic, + { kind: "zero-byte" } +> { + return diagnostic.kind === "zero-byte"; +} + +function backupTimestamp(now: Date): string { + return now.toISOString().replace(/[-:.]/g, ""); +} + +export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordinatorRecoveryResult { + const observed = inspectCodexCoordinator(); + if (!recoverable(observed)) { + if (observed.kind === "unsafe" || observed.kind === "unreadable") { + return { ok: false, reason: `coordinator state is ${observed.kind}: ${observed.reason}` }; + } + return { ok: false, reason: `coordinator state is ${observed.kind}, not a recoverable zero-byte remnant` }; + } + + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(observed.path, { readwrite: true, create: false }); + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + const lockedEntry = inspectTarget(observed.path, { allowSqliteSidecars: true }); + // SQLite may update file timestamps merely by opening a zero-byte database + // for BEGIN IMMEDIATE. Device/inode/size are the stable identity here; the + // transaction excludes content writers while we reclassify the database. + if (lockedEntry.kind !== "file" || !sameNodeAndSize(observed.identity, lockedEntry.identity)) { + return { ok: false, reason: "the coordinator changed before recovery acquired its SQLite lock" }; + } + if (lockedEntry.identity.size !== 0) { + return { ok: false, reason: "the coordinator stopped being zero-byte before recovery" }; + } + database.exec("ROLLBACK"); + transactionOpen = false; + database.close(); + database = undefined; + + const finalEntry = inspectTarget(observed.path); + if (finalEntry.kind !== "file" || !sameIdentity(lockedEntry.identity, finalEntry.identity)) { + return { ok: false, reason: "the coordinator changed before the backup move" }; + } + const backupPath = `${observed.path}.zero-byte-backup-${backupTimestamp(now)}`; + if (existsSync(backupPath)) return { ok: false, reason: "the same-directory backup path already exists" }; + renameSync(observed.path, backupPath); + const backupEntry = inspectTarget(backupPath); + // The rename itself can advance ctime, so post-move verification uses the + // stable filesystem object and byte size. The full timestamp identity was + // already revalidated immediately before rename while the source existed. + if (backupEntry.kind !== "file" || !sameNodeAndSize(finalEntry.identity, backupEntry.identity) || existsSync(observed.path)) { + return { ok: false, reason: "the coordinator backup move could not be verified" }; + } + return { ok: true, backupPath }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + const busy = errorCode(cause) === "SQLITE_BUSY" || errorCode(cause) === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); + return { ok: false, reason: busy ? "the coordinator is busy; stop active sync/service writers and retry" : message }; + } finally { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close releases the lock */ } + } + try { database?.close(); } catch { /* recovery already completed */ } + } +} diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 91f9374bc6..a8b1c28858 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -5,10 +5,11 @@ * sequence it is, rather than doubling in length around the lock. */ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { atomicWriteFile } from "../config"; import type { CodexWriteLockResult } from "./codex-write-lock"; +import { inspectCodexCoordinatorPath } from "./coordinator-doctor"; import { JOURNAL_PATH } from "./journal"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; import { @@ -43,21 +44,51 @@ export type CodexWriteCoordinationEligibility = | { kind: "legacy-uncoordinated"; reason: string } | { kind: "refused"; reason: string }; +/** + * A live SQLite creator exposes a zero-byte pathname before BEGIN IMMEDIATE. + * Requiring a settled filesystem age makes that scheduling window remain on + * the coordinated path while old crash remnants can use the legacy boundary. + */ +export const STABLE_ZERO_BYTE_COORDINATOR_AGE_MS = 1_000; + export function codexWriteCoordinationEligibility(deps: { coordinatorPath: () => string; residue: () => { kind: string }; integrationRecord: () => { kind: string }; + nowMs?: () => number; }): CodexWriteCoordinationEligibility { let coordinatorExists: boolean; + let coordinatorIsStableZeroByte = false; try { - coordinatorExists = existsSync(deps.coordinatorPath()); + const path = deps.coordinatorPath(); + coordinatorExists = existsSync(path); + if (coordinatorExists) { + const entry = lstatSync(path); + if (entry.isFile() && !entry.isSymbolicLink() && entry.size === 0) { + const diagnostic = inspectCodexCoordinatorPath(path); + if (diagnostic.kind === "zero-byte") { + const lastIdentityChange = Math.max(diagnostic.identity.mtimeMs, diagnostic.identity.ctimeMs); + coordinatorIsStableZeroByte = (deps.nowMs?.() ?? Date.now()) - lastIdentityChange + >= STABLE_ZERO_BYTE_COORDINATOR_AGE_MS; + } + } + } } catch (error) { return { kind: "refused", reason: `the coordinator path could not be resolved: ${String(error)}` }; } - // An existing coordinator is authoritative, and the lock owns validating it — - // including the unversioned and rowless cases it must refuse rather than adopt. - if (coordinatorExists) return { kind: "coordinated" }; + // Every existing coordinator remains authoritative unless it is proven to be + // an old, immutable SQLite-empty remnant. The age gate is part of that proof: + // a live creator exposes the same zero-byte pathname briefly before taking N, + // and sending that fresh file down the legacy path would bypass its lock. + // Non-empty, fresh, unsafe, changed, unversioned, and rowless files therefore + // stay coordinated and are validated/refused by the transaction owner. + // + // We do NOT initialize or adopt it here. Clean homes still enter the + // coordinated path, whose SQLite transaction safely initializes it. Routed + // or indeterminate legacy homes keep the same uncoordinated compatibility + // boundary they would have had if the remnant pathname were absent. + if (coordinatorExists && !coordinatorIsStableZeroByte) return { kind: "coordinated" }; const record = deps.integrationRecord(); if (record.kind === "invalid") { @@ -83,7 +114,9 @@ export function codexWriteCoordinationEligibility(deps: { */ return { kind: "legacy-uncoordinated", - reason: residue.kind === "residue" + reason: coordinatorIsStableZeroByte + ? "the coordinator is a zero-byte non-authoritative remnant and this routed home has not been adopted yet" + : residue.kind === "residue" ? "this home was routed before write coordination existed and has not been adopted yet" : "the existing native Codex state could not be classified, so it cannot seed a coordinator row", }; diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index 27ce605530..ed00fca09f 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -37,7 +37,7 @@ import { samePathIdentity, } from "./user-identity"; -const COORDINATOR_SCHEMA_VERSION = 1; +export const CODEX_COORDINATOR_SCHEMA_VERSION = 1; const DURABLE_HISTORY_STATUSES = new Set(["converged", "pending", "running", "blocked", "unknown"]); const DURABLE_HISTORY_REASONS = new Set([ "db-busy", @@ -241,7 +241,7 @@ function rowToState(row: TransitionRow | null): CodexTransitionState { }; } -function readState(database: Database): CodexTransitionState { +export function readCodexCoordinatorState(database: Database): CodexTransitionState { const row = database.query(SELECT_TRANSITION_ROW).get(); return rowToState(row); } @@ -282,7 +282,7 @@ function assertInitialStateCanBeCreated(): void { function initialize(database: Database, databaseWasAbsent: boolean): void { const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; - if (version !== 0 && version !== COORDINATOR_SCHEMA_VERSION) { + if (version !== 0 && version !== CODEX_COORDINATOR_SCHEMA_VERSION) { throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); } if (!databaseWasAbsent && version === 0) { @@ -301,8 +301,8 @@ function initialize(database: Database, databaseWasAbsent: boolean): void { assertInitialStateCanBeCreated(); database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); } - if (version === 0) database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); - readState(database); + if (version === 0) database.exec(`PRAGMA user_version = ${CODEX_COORDINATOR_SCHEMA_VERSION}`); + readCodexCoordinatorState(database); } function createCapability( @@ -336,7 +336,7 @@ function createCapability( expected.nativeGeneration, expected.currentTxId, ); - const state = readState(database); + const state = readCodexCoordinatorState(database); const update: TransitionStateUpdate = result.changes === 1 ? { kind: "updated", state } : { kind: "conflict", current: state }; @@ -451,7 +451,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code capability, expectation() { requireOpen(); - const state = readState(db); + const state = readCodexCoordinatorState(db); return { nativeBefore: state.nativeGeneration, nativeAfter: state.nativeGeneration + 1, @@ -460,7 +460,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code }, version() { requireOpen(); - const state = readState(db); + const state = readCodexCoordinatorState(db); return { nativeGeneration: state.nativeGeneration, currentTxId: state.currentTxId }; }, assertPublished(expectation) { @@ -468,7 +468,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code if (lastResult?.kind !== "updated") { throw new CodexCoordinatorTransactionError("The coordinator transition was not published."); } - const state = readState(db); + const state = readCodexCoordinatorState(db); if (state.nativeGeneration !== expectation.nativeAfter || state.currentTxId !== expectation.txId) { throw new CodexCoordinatorTransactionError("The coordinator published a different transition."); } @@ -540,7 +540,7 @@ function readCommittedState(): TransitionStateRead { try { database = new Database(path, { readonly: true }); database.exec("PRAGMA busy_timeout = 0"); - return { kind: "ready", state: readState(database) }; + return { kind: "ready", state: readCodexCoordinatorState(database) }; } catch (error) { return mapUnavailable(error); } finally { @@ -577,7 +577,7 @@ export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expec database = new Database(currentCoordinatorDatabasePath(), { readwrite: true, create: false }); database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); transactionOpen = true; - const current = readState(database); + const current = readCodexCoordinatorState(database); if (current.nativeGeneration > 0 && current.historySchedule === null) { throw new CodexCoordinatorTransactionError("A positive transition cannot lose its direction."); } @@ -594,7 +594,7 @@ export const updateCodexHistoryTransition: UpdateCodexHistoryTransition = (expec expected.currentTxId, expected.currentTxId, ); - const state = readState(database); + const state = readCodexCoordinatorState(database); database.exec("COMMIT"); transactionOpen = false; return result.changes === 1 diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 6054c211d4..26a279f592 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -80,6 +80,27 @@ on proven absence, never on an unreadable path. - 다른 대안 대신 이 방식을 선택한 이유: Physical credential ownership remains cross-process safe, while an inert optional subsystem can no longer create the reported lock/recovery catch-22. - 장점, 단점 및 영향: Fresh installs avoid the SQLite profile lock; any present or uncertain stage state retains the existing locked fail-closed cleanup and recovery behavior. +The native-write coordinator is keyed by the canonical `CODEX_HOME` in the effective-user runtime +namespace. A pathname alone is not authority: SQLite can expose a zero-byte file before its first +schema write, and a terminated process can leave that remnant behind. Eligibility treats the file +as non-authoritative only after an immutable SQLite read proves version zero with no tables, the +filesystem identity remains unchanged, and the file has been settled for at least one second; a +fresh zero-byte creator stays on the coordinated path so its lock cannot be bypassed. `ocx doctor` inspects the +coordinator with immutable read-only SQLite flags so diagnosis never creates WAL/SHM sidecars. It +distinguishes absent, zero-byte, unversioned, rowless, valid, unsupported, changed, unsafe, and +unreadable states and prints the exact path. Explicit recovery is available only after the proxy is +stopped and only for a proven zero-byte state. The command revalidates the same private +regular-file identity under a non-blocking SQLite write lock and moves it to a same-directory +backup; it never deletes or auto-adopts legacy routed residue. + +[Decision Log] +- 목적과 의도: Recover a crashed zero-byte coordinator without mistaking SQLite's normal creation window for stale authority. +- 기존 구현 및 제약 조건: Eligibility treated every existing pathname as coordinated, while initialization correctly refused a missing row over routed residue; catalog sync could therefore succeed before config injection failed permanently. +- 검토한 주요 대안: Delete zero-byte files automatically, initialize a new row over residue, require a manual filesystem command, or add observe-only classification plus explicit guarded quarantine. +- 선택한 방식: Treat only a settled, identity-stable, immutably verified zero-byte database like the existing legacy-uncoordinated boundary; keep fresh creators coordinated, diagnose all other database states immutably, and expose an opt-in zero-byte-only same-directory backup move with identity, ownership, sidecar, liveness, and SQLite-lock checks. +- 다른 대안 대신 이 방식을 선택한 이유: Automatic deletion or adoption can race a live creator or erase transition evidence; a guarded backup preserves evidence and makes the operator action reproducible. +- 장점, 단점 및 영향: A stale zero-byte file no longer wedges sync, valid/unrecognized databases remain fail-closed, and recovery requires the proxy to be stopped before `ocx sync` retries injection. + OpenCodex never overrides an explicit `CODEX_HOME`. On Windows, `ocx doctor` and `ocx status` nevertheless diagnose the high-confidence Orca dual-home case: both `CODEX_HOME` and `ORCA_CODEX_HOME` select Orca's `orca/codex-runtime-home/home`, while the ChatGPT/Codex app uses the diff --git a/tests/codex-coordinator-doctor.test.ts b/tests/codex-coordinator-doctor.test.ts new file mode 100644 index 0000000000..49e9be23ce --- /dev/null +++ b/tests/codex-coordinator-doctor.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + inspectCodexCoordinator, + recoverZeroByteCodexCoordinator, +} from "../src/codex/coordinator-doctor"; +import { + codexWriteCoordinationEligibility, + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS, +} from "../src/codex/inject-coordination"; +import { + openCodexCoordinatorTransaction, +} from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { formatCoordinatorDoctorLines } from "../src/cli/doctor"; + +let codexHome = ""; +let opencodexHome = ""; +let coordinatorPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-coordinator-doctor-codex-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-coordinator-doctor-ocx-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${coordinatorPath}${suffix}`, { force: true }); + } + rmSync(codexHome, { recursive: true, force: true }); + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +function privateFile(path: string, bytes = ""): void { + writeFileSync(path, bytes); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +test("doctor classifies and explicitly backs up a stable zero-byte coordinator", () => { + privateFile(coordinatorPath); + const diagnostic = inspectCodexCoordinator(); + expect(diagnostic.kind).toBe("zero-byte"); + if (diagnostic.kind !== "zero-byte") return; + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "ocx doctor --recover-zero-byte-coordinator --yes", + ); + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "size: 0 bytes; user_version: 0", + ); + + const recovered = recoverZeroByteCodexCoordinator(new Date("2026-08-21T12:00:00.000Z")); + expect(recovered.ok).toBe(true); + if (!recovered.ok) return; + expect(recovered.backupPath).toEndWith(".zero-byte-backup-20260821T120000000Z"); + expect(existsSync(coordinatorPath)).toBe(false); + expect(existsSync(recovered.backupPath)).toBe(true); + rmSync(recovered.backupPath, { force: true }); +}); + +test("doctor distinguishes unversioned, rowless, and authoritative coordinators", () => { + let database = new Database(coordinatorPath, { create: true }); + database.exec("CREATE TABLE temporary_probe (id INTEGER); DROP TABLE temporary_probe"); + database.close(); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + expect(inspectCodexCoordinator().kind).toBe("unversioned-empty"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is unversioned-empty, not a recoverable zero-byte remnant", + }); + + database = new Database(coordinatorPath, { readwrite: true, create: false }); + database.exec("PRAGMA user_version = 1; CREATE TABLE codex_transition_state (singleton INTEGER PRIMARY KEY)"); + database.close(); + expect(inspectCodexCoordinator().kind).toBe("rowless"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is rowless, not a recoverable zero-byte remnant", + }); + + database = new Database(coordinatorPath, { readwrite: true, create: false }); + database.exec("INSERT INTO codex_transition_state (singleton) VALUES (1)"); + database.close(); + const malformed = inspectCodexCoordinator(); + expect(malformed.kind).toBe("unreadable"); + expect(formatCoordinatorDoctorLines(malformed).join("\n")).toContain("user_version: 1"); + expect(formatCoordinatorDoctorLines(malformed).join("\n")).toContain("transition rows: 1"); + + rmSync(coordinatorPath, { force: true }); + const transaction = openCodexCoordinatorTransaction(coordinatorPath); + transaction.commit(); + transaction.close(); + expect(inspectCodexCoordinator().kind).toBe("ready"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is ready, not a recoverable zero-byte remnant", + }); +}); + +test("doctor inspection is immutable and refuses sidecars, unsafe modes, and symlinks", () => { + privateFile(coordinatorPath); + expect(inspectCodexCoordinator().kind).toBe("zero-byte"); + for (const suffix of ["-journal", "-wal", "-shm"]) { + expect(existsSync(`${coordinatorPath}${suffix}`)).toBe(false); + } + + privateFile(`${coordinatorPath}-wal`, "active"); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + rmSync(`${coordinatorPath}-wal`, { force: true }); + + if (process.platform !== "win32") { + chmodSync(coordinatorPath, 0o644); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + chmodSync(coordinatorPath, 0o600); + + const target = `${coordinatorPath}.target`; + privateFile(target); + rmSync(coordinatorPath, { force: true }); + symlinkSync(target, coordinatorPath); + expect(inspectCodexCoordinator()).toMatchObject({ kind: "unsafe" }); + rmSync(coordinatorPath, { force: true }); + rmSync(target, { force: true }); + } +}); + +test("recovery refuses a zero-byte coordinator with an active SQLite writer sidecar", () => { + privateFile(coordinatorPath); + const holder = new Database(coordinatorPath, { readwrite: true, create: false }); + holder.exec("PRAGMA journal_mode = OFF; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + expect(recoverZeroByteCodexCoordinator()).toMatchObject({ + ok: false, + reason: expect.stringContaining("active SQLite journal sidecar"), + }); + expect(existsSync(coordinatorPath)).toBe(true); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } +}); + +test("zero-byte residue uses the legacy boundary while clean homes still initialize", () => { + privateFile(coordinatorPath); + const afterStableAge = () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1; + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: afterStableAge, + })).toEqual({ + kind: "legacy-uncoordinated", + reason: "the coordinator is a zero-byte non-authoritative remnant and this routed home has not been adopted yet", + }); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "clean" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: afterStableAge, + })).toEqual({ kind: "coordinated" }); + + privateFile(coordinatorPath, "not-empty"); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + })).toEqual({ kind: "coordinated" }); +}); + +test("a fresh zero-byte coordinator stays on the locked path until it is stable", () => { + privateFile(coordinatorPath); + const fresh = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: () => Date.now(), + }); + expect(fresh).toEqual({ kind: "coordinated" }); + + const settled = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + nowMs: () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1, + }); + expect(settled.kind).toBe("legacy-uncoordinated"); +}); diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 5603137bd2..9054d5bacf 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -8,9 +8,14 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { STABLE_ZERO_BYTE_COORDINATOR_AGE_MS } from "../src/codex/inject-coordination"; const repoRoot = join(import.meta.dir, ".."); const CHILD = join(repoRoot, "tests", "helpers", "codex-inject-race-child.ts"); @@ -20,6 +25,7 @@ let root = ""; let codexHome = ""; let opencodexHome = ""; const cleanup: string[] = []; +const coordinatorCleanup: string[] = []; function seedNative(): void { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); @@ -50,6 +56,12 @@ beforeEach(() => { }); afterEach(() => { + while (coordinatorCleanup.length) { + const path = coordinatorCleanup.pop()!; + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${path}${suffix}`, { force: true }); + } + } while (cleanup.length) { const dir = cleanup.pop()!; // `force` covers a missing path, not a locked one: a child that is still exiting @@ -184,6 +196,35 @@ describe("homes the coordinator cannot adopt keep working", () => { expect(result.success).toBeTrue(); expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); }); + + test("a zero-byte coordinator remnant does not wedge a pre-substrate routed home", () => { + writeFileSync(join(codexHome, "config.toml"), [ + 'model_provider = "opencodex"', + 'model = "gpt-5.5"', + "", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'wire_api = "responses"', + "", + ].join("\n")); + const coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); + coordinatorCleanup.push(coordinatorPath); + writeFileSync(coordinatorPath, ""); + if (process.platform !== "win32") chmodSync(coordinatorPath, 0o600); + // Fresh zero-byte files remain on the coordinated path because they may + // belong to a live SQLite creator. This fixture represents an old remnant. + Bun.sleepSync(STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 100); + + const result = runInject(10100); + + expect(result.success).toBeTrue(); + expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); + expect(readFileSync(coordinatorPath)).toHaveLength(0); + }); }); describe("the transition is resolved, not left pending", () => { From a46ca46a5b0c193234a7457f977c7e14d4e40dc4 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 17:34:13 +0000 Subject: [PATCH 2/4] fix(codex): close zero-byte recovery race --- .../content/docs/guides/codex-integration.md | 4 +- .../content/docs/reference/cli/lifecycle.md | 3 +- src/cli/doctor.ts | 1 + src/codex/coordinator-doctor.ts | 16 ++- structure/02_config-and-codex-home.md | 8 +- tests/codex-coordinator-doctor.test.ts | 3 + tests/doctor.test.ts | 126 +++++++++++++++++- 7 files changed, 152 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 80e1c152dd..1578e20b79 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -224,7 +224,9 @@ ocx sync Recovery moves the still-identical zero-byte file to a same-directory `.zero-byte-backup-*` path; it does not delete the evidence or adopt legacy routed state. It refuses a running proxy, lock contention, symlinks/reparse points, foreign ownership, changed files, every non-empty database, -and any coordinator that already has an authoritative row. Desktop renderer filtering is a +and any coordinator that already has an authoritative row. If the file is less than one second +old, sync deliberately still treats it as coordinated; stop writers and use the explicit recovery, +or wait one second before retrying `ocx sync`. Desktop renderer filtering is a separate layer: a correct catalog and coordinator do not by themselves bypass the Codex App model allowlist. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index c56bfaa5cc..be3c13f65d 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -177,7 +177,8 @@ ocx sync The recovery accepts only a proven zero-byte remnant. It refuses every non-empty, valid, unknown, changed, unsafe, or busy database and creates a same-directory `.zero-byte-backup-*` file instead -of deleting anything. +of deleting anything. A file younger than one second intentionally remains coordinated; after +stopping writers, use the explicit recovery above or wait one second before retrying `ocx sync`. Run read-only environment and connectivity diagnostics: state paths and filesystem type, WSL dual installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index d40ba14f2e..d38fecc624 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -760,6 +760,7 @@ export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnos ...pathLine, ...evidenceLines, ` Action: stop the OpenCodex proxy/service, then run ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`, + " Note: a file younger than 1 second stays coordinated; use explicit recovery after stopping writers, or wait 1 second and retry ocx sync.", ]; case "unversioned-empty": return [ diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts index 1c238bd922..a5a6f7a82b 100644 --- a/src/codex/coordinator-doctor.ts +++ b/src/codex/coordinator-doctor.ts @@ -303,8 +303,18 @@ export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordina database.close(); database = undefined; - const finalEntry = inspectTarget(observed.path); - if (finalEntry.kind !== "file" || !sameIdentity(lockedEntry.identity, finalEntry.identity)) { + // Windows cannot rename this SQLite file while the locking handle remains + // open. Re-prove the complete recoverable state after releasing that handle, + // immediately before the move: pathname identity alone would miss a writer + // that populated schema state during this unavoidable unlocked window. + const finalDiagnostic = inspectCodexCoordinatorPath(observed.path); + if (finalDiagnostic.kind !== "zero-byte") { + if (finalDiagnostic.kind === "unsafe" || finalDiagnostic.kind === "unreadable") { + return { ok: false, reason: `the coordinator changed before the backup move: ${finalDiagnostic.reason}` }; + } + return { ok: false, reason: `the coordinator changed before the backup move: state is ${finalDiagnostic.kind}` }; + } + if (!sameNodeAndSize(lockedEntry.identity, finalDiagnostic.identity)) { return { ok: false, reason: "the coordinator changed before the backup move" }; } const backupPath = `${observed.path}.zero-byte-backup-${backupTimestamp(now)}`; @@ -314,7 +324,7 @@ export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordina // The rename itself can advance ctime, so post-move verification uses the // stable filesystem object and byte size. The full timestamp identity was // already revalidated immediately before rename while the source existed. - if (backupEntry.kind !== "file" || !sameNodeAndSize(finalEntry.identity, backupEntry.identity) || existsSync(observed.path)) { + if (backupEntry.kind !== "file" || !sameNodeAndSize(finalDiagnostic.identity, backupEntry.identity) || existsSync(observed.path)) { return { ok: false, reason: "the coordinator backup move could not be verified" }; } return { ok: true, backupPath }; diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 26a279f592..509582f1dc 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -90,8 +90,12 @@ coordinator with immutable read-only SQLite flags so diagnosis never creates WAL distinguishes absent, zero-byte, unversioned, rowless, valid, unsupported, changed, unsafe, and unreadable states and prints the exact path. Explicit recovery is available only after the proxy is stopped and only for a proven zero-byte state. The command revalidates the same private -regular-file identity under a non-blocking SQLite write lock and moves it to a same-directory -backup; it never deletes or auto-adopts legacy routed residue. +regular-file identity under a non-blocking SQLite write lock, releases the handle for Windows +rename compatibility, then immutably re-proves zero bytes, schema version zero, and no tables +immediately before moving it to a same-directory backup. It never deletes or auto-adopts legacy +routed residue. A lock refusal explicitly names concurrent sync/service writers; a file younger +than one second remains coordinated, so the operator must use the guarded recovery after stopping +writers or wait one second before retrying `ocx sync`. [Decision Log] - 목적과 의도: Recover a crashed zero-byte coordinator without mistaking SQLite's normal creation window for stale authority. diff --git a/tests/codex-coordinator-doctor.test.ts b/tests/codex-coordinator-doctor.test.ts index 49e9be23ce..b92b3e2175 100644 --- a/tests/codex-coordinator-doctor.test.ts +++ b/tests/codex-coordinator-doctor.test.ts @@ -69,6 +69,9 @@ test("doctor classifies and explicitly backs up a stable zero-byte coordinator", expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( "size: 0 bytes; user_version: 0", ); + expect(formatCoordinatorDoctorLines(diagnostic).join("\n")).toContain( + "a file younger than 1 second stays coordinated", + ); const recovered = recoverZeroByteCodexCoordinator(new Date("2026-08-21T12:00:00.000Z")); expect(recovered.ok).toBe(true); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index b308f7d0b3..34a4a7315c 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { homedir, tmpdir } from "node:os"; import { collectPaths, @@ -21,6 +21,10 @@ import { } from "../src/cli/doctor"; import { collectOrcaCodexHomeDiagnostic } from "../src/codex/home"; import { NativeProfileError } from "../src/codex/native-profile-types"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; import { LOCAL_MANAGEMENT_CAPABILITY_HEADER, LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, @@ -624,6 +628,124 @@ describe("service memory section (#314 WP4)", () => { const conflict = proxyDownRestartHint({ proxyRunning: false, port: 10100, serviceViable: false, serviceInstalled: true, serviceConflict: true }); expect(conflict).toContain("ocx service install"); }); + + describe("zero-byte coordinator recovery wiring", () => { + let previousCodexHome: string | undefined; + let previousOpencodexHome: string | undefined; + + beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_CODEX_HOME, { recursive: true }); + mkdirSync(TEST_OPENCODEX_HOME, { recursive: true }); + process.env.CODEX_HOME = TEST_CODEX_HOME; + process.env.OPENCODEX_HOME = TEST_OPENCODEX_HOME; + }); + + afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + const seedZeroByteCoordinator = (): string => { + const path = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(TEST_CODEX_HOME), + ); + writeFileSync(path, ""); + if (process.platform !== "win32") chmodSync(path, 0o600); + return path; + }; + + const cleanCoordinatorArtifacts = (path: string): void => { + rmSync(path, { force: true }); + const prefix = `${basename(path)}.zero-byte-backup-`; + for (const entry of readdirSync(dirname(path))) { + if (entry.startsWith(prefix)) rmSync(join(dirname(path), entry), { force: true }); + } + }; + + const captureDoctor = async (args: string[]): Promise<{ lines: string[]; exitCode: number | undefined }> => { + const previousExitCode = process.exitCode; + const realLog = console.log; + const lines: string[] = []; + try { + process.exitCode = 0; + console.log = (...parts: unknown[]) => { lines.push(parts.join(" ")); }; + await runDoctor(args); + return { lines, exitCode: process.exitCode }; + } finally { + console.log = realLog; + process.exitCode = previousExitCode; + } + }; + + test("zero-byte recovery requires --yes before touching the coordinator", async () => { + const coordinatorPath = seedZeroByteCoordinator(); + try { + const result = await captureDoctor(["--recover-zero-byte-coordinator"]); + expect(result.exitCode).toBe(1); + expect(result.lines.join("\n")).toContain("Recovery is explicit"); + expect(existsSync(coordinatorPath)).toBe(true); + } finally { + cleanCoordinatorArtifacts(coordinatorPath); + } + }); + + test("zero-byte recovery refuses while an identity-valid proxy is live", async () => { + const coordinatorPath = seedZeroByteCoordinator(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => Response.json({ + service: "opencodex", + status: "ok", + version: "test", + uptime: 1, + pid: process.pid, + }), + }); + try { + writeFileSync(join(TEST_OPENCODEX_HOME, "config.json"), JSON.stringify({ + port: server.port, + hostname: "127.0.0.1", + })); + const result = await captureDoctor(["--recover-zero-byte-coordinator", "--yes"]); + expect(result.exitCode).toBe(1); + expect(result.lines.join("\n")).toContain("is still running"); + expect(existsSync(coordinatorPath)).toBe(true); + } finally { + server.stop(true); + cleanCoordinatorArtifacts(coordinatorPath); + } + }); + + test("zero-byte recovery moves the proven file to a same-directory backup", async () => { + const coordinatorPath = seedZeroByteCoordinator(); + const closedPort = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + const port = closedPort.port; + closedPort.stop(true); + try { + writeFileSync(join(TEST_OPENCODEX_HOME, "config.json"), JSON.stringify({ + port, + hostname: "127.0.0.1", + })); + const result = await captureDoctor(["--recover-zero-byte-coordinator", "--yes"]); + expect(result.exitCode).toBe(0); + expect(existsSync(coordinatorPath)).toBe(false); + const prefix = `${basename(coordinatorPath)}.zero-byte-backup-`; + const backups = readdirSync(dirname(coordinatorPath)).filter(entry => entry.startsWith(prefix)); + expect(backups).toHaveLength(1); + expect(result.lines.join("\n")).toContain(join(dirname(coordinatorPath), backups[0]!)); + } finally { + cleanCoordinatorArtifacts(coordinatorPath); + } + }); + }); }); describe("doctor abandoned response-state temps", () => { From a813019c967335f43f42d85448bec20c1abf3814 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 18:07:19 +0000 Subject: [PATCH 3/4] test(doctor): restore a stable exit code --- tests/doctor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 34a4a7315c..46f788eb05 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -670,7 +670,7 @@ describe("service memory section (#314 WP4)", () => { }; const captureDoctor = async (args: string[]): Promise<{ lines: string[]; exitCode: number | undefined }> => { - const previousExitCode = process.exitCode; + const previousExitCode = process.exitCode ?? 0; const realLog = console.log; const lines: string[] = []; try { From 322126c6c972c3550bad3b06d3808df8b7ed0da2 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 20:04:11 +0000 Subject: [PATCH 4/4] fix(codex): address coordinator recovery review --- .../docs/ja/guides/codex-integration.md | 13 ++++++++ .../docs/ja/reference/cli/lifecycle.md | 15 +++++++-- .../docs/ko/guides/codex-integration.md | 23 +++++++++++++ .../docs/ko/reference/cli/lifecycle.md | 22 +++++++++++-- .../content/docs/reference/cli/lifecycle.md | 23 ++++++------- .../docs/ru/guides/codex-integration.md | 26 +++++++++++++++ .../docs/ru/reference/cli/lifecycle.md | 25 ++++++++++++-- .../docs/zh-cn/guides/codex-integration.md | 21 ++++++++++++ .../docs/zh-cn/reference/cli/lifecycle.md | 15 +++++++-- src/cli/dispatch.ts | 2 +- src/codex/coordinator-doctor.ts | 17 +++++----- src/codex/transition-state.ts | 4 +-- tests/codex-coordinator-doctor.test.ts | 33 +++++++++++++++---- 13 files changed, 200 insertions(+), 39 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index ced7a0a112..1e92858914 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -134,6 +134,19 @@ Codex には、ディスク上のカタログ (デフォルトでは `$CODEX_HOM プロバイダーとモデルメタデータに応じて Codex の `low | medium | high | xhigh | max | ultra` 段階を使い、 上流がサポートしない値はリクエスト送信前にマッピングまたはサポート範囲に下げます。 +### Coordinator の診断と回復 + +ネイティブ構成/履歴の書き込みは、canonical `CODEX_HOME` をキーとするユーザー単位の SQLite coordinator を使います。プロセスが SQLite の初期作成中に終了すると、権威ある transition row が存在しなくてもゼロバイトの coordinator が残ることがあります。`ocx doctor` は SQLite sidecar を作らずに正確な coordinator パスを報告し、ゼロバイト、未バージョン、row なし、有効、安全でない、読み取り不能の状態を区別します。自動 sync が許容するのは、identity が安定して 1 秒以上経過し、immutable SQLite snapshot の version が 0 で table がないゼロバイトファイルだけです。新しく作成されたゼロバイトファイルはロックされた coordinator パスに残ります。 + +doctor がゼロバイトの作成残骸だと証明した場合は、OpenCodex proxy/service を停止して実行します。 + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +回復は、同一性が保たれたゼロバイトファイルを同じディレクトリの `.zero-byte-backup-*` へ移動します。証拠を削除したり、既存の routed state を採用したりしません。実行中の proxy、lock 競合、symlink/reparse point、別所有者、変更済みファイル、空でない database、権威ある row を既に持つ coordinator は拒否します。ファイルが作成から 1 秒未満なら、sync は意図的に coordinator 扱いを続けます。writer を停止して明示的な回復を使うか、1 秒待ってから `ocx sync` を再実行してください。Desktop renderer のフィルタリングは別レイヤーであり、catalog と coordinator が正しいだけでは Codex App の model allowlist を回避できません。 + ### ルーティングされたローカルツール ネイティブではないルーティング済みカタログ項目は `tool_mode: "code_mode_only"` を使用します。これにより、 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 1f8c593e91..32c81cf32f 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -132,9 +132,20 @@ ocx status --json ### `ocx doctor` -読み取り専用環境と接続の診断を実行します: 状態パスとファイル システム タイプ、WSL デュアル インストール、プロキシ環境/構成、ChatGPT の到達可能性、Codex プラグインとプロジェクト設定の警告、保留中の履歴の移行。 Codex のアプリとホームのターゲット設定セクションでは、Windows Orca ランタイムとホームの狭い不一致も検出し、該当する場合はサービスの移行について説明します。この診断によって表示されるパスでは、OS ユーザー名が編集されます。医師は修復ヒントを出力しますが、適用しません。 +読み取り専用の環境および接続診断を実行します。状態パスとファイルシステム種別、WSL の二重インストール、プロキシ環境/構成、ChatGPT 到達性、Codex プラグインとプロジェクト構成の警告、保留中の履歴移行が含まれます。Codex app-home の対象設定セクションは、限定された Windows Orca runtime-home の不一致も検出し、該当する場合はサービス移行を説明します。表示されるパスでは OS ユーザー名がマスクされます。既定のレポートは修復のヒントを表示しますが、適用しません。 -**OAuth の信頼性** セクションでは、資格情報ストレージが書き込み可能かどうか、リフレッシュ シングルフライト/ロック ファイルが `OPENCODEX_HOME` で作成できるかどうか、回復 `Action:` を持つ正常でない OAuth または Codex プール アカウント (編集された ID)、および Codex 転送パスが公式クライアント メタデータを作成しない静的 OK が報告されます。 Doctor は資格情報を変更したり、修復を適用したりすることはありません。 +**OAuth の信頼性** セクションは、資格情報ストレージが書き込み可能か、refresh single-flight/lock ファイルを `OPENCODEX_HOME` に作成できるか、正常でない OAuth または Codex pool アカウント(マスク済み ID)と回復用 `Action:`、そして Codex forward path が公式クライアント metadata を捏造しないことを示す静的 OK を報告します。Doctor は資格情報を変更しません。実際に修復を行うのは、以下の明示的なゼロバイト coordinator 回復だけです。 + +既定のレポートは immutable な読み取り専用 SQLite 検査を使い、native-write coordinator の状態と正確なパスを表示します。ゼロバイト、空の未バージョン、row のない状態を catalog/app-server の健全性と区別するため、catalog の更新成功を Codex 構成注入の成功と取り違えません。 + +OpenCodex proxy/service を停止した後、権威ある状態を含まないと証明された coordinator を明示的に保存して移動し、sync を再実行します。 + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +回復は、証明されたゼロバイトの残骸だけを受け入れます。空でない、有効、不明、変更済み、安全でない、または使用中のデータベースはすべて拒否し、削除せず同じディレクトリに `.zero-byte-backup-*` ファイルを作成します。作成から 1 秒未満のファイルは意図的に coordinator 扱いのままです。writer を停止して上記の明示的な回復を使うか、1 秒待ってから `ocx sync` を再実行してください。 ## カタログの同期 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index b1a15ea4c2..c742990b09 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -124,6 +124,29 @@ Codex는 디스크의 카탈로그(`$CODEX_HOME/opencodex-catalog.json`이 기 프로바이더와 모델 메타데이터에 따라 Codex의 `low | medium | high | xhigh | max | ultra` 단계를 사용하며, 업스트림이 지원하지 않는 값은 요청을 보내기 전에 매핑하거나 지원 범위로 낮춥니다. +### Coordinator 진단 및 복구 + +네이티브 설정/기록 쓰기는 canonical `CODEX_HOME`을 키로 하는 사용자별 SQLite coordinator를 사용합니다. +프로세스가 SQLite 초기 생성 구간에서 종료되면 권한 있는 transition row가 전혀 없는데도 제로 바이트 +coordinator가 남을 수 있습니다. `ocx doctor`는 SQLite sidecar를 만들지 않고 정확한 coordinator 경로를 +보고하며, 제로 바이트, 미버전, row 없는 상태, 유효, 안전하지 않음, 읽을 수 없음 상태를 구분합니다. +자동 sync는 identity가 안정되고 최소 1초 동안 유지되었으며 immutable SQLite snapshot의 version이 0이고 +table이 없는 제로 바이트 파일만 허용합니다. 새로 생성된 제로 바이트 파일은 잠긴 coordinator 경로에 남습니다. + +doctor가 제로 바이트 생성 잔여물이라고 증명한 상태라면 OpenCodex proxy/service를 중지하고 실행하세요. + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +복구는 동일한 제로 바이트 파일을 같은 디렉터리의 `.zero-byte-backup-*` 경로로 옮깁니다. 증거를 삭제하거나 +기존 routed 상태를 채택하지 않습니다. 실행 중인 proxy, lock 경합, symlink/reparse point, 다른 소유자, +변경된 파일, 모든 비어 있지 않은 database, 이미 권한 있는 row가 있는 coordinator는 거절합니다. 파일이 +생성된 지 1초 미만이면 sync는 의도적으로 계속 coordinated 상태로 취급합니다. writer를 중지하고 명시적인 +복구를 사용하거나 1초를 기다린 다음 `ocx sync`를 다시 실행하세요. Desktop renderer 필터링은 별도 계층이므로, +catalog와 coordinator가 올바르다는 사실만으로 Codex App model allowlist를 우회하지는 않습니다. + ### 라우팅된 로컬 도구 네이티브가 아닌 라우팅 catalog 항목은 `tool_mode: "code_mode_only"`를 사용합니다. 이를 통해 Codex는 공식 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 14d8db2cf0..a81a5cad0c 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -166,12 +166,30 @@ probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `fa 환경/설정, ChatGPT 도달 가능성, Codex 플러그인 및 프로젝트 설정 경고, 보류 중인 기록 마이그레이션이 포함됩니다. Codex 앱 홈 대상 지정 섹션은 좁은 범위의 Windows Orca 런타임 홈 불일치도 감지하고, 해당할 때 서비스 마이그레이션을 설명합니다. 이 진단에 표시되는 경로는 OS 사용자 이름을 마스킹합니다. -doctor는 복구 힌트를 보여 주지만 직접 적용하지는 않습니다. +기본 보고서는 복구 힌트를 보여 주지만 직접 적용하지는 않습니다. **OAuth 안정성** 섹션은 자격 증명 저장소에 쓰기 가능한지, `OPENCODEX_HOME` 아래에 refresh single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또는 Codex pool 계정(마스킹된 ID)과 복구용 `Action:`, 그리고 Codex 전달 경로가 공식 클라이언트 메타데이터를 꾸며 내지 않는다는 -정적 OK를 보고합니다. doctor는 자격 증명을 변경하거나 복구를 적용하지 않습니다. +정적 OK를 보고합니다. doctor는 자격 증명을 변경하지 않으며, 아래의 명시적인 제로 바이트 +coordinator 복구만 실제 복구 작업을 수행합니다. + +기본 보고서는 immutable 읽기 전용 SQLite 검사로 네이티브 쓰기 coordinator의 상태와 정확한 경로를 +보여 줍니다. 제로 바이트, 비어 있는 미버전, row 없는 상태를 catalog/app-server 상태와 구분하므로, +catalog 갱신 성공을 Codex 설정 주입 성공으로 오해하지 않게 합니다. + +OpenCodex proxy/service를 중지한 뒤, 권한 있는 상태가 아님이 증명된 coordinator를 명시적으로 보존하여 +옮기고 sync를 다시 실행합니다. + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +복구는 증명된 제로 바이트 잔여 파일만 허용합니다. 비어 있지 않거나 유효하거나 알 수 없거나 변경되었거나 +안전하지 않거나 사용 중인 데이터베이스는 모두 거절하고, 삭제하지 않고 같은 디렉터리에 +`.zero-byte-backup-*` 파일을 만듭니다. 생성된 지 1초 미만인 파일은 의도적으로 coordinator 상태를 유지합니다. +writer를 중지한 뒤 위의 명시적 복구를 사용하거나 1초를 기다린 다음 `ocx sync`를 다시 실행하세요. ## 카탈로그 동기화 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index be3c13f65d..0261fb4114 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -162,6 +162,18 @@ unreachable; and 64 for invalid arguments. ### `ocx doctor` +Run read-only environment and connectivity diagnostics: state paths and filesystem type, WSL dual +installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, +and pending history migration. The Codex app-home targeting section also detects the narrow Windows +Orca runtime-home mismatch and explains service migration when applicable. Paths shown by this +diagnostic redact the OS username. The default report prints repair hints but does not apply them. + +The **OAuth reliability** section reports whether credential storage is writable, whether refresh +single-flight/lock files can be created under `OPENCODEX_HOME`, non-healthy OAuth or Codex pool +accounts (redacted ids) with a recovery `Action:`, and a static OK that the Codex forward path does +not fabricate official-client metadata. Doctor never mutates credentials; only the explicit +zero-byte coordinator recovery below applies a repair. + The default report includes the native-write coordinator state and exact path using immutable read-only SQLite inspection. Zero-byte, empty-unversioned, and rowless states are shown separately from catalog/app-server health, so a successful catalog refresh is not mistaken for successful @@ -180,17 +192,6 @@ changed, unsafe, or busy database and creates a same-directory `.zero-byte-backu of deleting anything. A file younger than one second intentionally remains coordinated; after stopping writers, use the explicit recovery above or wait one second before retrying `ocx sync`. -Run read-only environment and connectivity diagnostics: state paths and filesystem type, WSL dual -installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, -and pending history migration. The Codex app-home targeting section also detects the narrow Windows -Orca runtime-home mismatch and explains service migration when applicable. Paths shown by this -diagnostic redact the OS username. Doctor prints repair hints but does not apply them. - -The **OAuth reliability** section reports whether credential storage is writable, whether refresh -single-flight/lock files can be created under `OPENCODEX_HOME`, non-healthy OAuth or Codex pool -accounts (redacted ids) with a recovery `Action:`, and a static OK that the Codex forward path does -not fabricate official-client metadata. Doctor never mutates credentials or applies repairs. - ## Catalog sync ### `ocx sync [--restart-codex]` diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 118c663870..8d8327140c 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -204,6 +204,32 @@ Codex показывает модели из каталога на диске (` по шкале Codex `low | medium | high | xhigh | max | ultra`; неподдерживаемые значения сопоставляются или ограничиваются перед запросом к вышестоящему провайдеру. +### Диагностика и восстановление coordinator + +Запись нативной конфигурации и истории использует отдельный для пользователя SQLite coordinator, +ключом которого служит canonical `CODEX_HOME`. Если процесс завершится в начальном окне создания +SQLite, может остаться zero-byte coordinator без авторитетной transition row. `ocx doctor` +сообщает точный путь coordinator и различает состояния zero-byte, unversioned, без строк, valid, +unsafe и unreadable, не создавая SQLite sidecar-файлы. Автоматический sync допускает только +стабильный по identity zero-byte файл возрастом не менее секунды, чей immutable SQLite snapshot +имеет version 0 и не содержит таблиц; только что созданный zero-byte файл остаётся на заблокированном +пути coordinator. + +Если doctor доказал, что это zero-byte остаток создания, остановите proxy/service OpenCodex и выполните: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +Восстановление перемещает всё ещё идентичный zero-byte файл в `.zero-byte-backup-*` в том же каталоге; +оно не удаляет свидетельство и не принимает legacy routed state. Оно отклоняет работающий proxy, +конфликт блокировки, symlink/reparse point, чужого владельца, изменившийся файл, любую непустую базу и +coordinator с авторитетной row. Если файл моложе секунды, sync намеренно продолжает считать его +coordinated; остановите writer'ы и используйте явное восстановление либо подождите секунду перед +повторным `ocx sync`. Фильтрация Desktop renderer — отдельный слой: корректные catalog и coordinator +сами по себе не обходят allowlist моделей Codex App. + ### Локальные инструменты для маршрутизируемых моделей Маршрутизируемые записи, которые не являются нативными, используют diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 2696370cc5..d08d959390 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -177,14 +177,33 @@ Identity-check живого прокси. Текстовый вывод сооб двойные установки WSL, proxy environment/config, достижимость ChatGPT, предупреждения о plugin'е и project-config Codex, а также ожидающую миграцию истории. Раздел, касающийся app-home Codex, тоже обнаруживает узкий mismatch runtime-home Windows Orca и при необходимости объясняет миграцию -службы. Пути в этом выводе маскируют имя пользователя ОС. Doctor печатает подсказки по ремонту, -но ничего не меняет. +службы. Пути в этом выводе маскируют имя пользователя ОС. Обычный отчёт Doctor печатает +подсказки по восстановлению, но не применяет их. Раздел **OAuth reliability** показывает, можно ли записывать credential storage, удаётся ли создавать refresh single-flight/lock file'ы в `OPENCODEX_HOME`, есть ли нездоровые OAuth- или Codex-pool-аккаунты (с masked-id) с подсказкой `Action:`, а также статическое OK-подтверждение, что путь Codex forward не подделывает metadata официального клиента. Doctor никогда не мутирует -credential'ы и не выполняет repair. +credential'ы; repair выполняет только явное восстановление zero-byte coordinator ниже. + +Обычный отчёт показывает состояние и точный путь native-write coordinator через immutable +read-only проверку SQLite. Состояния zero-byte, пустой unversioned и без строк отделены от +здоровья catalog/app-server, поэтому успешное обновление каталога нельзя принять за успешное +внедрение конфигурации Codex. + +После остановки proxy/service OpenCodex явно сохраните и переместите coordinator, для которого +доказано отсутствие авторитетного состояния, а затем повторите sync: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +Восстановление принимает только доказанный zero-byte остаток. Оно отклоняет любую непустую, +валидную, неизвестную, изменившуюся, небезопасную или занятую базу и вместо удаления создаёт +файл `.zero-byte-backup-*` в том же каталоге. Файл моложе одной секунды намеренно остаётся +coordinated; остановите writer'ы и используйте явное восстановление выше либо подождите секунду +перед повторным `ocx sync`. ## Синхронизация каталога diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index ef693616f6..2c7172c23e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -181,6 +181,27 @@ Codex 显示的模型来自一个磁盘上的 catalog(默认是 `$CODEX_HOME/o 使用 Codex 的 `low | medium | high | xhigh | max | ultra` 档位;上游不支持的值会在发送请求前完成 映射或下调。 +### Coordinator 诊断与恢复 + +原生配置/历史记录写入使用一个按用户划分、以 canonical `CODEX_HOME` 为键的 SQLite coordinator。 +如果进程在 SQLite 初始创建窗口中退出,可能会留下不含任何权威 transition row 的零字节 coordinator。 +`ocx doctor` 会在不创建 SQLite sidecar 的情况下报告准确的 coordinator 路径,并区分零字节、未版本化、 +无行、有效、不安全和不可读状态。自动 sync 只容忍 identity 稳定至少一秒,并且 immutable SQLite snapshot +的 version 为 0、没有 table 的零字节文件;刚创建的零字节文件仍会留在受锁保护的 coordinator 路径上。 + +如果 doctor 证明某状态是零字节创建残留,请停止 OpenCodex proxy/service 后运行: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +恢复会把仍保持相同 identity 的零字节文件移动到同一目录下的 `.zero-byte-backup-*` 路径;它不会删除证据, +也不会采用旧的 routed state。运行中的 proxy、锁竞争、symlink/reparse point、其他所有者、已变化的文件、 +任何非空数据库,以及已经包含权威 row 的 coordinator 都会被拒绝。如果文件创建不足一秒,sync 会故意继续 +把它视为 coordinated;停止 writer 后使用显式恢复,或等待一秒再重试 `ocx sync`。Desktop renderer 过滤属于 +独立层:catalog 和 coordinator 正确本身并不能绕过 Codex App 的 model allowlist。 + ### 路由模型的本地工具 非原生的路由 catalog 条目使用 `tool_mode: "code_mode_only"`。这样 Codex 可以公开其官方 `exec` 入口以及 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 4d103f0f06..1bacc7f712 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -129,9 +129,20 @@ ocx status --json ### `ocx doctor` -运行只读的环境与连通性诊断:状态路径和文件系统类型、WSL 双重安装、代理环境/配置、ChatGPT 可达性、Codex 插件和项目配置警告,以及待处理的历史迁移。Codex app-home 定位部分也会检测狭义的 Windows Orca 运行时 home 不匹配,并在适用时解释服务迁移。此诊断展示的路径会对操作系统用户名进行脱敏。Doctor 会输出修复提示,但不会自动应用。 +运行只读的环境与连通性诊断:状态路径和文件系统类型、WSL 双重安装、代理环境/配置、ChatGPT 可达性、Codex 插件和项目配置警告,以及待处理的历史迁移。Codex app-home 定位部分也会检测狭义的 Windows Orca 运行时 home 不匹配,并在适用时解释服务迁移。此诊断展示的路径会对操作系统用户名进行脱敏。默认报告会输出修复提示,但不会自动应用。 -**OAuth 可靠性** 部分会报告凭据存储是否可写、是否能够在 `OPENCODEX_HOME` 下创建刷新 single-flight/锁文件、不健康的 OAuth 或 Codex 池账户(脱敏 ID)及其恢复 `Action:`,并给出一条静态 OK,说明 Codex 转发路径不会伪造官方客户端元数据。Doctor 绝不会修改凭据或执行修复。 +**OAuth 可靠性** 部分会报告凭据存储是否可写、是否能够在 `OPENCODEX_HOME` 下创建刷新 single-flight/锁文件、不健康的 OAuth 或 Codex 池账户(脱敏 ID)及其恢复 `Action:`,并给出一条静态 OK,说明 Codex 转发路径不会伪造官方客户端元数据。Doctor 绝不会修改凭据;只有下面显式的零字节 coordinator 恢复会执行修复。 + +默认报告会通过 immutable 只读 SQLite 检查显示 native-write coordinator 的状态和准确路径。零字节、空的未版本化状态以及无行状态会与 catalog/app-server 健康状况分开显示,避免把 catalog 刷新成功误认为 Codex 配置注入成功。 + +停止 OpenCodex proxy/service 后,显式保存并移走已证明不含权威状态的 coordinator,然后重试 sync: + +```bash +ocx doctor --recover-zero-byte-coordinator --yes +ocx sync +``` + +恢复只接受已证明的零字节残留。任何非空、有效、未知、已变化、不安全或正忙的数据库都会被拒绝;它不会删除文件,而是在同一目录创建 `.zero-byte-backup-*` 文件。创建时间不足一秒的文件会被故意继续视为 coordinated;停止 writer 后使用上述显式恢复,或等待一秒再重试 `ocx sync`。 ## 目录同步 diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 217e2d8967..204da2e9f9 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -179,7 +179,7 @@ const commandRunners: Record = { const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor"); printCodexLogGuardDoctor(); } - return 0; + return Number(process.exitCode ?? 0); }, debug: async deps => { const { handleDebugCommand } = await import("./debug"); diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts index a5a6f7a82b..e9cd53237a 100644 --- a/src/codex/coordinator-doctor.ts +++ b/src/codex/coordinator-doctor.ts @@ -27,6 +27,8 @@ import { } from "./user-identity"; import { CODEX_COORDINATOR_SCHEMA_VERSION, + errorCode, + isBusy, readCodexCoordinatorState, } from "./transition-state"; @@ -59,12 +61,6 @@ export type CodexCoordinatorRecoveryResult = | { ok: true; backupPath: string } | { ok: false; reason: string }; -function errorCode(error: unknown): string { - return error && typeof error === "object" && "code" in error - ? String((error as { code?: unknown }).code) - : ""; -} - function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino @@ -330,9 +326,12 @@ export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordina return { ok: true, backupPath }; } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); - const busy = errorCode(cause) === "SQLITE_BUSY" || errorCode(cause) === "SQLITE_LOCKED" - || /database (?:is|table is) locked/i.test(message); - return { ok: false, reason: busy ? "the coordinator is busy; stop active sync/service writers and retry" : message }; + return { + ok: false, + reason: isBusy(cause) + ? "the coordinator is busy; stop active sync/service writers and retry" + : message, + }; } finally { if (transactionOpen) { try { database?.exec("ROLLBACK"); } catch { /* close releases the lock */ } diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index ed00fca09f..8c6f69c857 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -166,13 +166,13 @@ class CodexCoordinatorLegacyAmbiguousError extends CodexCoordinatorTransactionEr } } -function errorCode(error: unknown): string { +export function errorCode(error: unknown): string { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : ""; } -function isBusy(error: unknown): boolean { +export function isBusy(error: unknown): boolean { const code = errorCode(error); const message = error instanceof Error ? error.message : String(error); return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || /database (?:is|table is) locked/i.test(message); diff --git a/tests/codex-coordinator-doctor.test.ts b/tests/codex-coordinator-doctor.test.ts index b92b3e2175..05eef729a5 100644 --- a/tests/codex-coordinator-doctor.test.ts +++ b/tests/codex-coordinator-doctor.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { Database } from "bun:sqlite"; @@ -49,6 +49,13 @@ afterEach(() => { for (const suffix of ["", "-journal", "-wal", "-shm"]) { rmSync(`${coordinatorPath}${suffix}`, { force: true }); } + const locksDir = dirname(coordinatorPath); + const backupPrefix = `${basename(coordinatorPath)}.zero-byte-backup-`; + if (existsSync(locksDir)) { + for (const entry of readdirSync(locksDir)) { + if (entry.startsWith(backupPrefix)) rmSync(join(locksDir, entry), { force: true }); + } + } rmSync(codexHome, { recursive: true, force: true }); rmSync(opencodexHome, { recursive: true, force: true }); }); @@ -122,8 +129,10 @@ test("doctor distinguishes unversioned, rowless, and authoritative coordinators" }); test("doctor inspection is immutable and refuses sidecars, unsafe modes, and symlinks", () => { - privateFile(coordinatorPath); - expect(inspectCodexCoordinator().kind).toBe("zero-byte"); + const transaction = openCodexCoordinatorTransaction(coordinatorPath); + transaction.commit(); + transaction.close(); + expect(inspectCodexCoordinator().kind).toBe("ready"); for (const suffix of ["-journal", "-wal", "-shm"]) { expect(existsSync(`${coordinatorPath}${suffix}`)).toBe(false); } @@ -147,14 +156,14 @@ test("doctor inspection is immutable and refuses sidecars, unsafe modes, and sym } }); -test("recovery refuses a zero-byte coordinator with an active SQLite writer sidecar", () => { +test("recovery refuses a zero-byte coordinator held by an active SQLite writer", () => { privateFile(coordinatorPath); const holder = new Database(coordinatorPath, { readwrite: true, create: false }); - holder.exec("PRAGMA journal_mode = OFF; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + holder.exec("PRAGMA journal_mode = MEMORY; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); try { expect(recoverZeroByteCodexCoordinator()).toMatchObject({ ok: false, - reason: expect.stringContaining("active SQLite journal sidecar"), + reason: "the coordinator is busy; stop active sync/service writers and retry", }); expect(existsSync(coordinatorPath)).toBe(true); } finally { @@ -163,6 +172,16 @@ test("recovery refuses a zero-byte coordinator with an active SQLite writer side } }); +test("recovery refuses a zero-byte coordinator with an active SQLite journal sidecar", () => { + privateFile(coordinatorPath); + privateFile(`${coordinatorPath}-journal`, "active"); + expect(recoverZeroByteCodexCoordinator()).toEqual({ + ok: false, + reason: "coordinator state is unsafe: the coordinator has an active SQLite journal sidecar", + }); + expect(existsSync(coordinatorPath)).toBe(true); +}); + test("zero-byte residue uses the legacy boundary while clean homes still initialize", () => { privateFile(coordinatorPath); const afterStableAge = () => Date.now() + STABLE_ZERO_BYTE_COORDINATOR_AGE_MS + 1;