From 5b11dada3489360535fc12b468a0c71f89f357de Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 03:18:57 +0900 Subject: [PATCH 1/5] fix: auto-restore replaced Codex shims --- src/cli/codex-shim-autorestore.ts | 43 ++++ src/cli/index.ts | 3 + src/codex/shim.ts | 340 ++++++++++++++++++++++++++- src/config.ts | 11 + src/types.ts | 2 + tests/codex-shim-autorestore.test.ts | 132 +++++++++++ tests/codex-shim.test.ts | 212 ++++++++++++++++- tests/config.test.ts | 41 ++++ 8 files changed, 779 insertions(+), 5 deletions(-) create mode 100644 src/cli/codex-shim-autorestore.ts create mode 100644 tests/codex-shim-autorestore.test.ts diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts new file mode 100644 index 000000000..1636a27ff --- /dev/null +++ b/src/cli/codex-shim-autorestore.ts @@ -0,0 +1,43 @@ +import { autoRestoreCodexShim } from "../codex/shim"; +import { codexShimAutoRestoreEnabled, readConfigDiagnostics } from "../config"; + +export interface CodexShimAutoRestoreCliDeps { + env: NodeJS.ProcessEnv; + warn: (message: string) => void; + restore: typeof autoRestoreCodexShim; + readConfig: typeof readConfigDiagnostics; +} + +const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = { + env: process.env, + warn: message => console.warn(message), + restore: autoRestoreCodexShim, + readConfig: readConfigDiagnostics, +}; + +export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean { + if (command === "uninstall" || command === "remove") return true; + return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? ""); +} + +export function maybeAutoRestoreCodexShim( + command: string | undefined, + args: string[], + deps: CodexShimAutoRestoreCliDeps = DEFAULT_DEPS, +): void { + if (skipsCodexShimAutoRestore(command, args)) return; + try { + const result = deps.restore({ + enabled: () => codexShimAutoRestoreEnabled(deps.readConfig().config, deps.env), + }); + if (result.status === "restored") { + deps.warn(`⚠️ ${result.message} (automatic repair after Codex update)`); + } + } catch (error) { + deps.warn( + `⚠️ Codex shim auto-restore failed; continuing without it: ${ + error instanceof Error ? error.message : String(error) + }. Run 'ocx codex-shim install' after the Codex update finishes.`, + ); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 3d1d33cbf..50a7cf4a0 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -36,6 +36,7 @@ import { buildDesktop3pRegistry } from "../claude/desktop-3p"; import { installShellHook, uninstallShellHook } from "../server/system-env"; import { startTokenGuardian } from "../oauth/token-guardian"; import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian"; +import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; import { maybeShowStarPrompt } from "./star-prompt"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; @@ -59,6 +60,8 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) { process.exit(0); } +maybeAutoRestoreCodexShim(command, args); + function parsePortOption(): number | undefined { if (args.length === 1) return undefined; if (args.length !== 3 || args[1] !== "--port") { diff --git a/src/codex/shim.ts b/src/codex/shim.ts index c3216d711..9951ed487 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,5 +1,18 @@ import { delimiter, dirname, extname, join, posix } from "node:path"; -import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { getConfigDir } from "../config"; import { durableBunPath } from "../lib/bun-runtime"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; @@ -7,6 +20,8 @@ import { windowsEnvIndirectBatchValue } from "../lib/win-paths"; import { isWslRuntime, wslAutomountRoot } from "./home"; const SHIM_MARKER = "opencodex codex autostart shim"; +const CODEX_SHIM_PROBE_BYTES = 16 * 1024; +export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100; let lastShimDiscoveryError: string | null = null; /** Last human-readable reason discovery returned null (exposed for doctor/tests). */ export function lastCodexDiscoveryError(): string | null { @@ -67,6 +82,32 @@ interface ShimFileState { preserveOnly?: boolean; } +interface ShimPathFingerprint { + dev: number; + ino: number; + kind: "file" | "symlink"; + mode: number; + size: number; + mtimeMs: number; + ctimeMs: number; + target?: Omit; +} + +interface StableShimPathProbe { + fingerprint: ShimPathFingerprint; + prefix: string; +} + +interface InstallCodexShimInternalOptions { + expectedReplacements?: ReadonlyMap; + allowFreshInstall: boolean; + beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; +} + +export type CodexShimAutoRestoreResult = + | { status: "not-installed" | "healthy" | "ineligible" | "deferred" | "disabled" } + | { status: "restored"; message: string }; + function cliEntry(): { bun: string; cli: string } { // Bundled Bun path (survives `ocx update`); all three shim builders // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. @@ -99,6 +140,101 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { } } +function readShimProbePrefix(path: string): string { + const fd = openSync(path, "r"); + try { + const buffer = Buffer.allocUnsafe(CODEX_SHIM_PROBE_BYTES); + const bytesRead = readSync(fd, buffer, 0, buffer.length, 0); + return buffer.toString("utf8", 0, bytesRead); + } finally { + closeSync(fd); + } +} + +function statFingerprint(path: string, follow: boolean): Omit | null { + try { + const stat = follow ? statSync(path) : lstatSync(path); + if (follow ? !stat.isFile() : (!stat.isFile() && !stat.isSymbolicLink())) return null; + return { + dev: stat.dev, + ino: stat.ino, + kind: stat.isSymbolicLink() ? "symlink" : "file", + mode: stat.mode, + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + }; + } catch { + return null; + } +} + +function sameFingerprint( + left: ShimPathFingerprint | Omit, + right: ShimPathFingerprint | Omit, +): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.kind === right.kind + && left.mode === right.mode + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs + && (!("target" in left) || !("target" in right) + ? true + : left.target === undefined && right.target === undefined + ? true + : left.target !== undefined && right.target !== undefined + ? sameFingerprint(left.target, right.target) + : false); +} + +function stableShimPathProbe(path: string): StableShimPathProbe | null { + const before = statFingerprint(path, false); + if (!before) return null; + const targetBefore = before.kind === "symlink" ? statFingerprint(path, true) : undefined; + if (before.kind === "symlink" && !targetBefore) return null; + let prefix: string; + try { + prefix = readShimProbePrefix(path); + } catch { + return null; + } + const targetAfter = before.kind === "symlink" ? statFingerprint(path, true) : undefined; + const after = statFingerprint(path, false); + if (!after || !sameFingerprint(before, after)) return null; + if (before.kind === "symlink") { + if (!targetBefore || !targetAfter || !sameFingerprint(targetBefore, targetAfter)) return null; + } + const fingerprint: ShimPathFingerprint = { + ...before, + ...(targetBefore ? { target: targetBefore } : {}), + }; + const contentSize = fingerprint.target?.size ?? fingerprint.size; + return contentSize > 0 ? { fingerprint, prefix } : null; +} + +function fingerprintIsOldEnough(fingerprint: ShimPathFingerprint, nowMs: number): boolean { + const timestamps = [fingerprint.mtimeMs, fingerprint.ctimeMs]; + if (fingerprint.target) timestamps.push(fingerprint.target.mtimeMs, fingerprint.target.ctimeMs); + return nowMs - Math.max(...timestamps) >= CODEX_SHIM_REPLACEMENT_STABLE_MS; +} + +function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean { + if (probe.prefix.length < 180 || !probe.prefix.includes(SHIM_MARKER) || !probe.prefix.includes("ensure")) return false; + const mode = probe.fingerprint.target?.mode ?? probe.fingerprint.mode; + return platform === "win32" || (mode & 0o111) !== 0; +} + +function hasUsableBackingPath(file: ShimFileState): boolean { + return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath] + .some(path => { + if (!path) return false; + const fingerprint = statFingerprint(path, true); + return fingerprint !== null && fingerprint.size > 0; + }); +} + /** * A PATH entry that reaches Windows through WSL drive interop * (`//...`; root defaults to /mnt, configurable via @@ -468,10 +604,158 @@ function refreshShimFile(file: ShimFileState): boolean { return false; } -export function installCodexShim(): { installed: boolean; message: string } { +interface GuardedRefreshOperation { + file: ShimFileState; + expectedReplacement: ShimPathFingerprint; + sourcePath: string; +} + +interface GuardedRefreshJournalEntry { + operation: GuardedRefreshOperation; + stagedOldBackupPath?: string; + replacementMovedToBackup: boolean; + wrapperWriteStarted: boolean; +} + +let guardedRefreshTransactionId = 0; + +function planGuardedRefreshTransaction( + files: readonly ShimFileState[], + expectedReplacements: ReadonlyMap, +): GuardedRefreshOperation[] | null { + const operations: GuardedRefreshOperation[] = []; + const seen = new Set(); + for (const file of files) { + if (seen.has(file.wrapperPath)) return null; + seen.add(file.wrapperPath); + if (file.preserveOnly) { + if (!existsSync(file.backupPath) || existsSync(file.originalPath)) return null; + continue; + } + if (!hasUsableBackingPath(file)) return null; + const probe = stableShimPathProbe(file.wrapperPath); + if (!probe) return null; + const expectedReplacement = expectedReplacements.get(file.wrapperPath); + if (!expectedReplacement) { + if (!isHealthyShimProbe(probe, process.platform)) return null; + continue; + } + if (file.wrapperPath !== file.originalPath + || probe.prefix.includes(SHIM_MARKER) + || !sameFingerprint(probe.fingerprint, expectedReplacement)) return null; + operations.push({ file, expectedReplacement, sourcePath: file.wrapperPath }); + } + if (operations.length !== expectedReplacements.size) return null; + return operations; +} + +function rollbackGuardedRefresh(journal: readonly GuardedRefreshJournalEntry[]): Error[] { + const rollbackErrors: Error[] = []; + const attempt = (operation: () => void): void => { + try { + operation(); + } catch (error) { + rollbackErrors.push(error instanceof Error ? error : new Error(String(error))); + } + }; + for (const entry of [...journal].reverse()) { + attempt(() => { + if (entry.wrapperWriteStarted && existsSync(entry.operation.file.wrapperPath)) { + unlinkSync(entry.operation.file.wrapperPath); + } + }); + attempt(() => { + if (entry.replacementMovedToBackup && existsSync(entry.operation.file.backupPath)) { + renameSync(entry.operation.file.backupPath, entry.operation.sourcePath); + } + }); + attempt(() => { + if (entry.stagedOldBackupPath && existsSync(entry.stagedOldBackupPath)) { + renameSync(entry.stagedOldBackupPath, entry.operation.file.backupPath); + } + }); + } + return rollbackErrors; +} + +function applyGuardedRefreshTransaction( + operations: readonly GuardedRefreshOperation[], + beforeGuardedRefresh?: (wrapperPath: string, index: number) => void, +): boolean { + const journal: GuardedRefreshJournalEntry[] = []; + let applyError: Error | null = null; + let fingerprintMismatch = false; + const transactionId = `${process.pid}-${++guardedRefreshTransactionId}`; + + for (const [index, operation] of operations.entries()) { + beforeGuardedRefresh?.(operation.sourcePath, index); + const probe = stableShimPathProbe(operation.sourcePath); + if (!probe || !sameFingerprint(probe.fingerprint, operation.expectedReplacement)) { + fingerprintMismatch = true; + break; + } + const entry: GuardedRefreshJournalEntry = { + operation, + replacementMovedToBackup: false, + wrapperWriteStarted: false, + }; + journal.push(entry); + try { + if (existsSync(operation.file.backupPath)) { + entry.stagedOldBackupPath = `${operation.file.backupPath}.autorestore-${transactionId}-${index}`; + if (existsSync(entry.stagedOldBackupPath)) unlinkSync(entry.stagedOldBackupPath); + renameSync(operation.file.backupPath, entry.stagedOldBackupPath); + } + renameSync(operation.sourcePath, operation.file.backupPath); + entry.replacementMovedToBackup = true; + entry.wrapperWriteStarted = true; + writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath); + } catch (error) { + applyError = error instanceof Error ? error : new Error(String(error)); + break; + } + } + + if (fingerprintMismatch || applyError) { + const rollbackErrors = rollbackGuardedRefresh(journal); + if (applyError || rollbackErrors.length > 0) { + throw new AggregateError( + [...(applyError ? [applyError] : []), ...rollbackErrors], + "Codex shim guarded refresh failed", + ); + } + return false; + } + + const cleanupErrors: Error[] = []; + for (const entry of journal) { + try { + if (entry.stagedOldBackupPath && existsSync(entry.stagedOldBackupPath)) unlinkSync(entry.stagedOldBackupPath); + } catch (error) { + cleanupErrors.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Codex shim guarded refresh cleanup failed"); + return true; +} + +function installCodexShimInternal(options: InstallCodexShimInternalOptions): { installed: boolean; message: string } { const existing = readState(); if (existing) { const files = stateFiles(existing); + if (options.expectedReplacements) { + const operations = planGuardedRefreshTransaction(files, options.expectedReplacements); + if (!operations || operations.length === 0) { + return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." }; + } + if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh)) { + return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." }; + } + return { + installed: true, + message: `Codex update detected. Backed up new launcher and refreshed shim at ${files.map(f => f.wrapperPath).join(", ")}.`, + }; + } let refreshed = false; for (const file of files) refreshed = refreshShimFile(file) || refreshed; const allInstalled = files.every(file => file.preserveOnly @@ -494,6 +778,10 @@ export function installCodexShim(): { installed: boolean; message: string } { } } + if (!options.allowFreshInstall) { + return { installed: false, message: "Codex shim auto-restore requires a valid prior installation." }; + } + const targets: ShimFileState[] | null = process.platform === "win32" ? findWindowsCodexTargets() : (() => { @@ -516,6 +804,54 @@ export function installCodexShim(): { installed: boolean; message: string } { }; } +export function installCodexShim(): { installed: boolean; message: string } { + return installCodexShimInternal({ allowFreshInstall: true }); +} + +export function autoRestoreCodexShim(options: { + enabled: () => boolean; + nowMs?: number; + /** Narrow deterministic race seam for the guarded transaction tests. */ + beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; +}): CodexShimAutoRestoreResult { + const state = readState(); + if (!state) return { status: existsSync(statePath()) ? "ineligible" : "not-installed" }; + if (state.platform !== process.platform) return { status: "ineligible" }; + + const files = stateFiles(state); + const expectedReplacements = new Map(); + const seen = new Set(); + const nowMs = options.nowMs ?? Date.now(); + for (const file of files) { + if (seen.has(file.wrapperPath)) return { status: "ineligible" }; + seen.add(file.wrapperPath); + if (file.preserveOnly) { + if (!existsSync(file.backupPath) || existsSync(file.originalPath)) return { status: "ineligible" }; + continue; + } + if (!existsSync(file.wrapperPath) || !hasUsableBackingPath(file)) return { status: "ineligible" }; + const probe = stableShimPathProbe(file.wrapperPath); + if (!probe) return { status: "deferred" }; + if (probe.prefix.includes(SHIM_MARKER)) { + if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" }; + continue; + } + if (!fingerprintIsOldEnough(probe.fingerprint, nowMs)) return { status: "deferred" }; + expectedReplacements.set(file.wrapperPath, probe.fingerprint); + } + + if (expectedReplacements.size === 0) return { status: "healthy" }; + if (!options.enabled()) return { status: "disabled" }; + const result = installCodexShimInternal({ + allowFreshInstall: false, + expectedReplacements, + beforeGuardedRefresh: options.beforeGuardedRefresh, + }); + return result.installed + ? { status: "restored", message: result.message } + : { status: "deferred" }; +} + export function uninstallCodexShim(): { removed: boolean; message: string } { const state = readState(); if (!state) return { removed: false, message: "Codex autostart shim is not installed." }; diff --git a/src/config.ts b/src/config.ts index f21691344..6368ce64a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -442,6 +442,7 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), + codexShimAutoRestore: z.boolean().optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. @@ -793,6 +794,15 @@ export function codexAutoStartEnabled(config: Pick) return config.codexAutoStart !== false; } +export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE"; + +export function codexShimAutoRestoreEnabled( + config: Pick, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0"; +} + export function multiAgentGuidanceEnabled( config: Pick, ): boolean { @@ -822,6 +832,7 @@ export function getDefaultConfig(): OcxConfig { multiAgentGuidanceEnabled: true, websockets: false, codexAutoStart: true, + codexShimAutoRestore: true, }; } diff --git a/src/types.ts b/src/types.ts index 97a16597f..300fc531a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -540,6 +540,8 @@ export interface OcxConfig { apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>; /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ codexAutoStart?: boolean; + /** Restore an installed shim after a stable external Codex update replaces it. Default true. */ + codexShimAutoRestore?: boolean; /** * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default diff --git a/tests/codex-shim-autorestore.test.ts b/tests/codex-shim-autorestore.test.ts new file mode 100644 index 000000000..1c2b589e8 --- /dev/null +++ b/tests/codex-shim-autorestore.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodexShimAutoRestoreResult } from "../src/codex/shim"; +import { getDefaultConfig } from "../src/config"; +import { + maybeAutoRestoreCodexShim, + skipsCodexShimAutoRestore, + type CodexShimAutoRestoreCliDeps, +} from "../src/cli/codex-shim-autorestore"; +import { installCodexShim } from "../src/codex/shim"; + +const SHIM_MARKER = "opencodex codex autostart shim"; + +function cliDeps( + result: CodexShimAutoRestoreResult, + overrides: Partial = {}, +): { deps: CodexShimAutoRestoreCliDeps; warnings: string[]; readConfigCalls: () => number } { + const warnings: string[] = []; + let configCalls = 0; + const deps: CodexShimAutoRestoreCliDeps = { + env: {}, + warn: message => warnings.push(message), + restore: () => result, + readConfig: () => { + configCalls += 1; + return { config: getDefaultConfig(), source: "default", error: null }; + }, + ...overrides, + }; + return { deps, warnings, readConfigCalls: () => configCalls }; +} + +describe("Codex shim CLI auto-restore policy", () => { + test("skips destructive and explicit repair commands but keeps status and ordinary commands eligible", () => { + expect(skipsCodexShimAutoRestore("uninstall", ["uninstall"])).toBe(true); + expect(skipsCodexShimAutoRestore("remove", ["remove"])).toBe(true); + for (const subcommand of ["install", "uninstall", "remove"]) { + expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", subcommand])).toBe(true); + } + expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", "status"])).toBe(false); + expect(skipsCodexShimAutoRestore("status", ["status"])).toBe(false); + }); + + test("restore failure -> warning only, command succeeds", () => { + const { deps, warnings } = cliDeps({ status: "healthy" }, { + restore: () => { throw new Error("permission denied"); }, + }); + + expect(maybeAutoRestoreCodexShim("status", ["status"], deps)).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("continuing without it"); + expect(warnings[0]).toContain("ocx codex-shim install"); + }); + + test("successful automatic repair warns exactly once", () => { + const { deps, warnings } = cliDeps({ status: "restored", message: "restored shim" }); + maybeAutoRestoreCodexShim("status", ["status"], deps); + expect(warnings).toEqual([expect.stringContaining("automatic repair after Codex update")]); + }); + + test("healthy, not-installed, disabled, and deferred outcomes stay silent and lazy", () => { + for (const status of ["healthy", "not-installed", "disabled", "deferred"] as const) { + const { deps, warnings, readConfigCalls } = cliDeps({ status }); + maybeAutoRestoreCodexShim("status", ["status"], deps); + expect(warnings).toEqual([]); + expect(readConfigCalls()).toBe(0); + } + }); + + test("opt-out config and environment are evaluated lazily only for a candidate", () => { + for (const [configValue, env] of [[false, {}], [true, { OPENCODEX_CODEX_SHIM_AUTO_RESTORE: "0" }]] as const) { + let enabledValue: boolean | undefined; + const { deps, warnings } = cliDeps({ status: "disabled" }, { + env, + restore: options => { + enabledValue = options.enabled(); + return { status: enabledValue ? "deferred" : "disabled" }; + }, + readConfig: () => ({ + config: { ...getDefaultConfig(), codexShimAutoRestore: configValue }, + source: "file", + error: null, + }), + }); + maybeAutoRestoreCodexShim("status", ["status"], deps); + expect(enabledValue).toBe(false); + expect(warnings).toEqual([]); + } + }); + + test("shim replaced -> next ocx command auto-restores and warns", async () => { + if (process.platform === "win32") return; + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-activation-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-activation-home-")); + const wrapper = join(binDir, "codex"); + const backup = join(binDir, "codex.opencodex-real"); + const replacement = "#!/bin/sh\necho externally updated codex\n"; + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.PATH = binDir; + process.env.OPENCODEX_HOME = home; + writeFileSync(wrapper, "#!/bin/sh\necho original codex\n", "utf8"); + chmodSync(wrapper, 0o755); + expect(installCodexShim().installed).toBe(true); + writeFileSync(wrapper, replacement, "utf8"); + chmodSync(wrapper, 0o755); + await Bun.sleep(120); + + const result = spawnSync(process.execPath, [join(import.meta.dir, "..", "src", "cli", "index.ts"), "codex-shim", "status"], { + encoding: "utf8", + env: { ...process.env, PATH: binDir, OPENCODEX_HOME: home }, + }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("automatic repair after Codex update"); + expect(result.stdout).toContain("wrapper shim present"); + expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(backup, "utf8")).toBe(replacement); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index d7176f5a0..4c7ec2c4b 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -1,12 +1,53 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync } from "node:fs"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, uninstallCodexShim } from "../src/codex/shim"; +import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; +function withInstalledShim(run: (paths: { + binDir: string; + home: string; + wrappers: string[]; + backups: string[]; + statePath: string; +}) => void): void { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const wrappers = process.platform === "win32" + ? [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")] + : [join(binDir, "codex")]; + try { + process.env.PATH = binDir; + process.env.OPENCODEX_HOME = home; + for (const wrapper of wrappers) { + writeFileSync(wrapper, process.platform === "win32" ? `real ${wrapper}\n` : "#!/bin/sh\necho real\n", "utf8"); + if (process.platform !== "win32") chmodSync(wrapper, 0o755); + } + expect(installCodexShim().installed).toBe(true); + const statePath = join(home, "codex-shim.json"); + const state = JSON.parse(readFileSync(statePath, "utf8")) as { wrappers: Array<{ wrapperPath: string; backupPath: string }> }; + run({ + binDir, + home, + wrappers: state.wrappers.map(file => file.wrapperPath), + backups: state.wrappers.map(file => file.backupPath), + statePath, + }); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +} + describe("Codex autostart shim", () => { test("builds a Unix shim that starts ocx before execing Codex", () => { const script = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts"); @@ -318,6 +359,171 @@ describe("Codex autostart shim", () => { rmSync(home, { recursive: true, force: true }); } }); + + test("shim intact -> zero-overhead path is read-only and never loads config", () => { + withInstalledShim(({ wrappers, backups, statePath }) => { + const paths = [...wrappers, ...backups, statePath]; + const before = paths.map(path => ({ + path, + bytes: readFileSync(path), + mtimeMs: statSync(path).mtimeMs, + })); + let enabledCalls = 0; + + expect(autoRestoreCodexShim({ + enabled: () => { + enabledCalls += 1; + return true; + }, + })).toEqual({ status: "healthy" }); + expect(enabledCalls).toBe(0); + for (const snapshot of before) { + expect(readFileSync(snapshot.path)).toEqual(snapshot.bytes); + expect(statSync(snapshot.path).mtimeMs).toBe(snapshot.mtimeMs); + } + }); + }); + + test("stable shim replacement restores through the shared install transaction", () => { + withInstalledShim(({ wrappers, backups }) => { + const replacements = wrappers.map((wrapper, index) => `replacement-${index}\n`); + wrappers.forEach((wrapper, index) => writeFileSync(wrapper, replacements[index], "utf8")); + + const result = autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }); + + expect(result.status).toBe("restored"); + wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); + backups.forEach((backup, index) => expect(readFileSync(backup, "utf8")).toBe(replacements[index])); + }); + }); + + test("npm update in progress -> young replacement defers, then restores after stability", () => { + withInstalledShim(({ wrappers, backups }) => { + const oldBackups = backups.map(path => readFileSync(path)); + wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `young-${index}\n`, "utf8")); + + expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() })).toEqual({ status: "deferred" }); + wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(`young-${index}\n`)); + backups.forEach((backup, index) => expect(readFileSync(backup)).toEqual(oldBackups[index])); + + expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("restored"); + }); + }); + + test("opt-out set -> no restore and explicit install remains available", () => { + withInstalledShim(({ wrappers }) => { + wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `disabled-${index}\n`, "utf8")); + + expect(autoRestoreCodexShim({ enabled: () => false, nowMs: Date.now() + 1_000 })).toEqual({ status: "disabled" }); + wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(`disabled-${index}\n`)); + expect(installCodexShim().installed).toBe(true); + wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); + }); + }); + + test("fingerprint mismatch before guarded rename defers without owned-path mutation", () => { + withInstalledShim(({ wrappers, backups, statePath }) => { + wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `candidate-${index}\n`, "utf8")); + const oldBackups = backups.map(path => readFileSync(path)); + const oldState = readFileSync(statePath); + + const result = autoRestoreCodexShim({ + enabled: () => true, + nowMs: Date.now() + 1_000, + beforeGuardedRefresh: (wrapperPath, index) => { + if (index === 0) writeFileSync(wrapperPath, "concurrent replacement\n", "utf8"); + }, + }); + + expect(result).toEqual({ status: "deferred" }); + expect(readFileSync(wrappers[0], "utf8")).toBe("concurrent replacement\n"); + backups.forEach((backup, index) => expect(readFileSync(backup)).toEqual(oldBackups[index])); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("multi-wrapper restore rolls back when a later sibling fingerprint changes", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-shim-transaction-home-")); + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-transaction-bin-")); + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.OPENCODEX_HOME = home; + const wrappers = [join(binDir, "codex.cmd"), join(binDir, "codex.ps1")]; + const backups = [join(binDir, "codex.opencodex-real.cmd"), join(binDir, "codex.opencodex-real.ps1")]; + const wrapperBytes = ["replacement cmd\n", "replacement ps1\n"]; + const backupBytes = ["prior cmd\n", "prior ps1\n"]; + wrappers.forEach((path, index) => writeFileSync(path, wrapperBytes[index], "utf8")); + backups.forEach((path, index) => writeFileSync(path, backupBytes[index], "utf8")); + const statePath = join(home, "codex-shim.json"); + writeFileSync(statePath, JSON.stringify({ + platform: process.platform, + wrapperPath: wrappers[0], + originalPath: wrappers[0], + backupPath: backups[0], + wrappers: wrappers.map((wrapperPath, index) => ({ + wrapperPath, + originalPath: wrapperPath, + backupPath: backups[index], + })), + }, null, 2) + "\n", "utf8"); + const stateBytes = readFileSync(statePath); + const modes = [...wrappers, ...backups].map(path => statSync(path).mode & 0o777); + + const result = autoRestoreCodexShim({ + enabled: () => true, + nowMs: Date.now() + 1_000, + beforeGuardedRefresh: (wrapperPath, index) => { + if (index === 1) { + const originalMtime = statSync(wrapperPath).mtime; + utimesSync(wrapperPath, originalMtime.getTime() / 1_000 - 1, originalMtime); + } + }, + }); + + expect(result).toEqual({ status: "deferred" }); + wrappers.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(wrapperBytes[index])); + backups.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(backupBytes[index])); + expect(readFileSync(statePath)).toEqual(stateBytes); + [...wrappers, ...backups].forEach((path, index) => expect(statSync(path).mode & 0o777).toBe(modes[index])); + expect(readdirSync(binDir).filter(name => name.includes(".autorestore-"))).toEqual([]); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(home, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("missing backup, missing wrapper, corrupt state, and platform mismatch never fresh-install", () => { + withInstalledShim(({ wrappers, backups, statePath }) => { + rmSync(backups[0]); + expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("ineligible"); + + writeFileSync(backups[0], "backup\n", "utf8"); + rmSync(wrappers[0]); + expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("ineligible"); + + if (process.platform !== "win32") { + mkdirSync(wrappers[0]); + expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("deferred"); + rmSync(wrappers[0], { recursive: true }); + symlinkSync(join(dirname(wrappers[0]), "missing-target"), wrappers[0]); + expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("ineligible"); + } + + writeFileSync(statePath, "{broken", "utf8"); + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("ineligible"); + + const otherPlatform = process.platform === "win32" ? "linux" : "win32"; + writeFileSync(statePath, JSON.stringify({ + platform: otherPlatform, + wrapperPath: wrappers[0], + originalPath: wrappers[0], + backupPath: backups[0], + }), "utf8"); + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("ineligible"); + }); + }); }); describe("WSL PATH interop guard", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 16091d548..ac4b31bbb 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -3,7 +3,9 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { + CODEX_SHIM_AUTO_RESTORE_ENV, codexAutoStartEnabled, + codexShimAutoRestoreEnabled, getConfigPath, getDefaultConfig, getPidPath, @@ -103,6 +105,45 @@ describe("opencodex config defaults", () => { expect(codexAutoStartEnabled({ codexAutoStart: true })).toBe(true); }); + test("Codex shim auto-restore defaults on with config and environment opt-out precedence", () => { + expect(getDefaultConfig().codexShimAutoRestore).toBe(true); + expect(codexShimAutoRestoreEnabled({}, {})).toBe(true); + expect(codexShimAutoRestoreEnabled({ codexShimAutoRestore: true }, {})).toBe(true); + expect(codexShimAutoRestoreEnabled({ codexShimAutoRestore: false }, {})).toBe(false); + expect(codexShimAutoRestoreEnabled( + { codexShimAutoRestore: false }, + { [CODEX_SHIM_AUTO_RESTORE_ENV]: "1" }, + )).toBe(false); + expect(codexShimAutoRestoreEnabled({}, { [CODEX_SHIM_AUTO_RESTORE_ENV]: "0" })).toBe(false); + expect(codexShimAutoRestoreEnabled( + { codexShimAutoRestore: true }, + { [CODEX_SHIM_AUTO_RESTORE_ENV]: "0" }, + )).toBe(false); + }); + + test("codexShimAutoRestore loads false and rejects non-booleans", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, codexShimAutoRestore: false }); + expect(readConfigDiagnostics().config.codexShimAutoRestore).toBe(false); + + for (const invalid of [null, "false", 0]) { + writeConfig({ ...base, codexShimAutoRestore: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("codexShimAutoRestore"); + } + }); + test("multi-agent guidance is default-on and false is the only off state", () => { expect(getDefaultConfig().multiAgentGuidanceEnabled).toBe(true); expect(multiAgentGuidanceEnabled({})).toBe(true); From 11319c19481db5d8341b534436a0369462c9be7b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 03:19:13 +0900 Subject: [PATCH 2/5] docs: document Codex shim auto-restore --- README.ja.md | 9 ++++++++- README.ko.md | 8 +++++++- README.md | 8 +++++++- README.ru.md | 8 +++++++- README.zh-CN.md | 8 +++++++- docs-site/src/content/docs/ja/reference/cli.md | 8 ++++++-- .../src/content/docs/ja/reference/configuration.md | 1 + docs-site/src/content/docs/ko/reference/cli.md | 7 +++++-- .../src/content/docs/ko/reference/configuration.md | 1 + docs-site/src/content/docs/reference/cli.md | 7 +++++-- docs-site/src/content/docs/reference/configuration.md | 1 + docs-site/src/content/docs/ru/reference/cli.md | 8 ++++++-- .../src/content/docs/ru/reference/configuration.md | 1 + docs-site/src/content/docs/zh-cn/reference/cli.md | 7 +++++-- .../src/content/docs/zh-cn/reference/configuration.md | 1 + structure/01_runtime.md | 11 +++++++++-- 16 files changed, 77 insertions(+), 17 deletions(-) diff --git a/README.ja.md b/README.ja.md index b917845f0..afc187a01 100644 --- a/README.ja.md +++ b/README.ja.md @@ -273,10 +273,17 @@ opencodex にはプロキシを自動起動する方法が 2 つあります: | **方式** | OS サービスマネージャー(launchd / systemd / schtasks) | `codex` スクリプトランチャーをラップし実際の `codex.exe` は触らない | | **タイミング** | ログイン後に常時実行 | オンデマンド — `codex` 起動時に `ocx ensure` を実行 | | **再起動** | クラッシュ時に自動再起動 | `codex` 呼び出しごとに 1 回起動 | -| **Codex 更新** | 影響なし | `ocx codex-shim install` または `ocx update` 時に修復 | +| **Codex 更新** | 影響なし | 安定して置換されたランチャーは次の通常の `ocx` コマンドで修復 | | **削除** | `ocx service uninstall` | `ocx codex-shim uninstall` | 常にプロキシを起動しておくには **service**(開発マシン推奨)、軽くオンデマンドで使うには **shim** を使ってください。 + +外部の Codex 更新でインストール済み shim が上書きされた場合、次の通常の `ocx` コマンドが +安定した新しいランチャーをバックアップして shim を復元します。まだ変更中のランチャーには触れず、 +後続のコマンドで再試行します。修復失敗は要求されたコマンドを失敗させず警告だけを表示し、手動の +代替手段は `ocx codex-shim install` です。自動修復を無効にするには +`codexShimAutoRestore` を `false` にするか、プロセスで +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 shim 自動起動はデフォルトでオンで、GUI ダッシュボードからオフにできます。設定されたプロキシポートが既に使用 中の場合、`ocx start` が自動的に別の空きローカルポートを選び、Codex の設定もそのポートに更新します。 diff --git a/README.ko.md b/README.ko.md index 2d5d9dad8..2da089357 100644 --- a/README.ko.md +++ b/README.ko.md @@ -264,10 +264,16 @@ opencodex에는 프록시를 자동 시작하는 두 가지 방법이 있습니 | **방식** | OS 서비스 관리자 (launchd / systemd / schtasks) | `codex` 스크립트 런처를 래핑하며 실제 `codex.exe`는 건드리지 않음 | | **시점** | 로그인 후 항상 실행 | 온디맨드 — `codex` 실행 시 `ocx ensure` 실행 | | **재시작** | 크래시 시 자동 재시작 | `codex` 호출마다 한 번 시작 | -| **Codex 업데이트** | 영향 없음 | `ocx codex-shim install` 또는 `ocx update` 시 복구 | +| **Codex 업데이트** | 영향 없음 | 안정적으로 교체가 끝난 런처는 다음 일반 `ocx` 명령에서 복구 | | **제거** | `ocx service uninstall` | `ocx codex-shim uninstall` | 항상 프록시를 켜두려면 **service** (개발 머신 권장), 가볍게 온디맨드로 쓰려면 **shim**을 사용하세요. + +외부 Codex 업데이트가 설치된 shim을 덮어쓰면 다음 일반 `ocx` 명령이 안정화된 새 런처를 백업하고 +shim을 복구합니다. 아직 변경 중인 런처는 건드리지 않고 이후 명령에서 다시 시도합니다. 복구 실패는 +요청한 명령을 실패시키지 않고 경고만 출력하며, 수동 대체 명령은 `ocx codex-shim install`입니다. +자동 복구를 끄려면 `codexShimAutoRestore`를 `false`로 설정하거나 프로세스에 +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정하세요. shim 자동 시작은 기본으로 켜져 있으며 GUI 대시보드에서 끌 수 있습니다. 설정된 프록시 포트가 이미 사용 중이면 `ocx start`가 자동으로 다른 빈 로컬 포트를 고르고 Codex 설정도 그 포트로 갱신합니다. diff --git a/README.md b/README.md index 3847d32ed..07462ffd4 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ opencodex has two ways to auto-start the proxy: | **How** | OS service manager (launchd / systemd / schtasks) | Wraps script launchers for `codex`; real `codex.exe` is left untouched | | **When** | Always running after login | On-demand — runs `ocx ensure` when `codex` is launched | | **Restart** | Auto-restarts on crash | Starts once per `codex` invocation | -| **Codex updates** | Unaffected | Repairs on next `ocx codex-shim install` or `ocx update` | +| **Codex updates** | Unaffected | A completed stable launcher replacement is repaired by the next ordinary `ocx` command | | **Remove** | `ocx service uninstall` | `ocx codex-shim uninstall` | Use the **service** for always-on proxy (recommended for development machines). Use the **shim** for @@ -313,6 +313,12 @@ lightweight, on-demand proxy startup without a background daemon. Shim autostart and can be disabled from the GUI dashboard. If the configured proxy port is already busy, `ocx start` automatically picks another free local port and updates Codex to use it. +If an external Codex update overwrites an installed shim, the next ordinary `ocx` command backs up +the stable new launcher and restores the shim. A launcher that is still changing is left untouched +and retried later. Repair failures warn without failing the requested command; use +`ocx codex-shim install` as the manual fallback. Set `codexShimAutoRestore` to `false`, or set +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. + ### Uninstall Before removing the npm package, clean up local state: diff --git a/README.ru.md b/README.ru.md index 923e10865..99c984539 100644 --- a/README.ru.md +++ b/README.ru.md @@ -305,10 +305,16 @@ ocx update [--tag preview] # обновить opencodex; preview-устан | **Как** | Менеджер служб ОС (launchd / systemd / schtasks) | Оборачивает скриптовые лончеры `codex`; настоящий `codex.exe` не затрагивается | | **Когда** | Всегда работает после входа в систему | По требованию — выполняет `ocx ensure` при запуске `codex` | | **Перезапуск** | Автоматический перезапуск при сбое | Запускается один раз на каждый вызов `codex` | -| **Обновления Codex** | Не влияют | Восстанавливается при следующем `ocx codex-shim install` или `ocx update` | +| **Обновления Codex** | Не влияют | Стабильно заменённый лончер восстанавливается следующей обычной командой `ocx` | | **Удаление** | `ocx service uninstall` | `ocx codex-shim uninstall` | Используйте **службу**, если прокси должен работать постоянно (рекомендуется для машин разработчиков). +Если внешнее обновление Codex перезапишет установленный shim, следующая обычная команда `ocx` +сохранит стабильный новый лончер в резервную копию и восстановит shim. Лончер, который ещё меняется, +остаётся нетронутым до следующей команды. Ошибка восстановления выдаёт предупреждение, но не приводит +к сбою запрошенной команды; ручной вариант — `ocx codex-shim install`. Для отключения установите +`codexShimAutoRestore` в `false` или задайте процессу +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. Используйте **shim** для лёгкого запуска прокси по требованию без фонового демона. Автозапуск через shim включён по умолчанию и отключается в GUI-панели управления. Если настроенный порт прокси уже занят, `ocx start` автоматически выберет другой свободный локальный порт и обновит настройки Codex. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8d684b77b..a40c160eb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -257,10 +257,16 @@ opencodex 提供两种自动启动代理的方式: | **方式** | OS 服务管理器(launchd / systemd / schtasks) | 包装 `codex` 脚本启动器;不会改动真实 `codex.exe` | | **时机** | 登录后始终运行 | 按需 — 仅在运行 `codex` 时启动 | | **重启** | 崩溃后自动重启 | 每次调用 `codex` 时启动一次 | -| **Codex 更新** | 不受影响 | 下次运行 `ocx codex-shim install` 或 `ocx update` 时修复 | +| **Codex 更新** | 不受影响 | 稳定完成的启动器替换会在下一条普通 `ocx` 命令中修复 | | **移除** | `ocx service uninstall` | `ocx codex-shim uninstall` | 如需常驻代理,使用 **service**(推荐开发环境)。轻量按需启动使用 **shim**。 + +如果外部 Codex 更新覆盖了已安装的 shim,下一条普通 `ocx` 命令会备份已稳定的新启动器并恢复 +shim。仍在变化的启动器不会被改动,而会在后续命令中重试。修复失败只会警告,不会让请求的命令 +失败;手动备用命令为 `ocx codex-shim install`。若要关闭自动恢复,请将 +`codexShimAutoRestore` 设为 `false`,或为进程设置 +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 如果配置的代理端口已被占用,`ocx start` 会自动选择另一个空闲本地端口并更新 Codex 使用它。 ### 卸载 diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index c254e4a91..851acb497 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -357,8 +357,12 @@ ocx service uninstall PATH 上のスクリプトベース `codex` ランチャーを軽量な自動起動スクリプトで包みます。実際の `codex.exe` 対象は正確な実行ファイル呼び出しが壊れないように触りません。 -Codex 更新がラッパーを上書きしても次の `install` 呼び出しで shim が自己修復します。新しい -バイナリをバックアップしたのち新しいラッパーを書きます。 +完了した外部 Codex 更新がインストール済み shim を上書きした場合、次の通常の `ocx` コマンドが +安定した新しいランチャーをバックアップし、コマンド実行前に shim を復元します。まだ変更中の +ランチャーには触れず、後で再試行します。修復失敗は要求されたコマンドを失敗させず警告だけを +表示し、手動の代替手段は `ocx codex-shim install` です。自動修復を無効にするには +`codexShimAutoRestore` を `false` にするか、プロセスで +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index b81218d95..6167e6df6 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -47,6 +47,7 @@ namespaced selected id を bare id に変えます。 | `websockets?` | `boolean` | `false` | `supports_websockets` を知らせ Codex が Responses WebSocket 経路を使うようにします。省略または `false` なら HTTP/SSE を維持します。 | | `apiKeys?` | `OcxApiKey[]` | `[]` | 非 loopback バインドで管理 API とデータプレーン認証に追加で許可する生成型 `ocx_…` 認証情報。ダッシュボードが管理し、項目フィールドは下で説明します。 | | `codexAutoStart?` | `boolean` | `true` | Codex shim が Codex 実行前に `ocx ensure` を実行するようにします。`false` なら `ocx ensure` は何もしません。 | +| `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | | `activeCodexAccountId?` | `string` | — | 次の新しい Codex thread に使う pool アカウント。既存 thread affinity は元のアカウントを維持します。 | diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 53d39196c..6f6d3f31c 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -376,8 +376,11 @@ ocx service uninstall PATH에 있는 스크립트 기반 `codex` 런처를 가벼운 자동 시작 스크립트로 감쌉니다. 실제 `codex.exe` 대상은 정확한 실행 파일 호출이 깨지지 않도록 건드리지 않습니다. -Codex 업데이트가 래퍼를 덮어쓰더라도 다음 `install` 호출에서 shim이 스스로 복구됩니다. 새 -바이너리를 백업한 뒤 새 래퍼를 씁니다. +완료된 외부 Codex 업데이트가 설치된 shim을 덮어쓰면 다음 일반 `ocx` 명령이 안정화된 새 런처를 +백업하고 명령 실행 전에 shim을 복구합니다. 아직 변경 중인 런처는 건드리지 않고 이후에 다시 +시도합니다. 복구 실패는 요청한 명령을 실패시키지 않고 경고만 출력하며, 수동 대체 명령은 +`ocx codex-shim install`입니다. 자동 복구를 끄려면 `codexShimAutoRestore`를 `false`로 설정하거나 +프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정하세요. | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index feca025a8..27abc6b47 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -48,6 +48,7 @@ namespaced selected id를 bare id로 바꿉니다. | `websockets?` | `boolean` | `false` | `supports_websockets`를 알려 Codex가 Responses WebSocket 경로를 쓰게 합니다. 생략하거나 `false`이면 HTTP/SSE를 유지합니다. | | `apiKeys?` | `OcxApiKey[]` | `[]` | 비-loopback 바인드에서 관리 API와 data plane 인증에 추가로 허용할 생성형 `ocx_…` 자격 증명. 대시보드가 관리하며 항목 필드는 아래에 설명합니다. | | `codexAutoStart?` | `boolean` | `true` | Codex shim이 Codex 실행 전에 `ocx ensure`를 실행하게 합니다. `false`이면 `ocx ensure`가 아무 작업도 하지 않습니다. | +| `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | | `activeCodexAccountId?` | `string` | — | 다음 새 Codex thread에 쓸 pool 계정. 기존 thread affinity는 원래 계정을 유지합니다. | diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 14e0c61a2..eb059da36 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -372,8 +372,11 @@ ocx service uninstall Wrap a script-based `codex` launcher on PATH with a lightweight autostart script. Real `codex.exe` targets are left untouched to avoid breaking exact executable invocations. -If Codex is updated and overwrites the wrapper, the shim auto-repairs on the next `install` call — -the new binary is backed up and a fresh wrapper is written. +If a completed external Codex update overwrites an installed shim, the next ordinary `ocx` command +backs up the stable new launcher and restores the shim before dispatch. A launcher that is still +changing is left untouched and retried later. Repair failures warn without failing the requested +command; manual fallback: `ocx codex-shim install`. Set `codexShimAutoRestore` to `false`, or set +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index dedca061e..4c0ab5322 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -49,6 +49,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `websockets?` | `boolean` | `false` | Advertise `supports_websockets` so Codex uses the Responses WebSocket path. Omit or set `false` to keep HTTP/SSE. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Additional generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Managed by the dashboard; entry fields are listed below. | | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. `false` makes `ocx ensure` a no-op. | +| `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | | `activeCodexAccountId?` | `string` | — | Pool account used for the next new Codex thread. Existing thread affinities keep their original account. | diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 9c177d3ab..0066793b3 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -397,8 +397,12 @@ ocx service uninstall Оборачивает скриптовый лаунчер `codex` в PATH лёгким скриптом автозапуска. Настоящие цели `codex.exe` не затрагиваются, чтобы не ломать вызовы точного исполняемого файла. -Если обновление Codex перезаписало обёртку, shim автоматически восстанавливается при следующем -вызове `install` — новый бинарный файл резервируется, и записывается свежая обёртка. +Если завершённое внешнее обновление Codex перезаписало установленный shim, следующая обычная команда +`ocx` до запуска сохранит стабильный новый лончер в резервную копию и восстановит shim. Лончер, +который ещё меняется, остаётся нетронутым до следующей попытки. Ошибка восстановления выдаёт +предупреждение, но не приводит к сбою запрошенной команды; ручной вариант — +`ocx codex-shim install`. Для отключения установите `codexShimAutoRestore` в `false` или задайте +процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index ca3b6eb6a..1a3d8e530 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -52,6 +52,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `websockets?` | `boolean` | `false` | Объявляет `supports_websockets`, чтобы Codex использовал путь Responses WebSocket. Опустите или установите `false`, чтобы остаться на HTTP/SSE. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Дополнительные сгенерированные учётные данные `ocx_…`, принимаемые аутентификацией management API и плоскости данных на не-loopback-привязках. Управляются дашбордом; поля записей перечислены ниже. | | `codexAutoStart?` | `boolean` | `true` | Разрешает shim Codex выполнять `ocx ensure` перед запуском Codex. `false` делает `ocx ensure` пустой операцией. | +| `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | | `activeCodexAccountId?` | `string` | — | Аккаунт пула, используемый для следующего нового потока Codex. Существующие привязки потоков сохраняют исходный аккаунт. | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 48d1be1a1..35d3b286b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -360,8 +360,11 @@ ocx service uninstall 把 PATH 上基于脚本的 `codex` launcher 包装成轻量自动启动脚本。真实 `codex.exe` 目标保持不变, 避免破坏精确的可执行文件调用。 -如果 Codex 更新覆盖了 wrapper,下一次调用 `install` 时 shim 会自动修复:先备份新 binary,再写入 -新的 wrapper。 +如果已完成的外部 Codex 更新覆盖了已安装的 shim,下一条普通 `ocx` 命令会在执行前备份已稳定的 +新启动器并恢复 shim。仍在变化的启动器不会被改动,而会稍后重试。修复失败只会警告,不会让请求的 +命令失败;手动备用命令为 `ocx codex-shim install`。若要关闭自动恢复,请将 +`codexShimAutoRestore` 设为 `false`,或为进程设置 +`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index ee70cfe0c..cfde18861 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -46,6 +46,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `websockets?` | `boolean` | `false` | 公布 `supports_websockets`,让 Codex 使用 Responses WebSocket 路径。省略或设为 `false` 会保持 HTTP/SSE。 | | `apiKeys?` | `OcxApiKey[]` | `[]` | 非 loopback 绑定下,management 和 data-plane 认证额外接受的生成式 `ocx_…` credential。由仪表盘管理;条目字段见下文。 | | `codexAutoStart?` | `boolean` | `true` | 允许 Codex shim 在启动 Codex 前运行 `ocx ensure`。`false` 会让 `ocx ensure` 不执行任何操作。 | +| `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | | `activeCodexAccountId?` | `string` | — | 下一个新 Codex thread 使用的 pool account。已有 thread affinity 继续保留原账号。 | diff --git a/structure/01_runtime.md b/structure/01_runtime.md index e1423b5fd..4e4fcdd69 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -6,14 +6,14 @@ | --- | --- | | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled Bun binary (`bun` dependency), lazy-runs its `install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, `durableBunPath()` (path baked into service/shim artifacts). | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI: init, start, stop, restore/eject, sync, status, login/logout, gui, service, update. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI: init, start, stop, restore/eject, sync, status, login/logout, gui, service, update. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing, exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: forward-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/config.ts` | `~/.opencodex/config.json`, defaults, PID path, env-value resolution, `websocketsEnabled()`. | | `src/router.ts` | Provider/model selection before adapter dispatch. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | -| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and detects stale/overwritten wrappers for repair. | +| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks): always-on proxy with crash restart. | The `src/` root stays thin: process entry, shared config/types, router, bridge, service manager, and @@ -38,6 +38,13 @@ config/catalog, then serves until shutdown. Normal shutdown restores native Code `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. +An installed Codex shim is checked on ordinary CLI startup with bounded state, metadata, and prefix +reads. A complete replacement must remain stable for at least 100 ms; changing or mixed sibling +launchers are silently deferred. Guarded repair preflights every tracked sibling before mutation, +revalidates each source before rename, and rolls back earlier siblings in reverse order on a later +race. Failures warn without changing the requested command's exit behavior. The probe uses +read-only config diagnostics only for a confirmed candidate and never reads adjacent auth state. + The bridge enforces a heartbeat stall deadline: after 5 minutes (150 ticks at the default 2 s interval) of upstream silence with no real events, the stream is closed and the upstream request cancelled. If the adapter generator ends without an explicit done/error event, the response is marked From ae1837496653a9efd65313a55d2d5ef3ce09e0d2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 03:21:48 +0900 Subject: [PATCH 3/5] fix: commit shim state inside guarded repair --- src/codex/shim.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 9951ed487..5d45358eb 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -681,6 +681,7 @@ function rollbackGuardedRefresh(journal: readonly GuardedRefreshJournalEntry[]): function applyGuardedRefreshTransaction( operations: readonly GuardedRefreshOperation[], beforeGuardedRefresh?: (wrapperPath: string, index: number) => void, + commitState?: () => void, ): boolean { const journal: GuardedRefreshJournalEntry[] = []; let applyError: Error | null = null; @@ -716,6 +717,14 @@ function applyGuardedRefreshTransaction( } } + if (!fingerprintMismatch && !applyError && commitState) { + try { + commitState(); + } catch (error) { + applyError = error instanceof Error ? error : new Error(String(error)); + } + } + if (fingerprintMismatch || applyError) { const rollbackErrors = rollbackGuardedRefresh(journal); if (applyError || rollbackErrors.length > 0) { @@ -748,7 +757,23 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i if (!operations || operations.length === 0) { return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." }; } - if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh)) { + const originalStateBytes = readFileSync(statePath()); + const commitState = (): void => { + try { + writeState(primaryState(files)); + } catch (writeError) { + try { + writeFileSync(statePath(), originalStateBytes); + } catch (restoreError) { + throw new AggregateError( + [writeError, restoreError], + "Codex shim state commit and restoration failed", + ); + } + throw writeError; + } + }; + if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh, commitState)) { return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." }; } return { From ce65a75aa617fb9d667681f5f75318465ac843d5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 12:25:09 +0900 Subject: [PATCH 4/5] fix: harden Codex shim auto-restore --- src/cli/codex-shim-autorestore.ts | 2 + src/cli/index.ts | 5 +- src/codex/shim.ts | 207 +++++++++++++++++++++++---- structure/01_runtime.md | 14 +- tests/cli-help.test.ts | 37 ++++- tests/codex-shim-autorestore.test.ts | 35 ++++- tests/codex-shim.test.ts | 136 ++++++++++++++++-- 7 files changed, 384 insertions(+), 52 deletions(-) diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 1636a27ff..e6cf3ffd6 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -32,6 +32,8 @@ export function maybeAutoRestoreCodexShim( }); if (result.status === "restored") { deps.warn(`⚠️ ${result.message} (automatic repair after Codex update)`); + } else if ((result.status === "deferred" || result.status === "ineligible") && result.message) { + deps.warn(`⚠️ ${result.message}`); } } catch (error) { deps.warn( diff --git a/src/cli/index.ts b/src/cli/index.ts index 50a7cf4a0..e1c89e2a2 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -50,8 +50,9 @@ if (command === "--version" || command === "-v" || command === "version") { process.exit(0); } -if (command === "help" && args[1]) { - printSubcommandUsage(args[1]); +if (command === undefined || command === "help" || command === "--help" || command === "-h") { + if (command === "help" && args[1]) printSubcommandUsage(args[1]); + else printUsage(); process.exit(0); } diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 5d45358eb..fee1627e0 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -3,6 +3,7 @@ import { chmodSync, closeSync, existsSync, + fstatSync, lstatSync, mkdirSync, openSync, @@ -10,6 +11,7 @@ import { readSync, renameSync, statSync, + type Stats, unlinkSync, writeFileSync, } from "node:fs"; @@ -22,6 +24,8 @@ import { isWslRuntime, wslAutomountRoot } from "./home"; const SHIM_MARKER = "opencodex codex autostart shim"; const CODEX_SHIM_PROBE_BYTES = 16 * 1024; export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100; +export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024; +const CODEX_SHIM_RESTORE_LOCK_STALE_MS = 30_000; let lastShimDiscoveryError: string | null = null; /** Last human-readable reason discovery returned null (exposed for doctor/tests). */ export function lastCodexDiscoveryError(): string | null { @@ -105,7 +109,8 @@ interface InstallCodexShimInternalOptions { } export type CodexShimAutoRestoreResult = - | { status: "not-installed" | "healthy" | "ineligible" | "deferred" | "disabled" } + | { status: "not-installed" | "healthy" | "disabled" } + | { status: "ineligible" | "deferred"; message?: string } | { status: "restored"; message: string }; function cliEntry(): { bun: string; cli: string } { @@ -214,10 +219,8 @@ function stableShimPathProbe(path: string): StableShimPathProbe | null { return contentSize > 0 ? { fingerprint, prefix } : null; } -function fingerprintIsOldEnough(fingerprint: ShimPathFingerprint, nowMs: number): boolean { - const timestamps = [fingerprint.mtimeMs, fingerprint.ctimeMs]; - if (fingerprint.target) timestamps.push(fingerprint.target.mtimeMs, fingerprint.target.ctimeMs); - return nowMs - Math.max(...timestamps) >= CODEX_SHIM_REPLACEMENT_STABLE_MS; +function sameStableShimPathProbe(left: StableShimPathProbe, right: StableShimPathProbe): boolean { + return left.prefix === right.prefix && sameFingerprint(left.fingerprint, right.fingerprint); } function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean { @@ -490,12 +493,62 @@ exit $LASTEXITCODE `; } -function readState(): ShimState | null { +interface ShimStateReadResult { + state: ShimState | null; + warning?: string; +} + +function fileErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +function readBoundedRegularFile(path: string, maxBytes: number): { content: string } | { warning: string } | null { + let fd: number; + try { + fd = openSync(path, "r"); + } catch (error) { + if (fileErrorCode(error) === "ENOENT") return null; + return { warning: `Codex shim state could not be opened as a regular file at ${path}.` }; + } + try { + const before = fstatSync(fd); + if (!before.isFile()) return { warning: `Codex shim state is not a regular file at ${path}; auto-restore skipped.` }; + if (before.size > maxBytes) { + return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; + } + const buffer = Buffer.allocUnsafe(before.size); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, offset); + if (bytesRead === 0) return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; + offset += bytesRead; + } + const extra = Buffer.allocUnsafe(1); + if (readSync(fd, extra, 0, 1, offset) !== 0) { + return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; + } + const after = fstatSync(fd); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) { + return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; + } + return { content: buffer.toString("utf8") }; + } finally { + closeSync(fd); + } +} + +function readStateResult(): ShimStateReadResult { + const bounded = readBoundedRegularFile(statePath(), CODEX_SHIM_STATE_MAX_BYTES); + if (!bounded) return { state: null }; + if ("warning" in bounded) return { state: null, warning: bounded.warning }; try { - const value = JSON.parse(readFileSync(statePath(), "utf8")) as unknown; - if (!value || typeof value !== "object") return null; + const value = JSON.parse(bounded.content) as unknown; + if (!value || typeof value !== "object") return { state: null }; const state = value as Record; - if (typeof state.platform !== "string") return null; + if (typeof state.platform !== "string") return { state: null }; const validFile = (item: unknown): item is ShimFileState => { if (!item || typeof item !== "object") return false; const file = item as Record; @@ -506,16 +559,20 @@ function readState(): ShimState | null { && (file.preserveOnly === undefined || typeof file.preserveOnly === "boolean"); }; if (state.wrappers !== undefined) { - if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return null; + if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return { state: null }; } else if (!validFile(state)) { - return null; + return { state: null }; } - return state as unknown as ShimState; + return { state: state as unknown as ShimState }; } catch { - return null; + return { state: null }; } } +function readState(): ShimState | null { + return readStateResult().state; +} + function statePath(): string { return join(getConfigDir(), "codex-shim.json"); } @@ -619,6 +676,70 @@ interface GuardedRefreshJournalEntry { let guardedRefreshTransactionId = 0; +interface ShimRestoreLock { + release(): void; +} + +function restoreLockPath(): string { + return join(getConfigDir(), "codex-shim.autorestore.lock"); +} + +function sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function reclaimStaleRestoreLock(path: string): boolean { + const before = statFingerprint(path, false); + if (!before || before.kind !== "file" || Date.now() - before.mtimeMs <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false; + const after = statFingerprint(path, false); + if (!after || !sameFingerprint(before, after)) return false; + try { + unlinkSync(path); + return true; + } catch (error) { + if (fileErrorCode(error) === "ENOENT") return true; + return false; + } +} + +function tryAcquireShimRestoreLock(): ShimRestoreLock | null { + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const path = restoreLockPath(); + for (let attempt = 0; attempt < 2; attempt += 1) { + let fd: number | null = null; + let identity: Stats | null = null; + try { + fd = openSync(path, "wx", 0o600); + identity = fstatSync(fd); + writeFileSync(fd, `${JSON.stringify({ acquiredAt: Date.now(), pid: process.pid })}\n`, "utf8"); + identity = fstatSync(fd); + let released = false; + return { + release(): void { + if (released) return; + released = true; + try { closeSync(fd!); } catch { /* stale recovery handles an uncertain lock */ } + try { + if (identity && sameFileIdentity(identity, lstatSync(path))) unlinkSync(path); + } catch { /* stale recovery handles release failures */ } + }, + }; + } catch (error) { + if (fd !== null) { + try { closeSync(fd); } catch { /* best-effort close before ownership cleanup */ } + try { + if (identity && sameFileIdentity(identity, lstatSync(path))) unlinkSync(path); + } catch { /* leave an uncertain lock for stale recovery */ } + } + if (fileErrorCode(error) !== "EEXIST") throw error; + if (attempt === 0 && reclaimStaleRestoreLock(path)) continue; + return null; + } + } + return null; +} + function planGuardedRefreshTransaction( files: readonly ShimFileState[], expectedReplacements: ReadonlyMap, @@ -835,18 +956,24 @@ export function installCodexShim(): { installed: boolean; message: string } { export function autoRestoreCodexShim(options: { enabled: () => boolean; - nowMs?: number; + stabilitySleep?: (ms: number) => void; + /** Narrow deterministic seam used to hold the interprocess lock in tests. */ + afterRestoreLockAcquired?: () => void; /** Narrow deterministic race seam for the guarded transaction tests. */ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; }): CodexShimAutoRestoreResult { - const state = readState(); - if (!state) return { status: existsSync(statePath()) ? "ineligible" : "not-installed" }; + const stateRead = readStateResult(); + const state = stateRead.state; + if (!state) { + if (stateRead.warning) return { status: "ineligible", message: stateRead.warning }; + return { status: existsSync(statePath()) ? "ineligible" : "not-installed" }; + } if (state.platform !== process.platform) return { status: "ineligible" }; const files = stateFiles(state); - const expectedReplacements = new Map(); + const replacementProbes = new Map(); const seen = new Set(); - const nowMs = options.nowMs ?? Date.now(); + let healthyCount = 0; for (const file of files) { if (seen.has(file.wrapperPath)) return { status: "ineligible" }; seen.add(file.wrapperPath); @@ -859,22 +986,44 @@ export function autoRestoreCodexShim(options: { if (!probe) return { status: "deferred" }; if (probe.prefix.includes(SHIM_MARKER)) { if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" }; + healthyCount += 1; continue; } - if (!fingerprintIsOldEnough(probe.fingerprint, nowMs)) return { status: "deferred" }; - expectedReplacements.set(file.wrapperPath, probe.fingerprint); + replacementProbes.set(file.wrapperPath, probe); } - if (expectedReplacements.size === 0) return { status: "healthy" }; + if (replacementProbes.size === 0) return { status: "healthy" }; if (!options.enabled()) return { status: "disabled" }; - const result = installCodexShimInternal({ - allowFreshInstall: false, - expectedReplacements, - beforeGuardedRefresh: options.beforeGuardedRefresh, - }); - return result.installed - ? { status: "restored", message: result.message } - : { status: "deferred" }; + if (files.length > 1 && healthyCount > 0) { + return { + status: "deferred", + message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.", + }; + } + + const lock = tryAcquireShimRestoreLock(); + if (!lock) return { status: "deferred" }; + try { + options.afterRestoreLockAcquired?.(); + (options.stabilitySleep ?? Bun.sleepSync)(CODEX_SHIM_REPLACEMENT_STABLE_MS); + const expectedReplacements = new Map(); + for (const [path, firstProbe] of replacementProbes) { + const secondProbe = stableShimPathProbe(path); + if (!secondProbe || secondProbe.prefix.includes(SHIM_MARKER) + || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" }; + expectedReplacements.set(path, secondProbe.fingerprint); + } + const result = installCodexShimInternal({ + allowFreshInstall: false, + expectedReplacements, + beforeGuardedRefresh: options.beforeGuardedRefresh, + }); + return result.installed + ? { status: "restored", message: result.message } + : { status: "deferred" }; + } finally { + lock.release(); + } } export function uninstallCodexShim(): { removed: boolean; message: string } { diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 4e4fcdd69..9e25291ed 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -38,12 +38,14 @@ config/catalog, then serves until shutdown. Normal shutdown restores native Code `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. -An installed Codex shim is checked on ordinary CLI startup with bounded state, metadata, and prefix -reads. A complete replacement must remain stable for at least 100 ms; changing or mixed sibling -launchers are silently deferred. Guarded repair preflights every tracked sibling before mutation, -revalidates each source before rename, and rolls back earlier siblings in reverse order on a later -race. Failures warn without changing the requested command's exit behavior. The probe uses -read-only config diagnostics only for a confirmed candidate and never reads adjacent auth state. +An installed Codex shim is checked on ordinary CLI startup with a regular-file/1 MiB state bound plus +bounded metadata and prefix reads. A complete replacement must produce identical fingerprints and +prefixes across a 100 ms observation interval; changing launchers are silently deferred, while mixed +sibling sets warn and defer as a unit. Guarded repair holds an O_EXCL interprocess lock across its +final revalidation, rename, shim write, and state commit; it preflights every tracked sibling before +mutation and rolls back earlier siblings in reverse order on a later race. Failures warn without +changing the requested command's exit behavior. The probe uses read-only config diagnostics only for +a confirmed candidate and never reads adjacent auth state. The bridge enforces a heartbeat stall deadline: after 5 minutes (150 ticks at the default 2 s interval) of upstream silence with no real events, the stream is closed and the upstream request diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 4a4119a89..31acc9bfe 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,6 +45,41 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("Start the proxy server and sync models to Codex."); }); + test("top-level help forms exit before Codex shim auto-restore can mutate launchers", () => { + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-help-shim-home-")); + const binDir = mkdtempSync(join(tmpdir(), "ocx-help-shim-bin-")); + try { + const wrapper = join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + const backup = join(binDir, process.platform === "win32" ? "codex.opencodex-real.cmd" : "codex.opencodex-real"); + const statePath = join(opencodexHome, "codex-shim.json"); + const replacement = "replacement that help must not promote\n"; + writeFileSync(wrapper, replacement, "utf8"); + writeFileSync(backup, "known-good prior launcher\n", "utf8"); + if (process.platform !== "win32") chmodSync(wrapper, 0o755); + writeFileSync(statePath, `${JSON.stringify({ + platform: process.platform, + wrapperPath: wrapper, + originalPath: wrapper, + backupPath: backup, + }, null, 2)}\n`, "utf8"); + const stateBefore = readFileSync(statePath); + const backupBefore = readFileSync(backup); + Bun.sleepSync(120); + + for (const args of [[], ["help"], ["--help"], ["-h"]]) { + const result = runCli(args, { OPENCODEX_HOME: opencodexHome, PATH: binDir }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("opencodex (ocx)"); + expect(readFileSync(wrapper, "utf8")).toBe(replacement); + expect(readFileSync(backup)).toEqual(backupBefore); + expect(readFileSync(statePath)).toEqual(stateBefore); + } + } finally { + rmSync(opencodexHome, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); + } + }); + test("tray help documents the install-only no-start flag", () => { const result = runCli(["help", "tray"]); expect(result.status).toBe(0); diff --git a/tests/codex-shim-autorestore.test.ts b/tests/codex-shim-autorestore.test.ts index 1c2b589e8..3b9b0b0ac 100644 --- a/tests/codex-shim-autorestore.test.ts +++ b/tests/codex-shim-autorestore.test.ts @@ -10,7 +10,7 @@ import { skipsCodexShimAutoRestore, type CodexShimAutoRestoreCliDeps, } from "../src/cli/codex-shim-autorestore"; -import { installCodexShim } from "../src/codex/shim"; +import { autoRestoreCodexShim, CODEX_SHIM_STATE_MAX_BYTES, installCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; @@ -61,6 +61,15 @@ describe("Codex shim CLI auto-restore policy", () => { expect(warnings).toEqual([expect.stringContaining("automatic repair after Codex update")]); }); + test("actionable mixed-sibling deferrals are logged while ordinary deferrals stay silent", () => { + const { deps, warnings } = cliDeps({ + status: "deferred", + message: "tracked launcher siblings are in a mixed shim/replacement state", + }); + maybeAutoRestoreCodexShim("status", ["status"], deps); + expect(warnings).toEqual([expect.stringContaining("mixed shim/replacement state")]); + }); + test("healthy, not-installed, disabled, and deferred outcomes stay silent and lazy", () => { for (const status of ["healthy", "not-installed", "disabled", "deferred"] as const) { const { deps, warnings, readConfigCalls } = cliDeps({ status }); @@ -91,6 +100,30 @@ describe("Codex shim CLI auto-restore policy", () => { } }); + test("oversized shim state is bounded, skipped, and warned without loading config", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-shim-oversized-state-")); + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.OPENCODEX_HOME = home; + const statePath = join(home, "codex-shim.json"); + writeFileSync(statePath, Buffer.alloc(CODEX_SHIM_STATE_MAX_BYTES + 1, 0x20)); + const before = readFileSync(statePath); + const { deps, warnings, readConfigCalls } = cliDeps({ status: "healthy" }, { + restore: autoRestoreCodexShim, + }); + + maybeAutoRestoreCodexShim("status", ["status"], deps); + + expect(warnings).toEqual([expect.stringContaining("exceeds the 1 MiB startup limit")]); + expect(readConfigCalls()).toBe(0); + expect(readFileSync(statePath)).toEqual(before); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(home, { recursive: true, force: true }); + } + }); + test("shim replaced -> next ocx command auto-restores and warns", async () => { if (process.platform === "win32") return; const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-activation-bin-")); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 4c7ec2c4b..42e2fdd13 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; +const skipStabilityWait = () => {}; function withInstalledShim(run: (paths: { binDir: string; @@ -389,7 +390,7 @@ describe("Codex autostart shim", () => { const replacements = wrappers.map((wrapper, index) => `replacement-${index}\n`); wrappers.forEach((wrapper, index) => writeFileSync(wrapper, replacements[index], "utf8")); - const result = autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }); + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); expect(result.status).toBe("restored"); wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); @@ -397,16 +398,125 @@ describe("Codex autostart shim", () => { }); }); - test("npm update in progress -> young replacement defers, then restores after stability", () => { + test("concurrent processes cannot move another restore's freshly written shim into backup", async () => { + if (process.platform === "win32") return; + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-concurrent-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-concurrent-home-")); + const readyPath = join(home, "first-lock-ready"); + const releasePath = join(home, "release-first-lock"); + const wrapper = join(binDir, "codex"); + const backup = join(binDir, "codex.opencodex-real"); + const replacement = "concurrent replacement launcher\n"; + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + let first: ReturnType | undefined; + try { + process.env.PATH = binDir; + process.env.OPENCODEX_HOME = home; + writeFileSync(wrapper, "#!/bin/sh\necho original\n", "utf8"); + chmodSync(wrapper, 0o755); + expect(installCodexShim().installed).toBe(true); + writeFileSync(wrapper, replacement, "utf8"); + chmodSync(wrapper, 0o755); + + const shimModule = join(import.meta.dir, "..", "src", "codex", "shim.ts"); + const firstScript = ` + import { existsSync, writeFileSync } from "node:fs"; + const { autoRestoreCodexShim } = await import(${JSON.stringify(shimModule)}); + const result = autoRestoreCodexShim({ + enabled: () => true, + stabilitySleep: () => {}, + afterRestoreLockAcquired: () => { + writeFileSync(${JSON.stringify(readyPath)}, "ready"); + while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(5); + }, + }); + console.log(JSON.stringify(result)); + `; + const secondScript = ` + const { autoRestoreCodexShim } = await import(${JSON.stringify(shimModule)}); + console.log(JSON.stringify(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }))); + `; + const childEnv = { ...process.env, PATH: binDir, OPENCODEX_HOME: home }; + first = Bun.spawn([process.execPath, "-e", firstScript], { + cwd: join(import.meta.dir, ".."), + env: childEnv, + stdout: "pipe", + stderr: "pipe", + }); + const deadline = Date.now() + 5_000; + while (!existsSync(readyPath) && Date.now() < deadline) await Bun.sleep(5); + expect(existsSync(readyPath)).toBe(true); + + const second = spawnSync(process.execPath, ["-e", secondScript], { + cwd: join(import.meta.dir, ".."), + env: childEnv, + encoding: "utf8", + }); + expect(second.status).toBe(0); + expect(JSON.parse(second.stdout.trim())).toEqual({ status: "deferred" }); + + writeFileSync(releasePath, "release", "utf8"); + expect(await first.exited).toBe(0); + const firstStdout = await new Response(first.stdout).text(); + expect(JSON.parse(firstStdout.trim()).status).toBe("restored"); + expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(backup, "utf8")).toBe(replacement); + } finally { + try { writeFileSync(releasePath, "release", "utf8"); } catch { /* temp dir may already be gone */ } + if (first) await first.exited; + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test("stalled partial write changing during the observation interval is never promoted", () => { withInstalledShim(({ wrappers, backups }) => { const oldBackups = backups.map(path => readFileSync(path)); - wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `young-${index}\n`, "utf8")); + wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `partial-${index}\n`, "utf8")); - expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() })).toEqual({ status: "deferred" }); - wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(`young-${index}\n`)); + const result = autoRestoreCodexShim({ + enabled: () => true, + stabilitySleep: () => writeFileSync(wrappers[0], "completed after stalled partial write\n", "utf8"), + }); + + expect(result).toEqual({ status: "deferred" }); + expect(readFileSync(wrappers[0], "utf8")).toBe("completed after stalled partial write\n"); backups.forEach((backup, index) => expect(readFileSync(backup)).toEqual(oldBackups[index])); + }); + }); + + test("mixed launcher siblings defer the whole restore without piecemeal mutation", () => { + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + if (wrappers.length === 1) { + const sibling = join(binDir, "codex.ps1"); + const siblingBackup = join(binDir, "codex.opencodex-real.ps1"); + writeFileSync(sibling, readFileSync(wrappers[0])); + chmodSync(sibling, 0o755); + writeFileSync(siblingBackup, "prior sibling launcher\n", "utf8"); + const state = JSON.parse(readFileSync(statePath, "utf8")) as { + wrappers: Array<{ wrapperPath: string; originalPath: string; backupPath: string }>; + }; + state.wrappers.push({ wrapperPath: sibling, originalPath: sibling, backupPath: siblingBackup }); + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + wrappers.push(sibling); + backups.push(siblingBackup); + } + const oldBackups = backups.map(path => readFileSync(path)); + const healthySiblings = wrappers.slice(1).map(path => readFileSync(path)); + writeFileSync(wrappers[0], "one updated sibling\n", "utf8"); + + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("restored"); + expect(result.status).toBe("deferred"); + expect(result.message).toContain("mixed shim/replacement state"); + expect(readFileSync(wrappers[0], "utf8")).toBe("one updated sibling\n"); + wrappers.slice(1).forEach((path, index) => expect(readFileSync(path)).toEqual(healthySiblings[index])); + backups.forEach((path, index) => expect(readFileSync(path)).toEqual(oldBackups[index])); }); }); @@ -414,7 +524,7 @@ describe("Codex autostart shim", () => { withInstalledShim(({ wrappers }) => { wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `disabled-${index}\n`, "utf8")); - expect(autoRestoreCodexShim({ enabled: () => false, nowMs: Date.now() + 1_000 })).toEqual({ status: "disabled" }); + expect(autoRestoreCodexShim({ enabled: () => false, stabilitySleep: skipStabilityWait })).toEqual({ status: "disabled" }); wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(`disabled-${index}\n`)); expect(installCodexShim().installed).toBe(true); wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); @@ -429,7 +539,7 @@ describe("Codex autostart shim", () => { const result = autoRestoreCodexShim({ enabled: () => true, - nowMs: Date.now() + 1_000, + stabilitySleep: skipStabilityWait, beforeGuardedRefresh: (wrapperPath, index) => { if (index === 0) writeFileSync(wrapperPath, "concurrent replacement\n", "utf8"); }, @@ -471,7 +581,7 @@ describe("Codex autostart shim", () => { const result = autoRestoreCodexShim({ enabled: () => true, - nowMs: Date.now() + 1_000, + stabilitySleep: skipStabilityWait, beforeGuardedRefresh: (wrapperPath, index) => { if (index === 1) { const originalMtime = statSync(wrapperPath).mtime; @@ -497,18 +607,18 @@ describe("Codex autostart shim", () => { test("missing backup, missing wrapper, corrupt state, and platform mismatch never fresh-install", () => { withInstalledShim(({ wrappers, backups, statePath }) => { rmSync(backups[0]); - expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("ineligible"); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); writeFileSync(backups[0], "backup\n", "utf8"); rmSync(wrappers[0]); - expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("ineligible"); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); if (process.platform !== "win32") { mkdirSync(wrappers[0]); - expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("deferred"); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("deferred"); rmSync(wrappers[0], { recursive: true }); symlinkSync(join(dirname(wrappers[0]), "missing-target"), wrappers[0]); - expect(autoRestoreCodexShim({ enabled: () => true, nowMs: Date.now() + 1_000 }).status).toBe("ineligible"); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); } writeFileSync(statePath, "{broken", "utf8"); From 916351f591e61e78573f1ff77144125a86808df5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 24 Jul 2026 12:46:04 +0900 Subject: [PATCH 5/5] fix: harden shim restore lock ownership --- src/codex/shim.ts | 120 +++++++++++++++++++++++++++++++++------ structure/01_runtime.md | 13 +++-- tests/codex-shim.test.ts | 82 +++++++++++++++++++++++--- 3 files changed, 185 insertions(+), 30 deletions(-) diff --git a/src/codex/shim.ts b/src/codex/shim.ts index fee1627e0..c752e9a5f 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { delimiter, dirname, extname, join, posix } from "node:path"; import { chmodSync, @@ -8,8 +9,10 @@ import { mkdirSync, openSync, readFileSync, + readdirSync, readSync, renameSync, + rmdirSync, statSync, type Stats, unlinkSync, @@ -17,6 +20,7 @@ import { } from "node:fs"; import { getConfigDir } from "../config"; import { durableBunPath } from "../lib/bun-runtime"; +import { isProcessAlive } from "../lib/process-control"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { windowsEnvIndirectBatchValue } from "../lib/win-paths"; import { isWslRuntime, wslAutomountRoot } from "./home"; @@ -680,39 +684,109 @@ interface ShimRestoreLock { release(): void; } +interface ShimRestoreLockRecord { + version: 1; + token: string; + pid: number; + createdAt: number; +} + +interface ShimRestoreLockSnapshot { + record: ShimRestoreLockRecord; + ownerPath: string; + lockIdentity: Pick; + fingerprint: ShimPathFingerprint; +} + function restoreLockPath(): string { return join(getConfigDir(), "codex-shim.autorestore.lock"); } -function sameFileIdentity(left: Stats, right: Stats): boolean { +function sameFileIdentity(left: Pick, right: Pick): boolean { return left.dev === right.dev && left.ino === right.ino; } -function reclaimStaleRestoreLock(path: string): boolean { - const before = statFingerprint(path, false); - if (!before || before.kind !== "file" || Date.now() - before.mtimeMs <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false; - const after = statFingerprint(path, false); - if (!after || !sameFingerprint(before, after)) return false; +function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | null { + let lockIdentity: Stats; + let entries: string[]; try { - unlinkSync(path); + lockIdentity = lstatSync(path); + if (!lockIdentity.isDirectory()) return null; + entries = readdirSync(path); + } catch { + return null; + } + if (entries.length !== 1 || !entries[0].endsWith(".json")) return null; + const ownerPath = join(path, entries[0]); + const probe = stableShimPathProbe(ownerPath); + if (!probe || probe.fingerprint.kind !== "file" || probe.fingerprint.size > 4096) return null; + try { + const value = JSON.parse(probe.prefix) as Partial; + if (value.version !== 1 || typeof value.token !== "string" || value.token.length === 0 + || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 + || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; + if (entries[0] !== `${value.token}.json`) return null; + const currentLockIdentity = lstatSync(path); + if (!currentLockIdentity.isDirectory() || !sameFileIdentity(lockIdentity, currentLockIdentity)) return null; + return { + record: value as ShimRestoreLockRecord, + ownerPath, + lockIdentity, + fingerprint: probe.fingerprint, + }; + } catch { + return null; + } +} + +function sameShimRestoreLock(left: ShimRestoreLockSnapshot, right: ShimRestoreLockSnapshot): boolean { + return left.record.token === right.record.token + && sameFileIdentity(left.lockIdentity, right.lockIdentity) + && sameFingerprint(left.fingerprint, right.fingerprint); +} + +function reclaimStaleRestoreLock(path: string, beforeDelete?: () => void): boolean { + const observed = readShimRestoreLockSnapshot(path); + if (!observed) return false; + const createdAt = Math.max(observed.record.createdAt, observed.fingerprint.mtimeMs); + if (Date.now() - createdAt <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false; + if (isProcessAlive(observed.record.pid)) return false; + const current = readShimRestoreLockSnapshot(path); + if (!current || !sameShimRestoreLock(observed, current)) return false; + beforeDelete?.(); + try { + // The token is part of the owner filename. Even if the lock directory is + // replaced after the comparison, this unlink cannot target a successor's + // differently named owner record. + unlinkSync(observed.ownerPath); + rmdirSync(path); return true; - } catch (error) { - if (fileErrorCode(error) === "ENOENT") return true; + } catch { return false; } } -function tryAcquireShimRestoreLock(): ShimRestoreLock | null { +function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreLock | null { const dir = getConfigDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); const path = restoreLockPath(); for (let attempt = 0; attempt < 2; attempt += 1) { let fd: number | null = null; let identity: Stats | null = null; + let createdDirectory = false; + const record: ShimRestoreLockRecord = { + version: 1, + token: `${process.pid}-${Date.now()}-${randomUUID()}`, + pid: process.pid, + createdAt: Date.now(), + }; + const ownerPath = join(path, `${record.token}.json`); try { - fd = openSync(path, "wx", 0o600); + mkdirSync(path, { mode: 0o700 }); + createdDirectory = true; + fd = openSync(ownerPath, "wx", 0o600); identity = fstatSync(fd); - writeFileSync(fd, `${JSON.stringify({ acquiredAt: Date.now(), pid: process.pid })}\n`, "utf8"); + writeFileSync(fd, `${JSON.stringify(record)}\n`, "utf8"); identity = fstatSync(fd); let released = false; return { @@ -721,7 +795,12 @@ function tryAcquireShimRestoreLock(): ShimRestoreLock | null { released = true; try { closeSync(fd!); } catch { /* stale recovery handles an uncertain lock */ } try { - if (identity && sameFileIdentity(identity, lstatSync(path))) unlinkSync(path); + const current = readShimRestoreLockSnapshot(path); + if (identity && current && current.record.token === record.token + && sameFileIdentity(identity, current.fingerprint)) { + unlinkSync(ownerPath); + rmdirSync(path); + } } catch { /* stale recovery handles release failures */ } }, }; @@ -729,11 +808,18 @@ function tryAcquireShimRestoreLock(): ShimRestoreLock | null { if (fd !== null) { try { closeSync(fd); } catch { /* best-effort close before ownership cleanup */ } try { - if (identity && sameFileIdentity(identity, lstatSync(path))) unlinkSync(path); + const current = readShimRestoreLockSnapshot(path); + if (identity && current && current.record.token === record.token + && sameFileIdentity(identity, current.fingerprint)) { + unlinkSync(ownerPath); + rmdirSync(path); + } } catch { /* leave an uncertain lock for stale recovery */ } + } else if (createdDirectory) { + try { rmdirSync(path); } catch { /* another owner exists or cleanup is uncertain */ } } if (fileErrorCode(error) !== "EEXIST") throw error; - if (attempt === 0 && reclaimStaleRestoreLock(path)) continue; + if (attempt === 0 && reclaimStaleRestoreLock(path, beforeStaleDelete)) continue; return null; } } @@ -959,6 +1045,8 @@ export function autoRestoreCodexShim(options: { stabilitySleep?: (ms: number) => void; /** Narrow deterministic seam used to hold the interprocess lock in tests. */ afterRestoreLockAcquired?: () => void; + /** Narrow deterministic seam for stale-lock compare-and-delete tests. */ + beforeStaleRestoreLockDelete?: () => void; /** Narrow deterministic race seam for the guarded transaction tests. */ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; }): CodexShimAutoRestoreResult { @@ -1001,7 +1089,7 @@ export function autoRestoreCodexShim(options: { }; } - const lock = tryAcquireShimRestoreLock(); + const lock = tryAcquireShimRestoreLock(options.beforeStaleRestoreLockDelete); if (!lock) return { status: "deferred" }; try { options.afterRestoreLockAcquired?.(); diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 9e25291ed..c34e5584c 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -41,11 +41,14 @@ uninstall still restore. An installed Codex shim is checked on ordinary CLI startup with a regular-file/1 MiB state bound plus bounded metadata and prefix reads. A complete replacement must produce identical fingerprints and prefixes across a 100 ms observation interval; changing launchers are silently deferred, while mixed -sibling sets warn and defer as a unit. Guarded repair holds an O_EXCL interprocess lock across its -final revalidation, rename, shim write, and state commit; it preflights every tracked sibling before -mutation and rolls back earlier siblings in reverse order on a later race. Failures warn without -changing the requested command's exit behavior. The probe uses read-only config diagnostics only for -a confirmed candidate and never reads adjacent auth state. +sibling sets warn and defer as a unit. Guarded repair holds a self-identifying atomic-mkdir +interprocess lock across its final revalidation, rename, shim write, and state commit. Its owner record +uses the unique token as the filename, so stale-owner deletion cannot name a successor's record. An +aged lock is reclaimed only when its owner PID is no longer alive and the same token, lock-directory +identity, and owner fingerprint are still present immediately before deletion. Repair preflights every +tracked sibling before mutation and rolls back earlier siblings in reverse order on a later race. +Failures warn without changing the requested command's exit behavior. The probe uses read-only config +diagnostics only for a confirmed candidate and never reads adjacent auth state. The bridge enforces a heartbeat stall deadline: after 5 minutes (150 ticks at the default 2 s interval) of upstream silence with no real events, the stream is closed and the upstream request diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 42e2fdd13..a1bdff3f4 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -398,14 +398,14 @@ describe("Codex autostart shim", () => { }); }); - test("concurrent processes cannot move another restore's freshly written shim into backup", async () => { - if (process.platform === "win32") return; + test("an aged lock held by a live restore owner is never reclaimed", async () => { const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-concurrent-bin-")); const home = mkdtempSync(join(tmpdir(), "ocx-shim-concurrent-home-")); const readyPath = join(home, "first-lock-ready"); const releasePath = join(home, "release-first-lock"); - const wrapper = join(binDir, "codex"); - const backup = join(binDir, "codex.opencodex-real"); + const restoreLockPath = join(home, "codex-shim.autorestore.lock"); + const wrapper = join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + const backup = join(binDir, process.platform === "win32" ? "codex.opencodex-real.cmd" : "codex.opencodex-real"); const replacement = "concurrent replacement launcher\n"; const oldPath = process.env.PATH; const oldHome = process.env.OPENCODEX_HOME; @@ -413,21 +413,27 @@ describe("Codex autostart shim", () => { try { process.env.PATH = binDir; process.env.OPENCODEX_HOME = home; - writeFileSync(wrapper, "#!/bin/sh\necho original\n", "utf8"); - chmodSync(wrapper, 0o755); + writeFileSync(wrapper, process.platform === "win32" ? "@echo off\r\necho original\r\n" : "#!/bin/sh\necho original\n", "utf8"); + if (process.platform !== "win32") chmodSync(wrapper, 0o755); expect(installCodexShim().installed).toBe(true); writeFileSync(wrapper, replacement, "utf8"); - chmodSync(wrapper, 0o755); + if (process.platform !== "win32") chmodSync(wrapper, 0o755); const shimModule = join(import.meta.dir, "..", "src", "codex", "shim.ts"); const firstScript = ` - import { existsSync, writeFileSync } from "node:fs"; + import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; const { autoRestoreCodexShim } = await import(${JSON.stringify(shimModule)}); const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {}, afterRestoreLockAcquired: () => { - writeFileSync(${JSON.stringify(readyPath)}, "ready"); + const ownerPath = join(${JSON.stringify(restoreLockPath)}, readdirSync(${JSON.stringify(restoreLockPath)})[0]); + const held = JSON.parse(readFileSync(ownerPath, "utf8")); + held.createdAt = 0; + writeFileSync(ownerPath, JSON.stringify(held) + "\\n"); + utimesSync(ownerPath, new Date(0), new Date(0)); + writeFileSync(${JSON.stringify(readyPath)}, readFileSync(ownerPath)); while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(5); }, }); @@ -455,6 +461,9 @@ describe("Codex autostart shim", () => { }); expect(second.status).toBe(0); expect(JSON.parse(second.stdout.trim())).toEqual({ status: "deferred" }); + const heldLock = JSON.parse(readFileSync(readyPath, "utf8")) as { pid?: number; token?: string }; + expect(heldLock.pid).toBe(first.pid); + expect(heldLock.token).toBeString(); writeFileSync(releasePath, "release", "utf8"); expect(await first.exited).toBe(0); @@ -474,6 +483,61 @@ describe("Codex autostart shim", () => { } }); + test("stale-lock compare-and-delete never unlinks a successor lock", () => { + withInstalledShim(({ home, wrappers, backups }) => { + const lockPath = join(home, "codex-shim.autorestore.lock"); + const stalePath = join(lockPath, "stale-owner.json"); + const successorPath = join(lockPath, "successor-owner.json"); + const stale = JSON.stringify({ version: 1, token: "stale-owner", pid: 2_147_483_647, createdAt: 0 }) + "\n"; + const successor = JSON.stringify({ version: 1, token: "successor-owner", pid: process.pid, createdAt: Date.now() }) + "\n"; + const oldBackups = backups.map(path => readFileSync(path)); + wrappers.forEach((path, index) => writeFileSync(path, `replacement-${index}\n`, "utf8")); + mkdirSync(lockPath); + writeFileSync(stalePath, stale, "utf8"); + utimesSync(stalePath, new Date(0), new Date(0)); + + const result = autoRestoreCodexShim({ + enabled: () => true, + stabilitySleep: skipStabilityWait, + beforeStaleRestoreLockDelete: () => { + rmSync(lockPath, { recursive: true }); + mkdirSync(lockPath); + writeFileSync(successorPath, successor, "utf8"); + }, + }); + + expect(result).toEqual({ status: "deferred" }); + expect(readdirSync(lockPath)).toEqual(["successor-owner.json"]); + expect(readFileSync(successorPath, "utf8")).toBe(successor); + wrappers.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(`replacement-${index}\n`)); + backups.forEach((path, index) => expect(readFileSync(path)).toEqual(oldBackups[index])); + }); + }); + + test("an unchanged stale lock from a dead owner is reclaimed", () => { + withInstalledShim(({ home, wrappers, backups }) => { + const lockPath = join(home, "codex-shim.autorestore.lock"); + const ownerPath = join(lockPath, "dead-owner.json"); + const replacements = wrappers.map((_, index) => `dead-owner-replacement-${index}\n`); + wrappers.forEach((path, index) => writeFileSync(path, replacements[index], "utf8")); + mkdirSync(lockPath); + writeFileSync(ownerPath, `${JSON.stringify({ + version: 1, + token: "dead-owner", + pid: 2_147_483_647, + createdAt: 0, + })}\n`, "utf8"); + utimesSync(ownerPath, new Date(0), new Date(0)); + + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + + expect(result.status).toBe("restored"); + expect(existsSync(lockPath)).toBe(false); + wrappers.forEach(path => expect(readFileSync(path, "utf8")).toContain(SHIM_MARKER)); + backups.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(replacements[index])); + }); + }); + test("stalled partial write changing during the observation interval is never promoted", () => { withInstalledShim(({ wrappers, backups }) => { const oldBackups = backups.map(path => readFileSync(path));