diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 447720170..1f8c593e9 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -181,6 +181,10 @@ Windows では、タスク スケジューラ エントリを作成するには 軽量の自動起動スクリプトを使用して、スクリプトベースの `codex` ランチャーを PATH 上にラップします。実際の `codex.exe` ターゲットは、正確な実行可能呼び出しの破損を避けるため、変更されないまま残されます。 +インストールまたは修復を確定する前に、OpenCodex はサービス起動をバイパスした状態で、保存済みランチャーを `--version` 付きで実行します。ランチャーが `codex` を再び shim に解決する、0 以外で終了する、5 秒を超える、子プロセスを残す、または安全に検証・クリーンアップできない場合、変更を拒否してロールバックします。したがって `codex-shim install` は無条件のインストールではありません。拒否された場合は、PATH エントリが具体的な実行ファイルまたはランチャーを指すよう Codex を再インストールしてから再試行してください。動的コマンドマネージャーのランチャーがこれらの検証を満たせない場合は、代わりに `ocx service install` を使用してください。 + +アップグレード時には、現在の検証ガードを持たない既存の Unix shim を再生成して検証します。保存済みランチャーが安全でない場合、OpenCodex は危険な wrapper を残さず、古い shim を削除して元のランチャーを復元します。 + 完了した外部 Codex アップデートがインストールされている shim を上書きした場合、次の通常の `ocx` コマンドは安定した新しいランチャーをバックアップし、ディスパッチ前に shim を復元します。まだ変更中のランチャーは変更されず、後で再試行されます。修復の失敗は、要求されたコマンドを失敗させることなく警告します。手動フォールバック: `ocx codex-shim install`。 `codexShimAutoRestore` を `false` に設定するか、プロセス レベルのオプトアウトの場合は `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 |サブコマンド |アクション | diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 881dc9bc2..14d8db2cf 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -235,6 +235,16 @@ Windows에서 Task Scheduler 항목을 만들려면 권한 상승이 필요합 PATH 위의 스크립트 기반 `codex` 런처를 가벼운 자동 시작 스크립트로 감쌉니다. 정확한 실행 파일 호출을 깨지 않도록 실제 `codex.exe` 대상은 손대지 않습니다. +설치나 복구를 확정하기 전에 OpenCodex는 서비스 시작을 우회한 상태에서 저장된 런처를 +`--version`으로 실행합니다. 런처가 `codex`를 shim으로 다시 해석해 재귀하거나, 0이 아닌 코드로 +종료하거나, 5초를 초과하거나, 실행 중인 자식 프로세스를 남기거나, 안전하게 검증·정리할 수 없으면 +변경을 거부하고 롤백합니다. 따라서 `codex-shim install`은 무조건 성공하는 명령이 아닙니다. 거부되면 +PATH 항목이 구체적인 실행 파일 또는 런처를 가리키도록 Codex를 다시 설치한 뒤 재시도하세요. 동적 +명령 관리자의 런처가 이 검증을 충족할 수 없다면 대신 `ocx service install`을 사용하세요. +업그레이드할 때 현재 검증 가드가 없는 기존 Unix shim은 다시 생성하고 검증합니다. 저장된 런처가 +안전하지 않으면 OpenCodex는 위험한 wrapper를 그대로 두지 않고 구버전 shim을 제거한 뒤 원래 +런처를 복원합니다. + 완료된 외부 Codex 업데이트가 설치된 shim을 덮어쓰면, 다음 일반 `ocx` 명령이 안정적인 새 런처를 백업하고 명령을 처리하기 전에 shim을 복원합니다. 아직 변경 중인 런처는 건드리지 않고 나중에 다시 시도합니다. 복구 실패는 요청한 명령을 실패시키지 않고 경고만 표시합니다. 수동 대체 수단은 `ocx codex-shim install` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index ccf2bc18e..1b0ed9044 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -287,6 +287,16 @@ dashboard UAC prompt or rerun `ocx service install` in an elevated PowerShell wi 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. +Before an install or repair is committed, OpenCodex runs the saved launcher with `--version` while +service startup is bypassed. It refuses the change and rolls back when the launcher resolves +`codex` back to the shim, exits nonzero, exceeds five seconds, leaves descendants running, or +cannot be validated and cleaned up safely. Therefore `codex-shim install` is not unconditional. If +it is refused, reinstall Codex so the PATH entry is a concrete executable or launcher and retry; +use `ocx service install` instead when a dynamic command-manager launcher cannot meet these checks. +During upgrades, an installed Unix shim that lacks the current validation guard is regenerated and +probed. If its saved launcher is unsafe, OpenCodex removes the obsolete shim and restores the +original launcher instead of leaving the unsafe wrapper installed. + Launcher installation alone does not prove that Codex requests will use OpenCodex. After a healthy install, the command checks the current Codex routing and reports a warning instead of a green result when routing is external, user-owned, or unverifiable. It also warns when outbound proxy variables diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 8d82fead7..2696370cc 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -253,6 +253,17 @@ scheduler-error. Чужие задачи и чужие операции нико Обернуть script-based launcher `codex` на `PATH` лёгким автозапусковым скриптом. Настоящие target'ы `codex.exe` не трогаются, чтобы не ломать точные вызовы исполняемого файла. +Перед фиксацией установки или repair OpenCodex запускает сохранённый launcher с `--version`, +не запуская сервис. Изменение отклоняется и откатывается, если launcher снова разрешает `codex` +в shim, завершается с ненулевым кодом, работает дольше пяти секунд, оставляет дочерние процессы +или не может быть безопасно проверен и очищен. Поэтому `codex-shim install` не является +безусловной установкой. После отказа переустановите Codex так, чтобы запись в `PATH` указывала на +конкретный исполняемый файл или launcher, и повторите попытку. Если динамический launcher +менеджера команд не проходит эти проверки, используйте вместо него `ocx service install`. +При обновлении установленный Unix shim без текущей validation-защиты пересоздаётся и проверяется. +Если сохранённый launcher небезопасен, OpenCodex удаляет устаревший shim и восстанавливает исходный +launcher, а не оставляет небезопасный wrapper установленным. + Если завершённое внешнее обновление Codex перезаписало установленный shim, следующая обычная команда `ocx` сохранит новый стабильный launcher и восстановит shim перед выполнением запроса. Launcher, который всё ещё меняется, не трогается, а попытка откладывается до следующего раза. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 81bc03406..4d103f0f0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -178,6 +178,10 @@ ocx service uninstall 在 PATH 上把基于脚本的 `codex` 启动器包装为一个轻量自启动脚本。真实的 `codex.exe` 目标会保持不变,以避免破坏精确的可执行文件调用。 +提交安装或修复前,OpenCodex 会在跳过服务启动的情况下,用 `--version` 运行已保存的启动器。如果启动器把 `codex` 再次解析到 shim、以非零状态退出、运行超过五秒、留下仍在运行的子进程,或无法被安全验证和清理,OpenCodex 会拒绝并回滚更改。因此 `codex-shim install` 并不是无条件安装。若被拒绝,请重新安装 Codex,使 PATH 条目指向具体的可执行文件或启动器,然后重试;如果动态命令管理器的启动器无法满足这些检查,请改用 `ocx service install`。 + +升级时,缺少当前验证保护的已安装 Unix shim 会被重新生成并接受探测。如果保存的启动器不安全,OpenCodex 会移除旧 shim 并恢复原始启动器,而不是保留不安全的 wrapper。 + 仅安装启动器并不能证明 Codex 请求会经过 OpenCodex。完成健康安装后,命令会检查当前 Codex 路由;当路由由外部配置、用户自有网关管理或无法验证时,会显示警告而不是绿色成功。若出站代理变量只存在于当前进程,而 `config.proxy` 未设置或无法解析,也会给出警告,因为 Codex 启动器和后台服务未必继承该环境。这些检查只读且绝不会打印代理值;在依赖自动启动前,请先处理提示的交接配置并运行 `ocx doctor`。 如果已完成的外部 Codex 更新覆盖了已安装的 shim,下一次普通的 `ocx` 命令会先备份稳定的新启动器,再在分发前恢复 shim。仍在变动中的启动器会保持不动,并在稍后重试。修复失败只会警告,不会让所请求的命令失败;手动回退:`ocx codex-shim install`。将 `codexShimAutoRestore` 设为 `false`,或设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可在进程级别关闭自动恢复。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02983cff7..a411f467d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -187,6 +187,10 @@ ocx service uninstall 在 PATH 上以輕量自動啟動腳本包裝基於腳本的 `codex` 啟動器。真實的 `codex.exe` 目標保持不動,以避免破壞精確的可執行檔呼叫。 +在提交安裝或修復之前,OpenCodex 會略過服務啟動,並以 `--version` 執行已儲存的啟動器。若啟動器將 `codex` 解析回 shim、以非零狀態結束、超過五秒、留下仍在執行的子行程,或無法安全地驗證與清理,OpenCodex 會拒絕變更並回復。因此,`codex-shim install` 並非無條件執行。若遭拒,請重新安裝 Codex,使 PATH 項目成為具體的可執行檔或啟動器後再試;若動態指令管理器的啟動器無法通過這些檢查,請改用 `ocx service install`。 + +升級期間,若已安裝的 Unix shim 缺少目前的驗證防護,OpenCodex 會重新產生並探測它。若已儲存的啟動器不安全,OpenCodex 會移除過時的 shim 並還原原始啟動器,而不是讓不安全的 wrapper 繼續安裝。 + 若已完成的外部 Codex 更新覆寫了已安裝的 shim,下一個普通 `ocx` 指令會備份穩定的新啟動器並在分派前還原 shim。仍在變動中的啟動器保持不動並稍後重試。修復失敗會發出警告但不會使請求的指令失敗;手動後備:`ocx codex-shim install`。將 `codexShimAutoRestore` 設為 `false`,或設定 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` 以進行行程層級的退出。 | 子指令 | 動作 | diff --git a/src/codex/shim.ts b/src/codex/shim.ts index c6a995655..fd1f54893 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; import { delimiter, dirname, extname, join, posix } from "node:path"; import { chmodSync, @@ -7,11 +9,13 @@ import { fstatSync, lstatSync, mkdirSync, + mkdtempSync, openSync, readFileSync, readdirSync, readSync, renameSync, + rmSync, rmdirSync, statSync, type Stats, @@ -29,10 +33,173 @@ import { isWslRuntime, wslAutomountRoot } from "./home"; import { truncateRetainedUtf8 } from "../lib/admission"; const SHIM_MARKER = "opencodex codex autostart shim"; +const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; 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; +const CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS = 5_000; +const CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS = 1_000; +const CODEX_SHIM_REENTRY_EXIT_CODE = 126; +const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart."; +const CODEX_SHIM_INSTALL_PROBE_SCRIPT = ` +const { spawn } = require("node:child_process"); +const { readFileSync, writeFileSync } = require("node:fs"); +const [markerPath, reentryPath, groupPath, stderrPath, launcherShellPath, wrapperPath, timeoutRaw, stderrLimitRaw, stderrDrainRaw, observationRaw] = process.argv.slice(1); +const timeoutMs = Number.parseInt(timeoutRaw, 10); +const stderrLimit = Number.parseInt(stderrLimitRaw, 10); +const stderrDrainMs = Number.parseInt(stderrDrainRaw, 10); +const observationMs = Number.parseInt(observationRaw, 10); +const probeStartedAt = Date.now(); +const stderrChunks = []; +let stderrBytes = 0; +let launcher; +let probeLease; +let timer; +let stderrDrainTimer; +let observationTimer; +let reentryPollTimer; +let marker = ""; +let finished = false; + +function writeExclusive(path, value) { + writeFileSync(path, value, { flag: "wx", mode: 0o600 }); +} + +function appendStderr(value) { + if (stderrBytes >= stderrLimit) return; + const bytes = Buffer.from(value); + const retained = bytes.subarray(0, stderrLimit - stderrBytes); + stderrChunks.push(retained); + stderrBytes += retained.byteLength; +} + +function groupAlive() { + if (!launcher || !launcher.pid) return false; + try { + process.kill(-launcher.pid, 0); + return true; + } catch (error) { + return error && error.code !== "ESRCH"; + } +} + +function killGroup() { + if (!launcher || !launcher.pid) return; + try { process.kill(-launcher.pid, "SIGKILL"); } catch (error) { + if (!error || error.code !== "ESRCH") appendStderr(String(error)); + } +} + +function setMarker(value) { + if (marker) return; + marker = value; + try { writeExclusive(markerPath, value + "\\n"); } catch (error) { appendStderr(String(error)); } +} + +function reentryDetected() { + try { return readFileSync(reentryPath, "utf8").trim() === "recursive"; } catch { return false; } +} + +function checkReentry() { + if (finished || !reentryDetected()) return; + setMarker("recursive"); + killGroup(); + finish(126); +} + +function finish(status) { + if (finished) return; + finished = true; + if (timer) clearTimeout(timer); + if (stderrDrainTimer) clearTimeout(stderrDrainTimer); + if (observationTimer) clearTimeout(observationTimer); + if (reentryPollTimer) clearInterval(reentryPollTimer); + if (!marker && reentryDetected()) setMarker("recursive"); + if (!marker && groupAlive()) { + setMarker("descendants"); + killGroup(); + } + try { writeExclusive(stderrPath, Buffer.concat(stderrChunks)); } catch { /* parent fails closed */ } + process.exit(marker === "timeout" ? 124 : marker === "descendants" ? 125 : marker === "recursive" ? 126 : status); +} + +function finishAfterStderr(status) { + if (finished) return; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + if (!launcher || !launcher.stderr || !probeLease) { + finish(status); + return; + } + let stderrEnded = launcher.stderr.readableEnded; + let leaseEnded = probeLease.readableEnded; + let observationElapsed = false; + const finishWhenReady = () => { + if (stderrEnded && leaseEnded && observationElapsed) finish(status); + }; + launcher.stderr.once("end", () => { + stderrEnded = true; + finishWhenReady(); + }); + probeLease.once("end", () => { + leaseEnded = true; + finishWhenReady(); + }); + stderrDrainTimer = setTimeout(() => { + stderrEnded = true; + if (!marker && groupAlive()) { + setMarker("descendants"); + killGroup(); + finish(125); + return; + } + finishWhenReady(); + }, stderrDrainMs); + const remainingObservationMs = Math.max(0, observationMs - (Date.now() - probeStartedAt)); + observationTimer = setTimeout(() => { + observationElapsed = true; + if (!leaseEnded) { + setMarker(groupAlive() ? "descendants" : "timeout"); + killGroup(); + finish(marker === "descendants" ? 125 : 124); + return; + } + finishWhenReady(); + }, remainingObservationMs); + finishWhenReady(); +} + +try { + launcher = spawn(launcherShellPath, [wrapperPath, "--version"], { + detached: true, + env: process.env, + stdio: ["ignore", "ignore", "pipe", "pipe"], + }); + if (!launcher.pid) throw new Error("Codex shim probe launcher has no pid"); + probeLease = launcher.stdio[3]; + if (!probeLease) throw new Error("Codex shim probe launcher has no descendant lease pipe"); + writeExclusive(groupPath, String(launcher.pid) + "\\n"); + launcher.stderr.on("data", appendStderr); + reentryPollTimer = setInterval(checkReentry, 10); + launcher.once("error", error => { + appendStderr(String(error)); + finishAfterStderr(127); + }); + launcher.once("exit", code => finishAfterStderr(Number.isInteger(code) ? code : 127)); + timer = setTimeout(() => { + setMarker("timeout"); + killGroup(); + finish(124); + }, timeoutMs); +} catch (error) { + appendStderr(String(error)); + killGroup(); + finish(127); +} +`; const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; let lastShimDiscoveryError: string | null = null; /** Last human-readable reason discovery returned null (exposed for doctor/tests). */ @@ -148,6 +315,7 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { try { const content = readFileSync(path, "utf8"); if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false; + if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false; if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false; return true; } catch { @@ -204,6 +372,12 @@ function sameFingerprint( : false); } +function sameFingerprintAfterRename(left: ShimPathFingerprint, right: ShimPathFingerprint): boolean { + // rename changes the outer directory entry ctime on macOS; every other field, + // including a symlink target fingerprint, must remain identical. + return sameFingerprint({ ...left, ctimeMs: 0 }, { ...right, ctimeMs: 0 }); +} + function stableShimPathProbe(path: string): StableShimPathProbe | null { const before = statFingerprint(path, false); if (!before) return null; @@ -239,6 +413,10 @@ function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platfor return platform === "win32" || (mode & 0o111) !== 0; } +function isCurrentUnixShimProbe(probe: StableShimPathProbe): boolean { + return probe.prefix.includes(UNIX_SHIM_REVISION_MARKER); +} + function hasUsableBackingPath(file: ShimFileState): boolean { return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath] .some(path => { @@ -383,6 +561,40 @@ export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPa const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|"); return `#!/usr/bin/env sh # ${SHIM_MARKER} +# ${UNIX_SHIM_REVISION_MARKER} +if [ "\${OCX_SHIM_PROBE:-}" = "1" ]; then + if [ "\${OCX_SHIM_PROBE_ACTIVE:-}" = "1" ]; then + if [ -n "\${OCX_SHIM_PROBE_REENTRY_PATH:-}" ]; then + (umask 077; printf '%s\n' recursive > "$OCX_SHIM_PROBE_REENTRY_PATH") 2>/dev/null || true + fi + printf '%s\n' ${shQuote(CODEX_SHIM_REENTRY_DIAGNOSTIC)} >&2 + exit ${CODEX_SHIM_REENTRY_EXIT_CODE} + fi + OCX_SHIM_PROBE_ACTIVE=1 + export OCX_SHIM_PROBE_ACTIVE +fi +if [ "\${OCX_SHIM_ACTIVE_PID:-}" = "$$" ]; then + printf '%s\n' ${shQuote(CODEX_SHIM_REENTRY_DIAGNOSTIC)} >&2 + exit ${CODEX_SHIM_REENTRY_EXIT_CODE} +fi +case "\${OCX_SHIM_ACTIVE_DEPTH:-0}" in + 0) + OCX_SHIM_ACTIVE_DEPTH=1 + ;; + 1) + OCX_SHIM_ACTIVE_DEPTH=2 + ;; + *) + printf '%s\n' ${shQuote(CODEX_SHIM_REENTRY_DIAGNOSTIC)} >&2 + exit ${CODEX_SHIM_REENTRY_EXIT_CODE} + ;; +esac +# Dynamic launchers such as mise exec -- codex may resolve the command name +# back to this wrapper. An exec chain keeps the same PID. A legitimate nested +# Codex invocation may enter once with a new PID; repeated child-process +# redispatch reaches depth 2 and is rejected before it can form an infinite chain. +OCX_SHIM_ACTIVE_PID=$$ +export OCX_SHIM_ACTIVE_PID OCX_SHIM_ACTIVE_DEPTH if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})" export OPENCODEX_API_AUTH_TOKEN @@ -426,6 +638,210 @@ exec ${shQuote(realCodexPath)} "$@" `; } +type UnixShimProbeResult = "cleanup" | "descendants" | "failed" | "recursive" | "timeout" | null; + +let codexShimProbeHookForTests: (() => void) | null = null; +let codexShimProbeShellForTests: string | null = null; +let codexShimGuardedWriteHookForTests: (() => void) | null = null; +let codexShimFreshWriteHookForTests: (() => void) | null = null; +let codexShimProbeObservationMs = CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS; + +/** Narrow deterministic seam for transaction rollback tests. */ +export function setCodexShimProbeHookForTests(hook: (() => void) | null): void { + codexShimProbeHookForTests = hook; +} + +/** Selects a POSIX shell only for cross-shell probe regression tests. */ +export function setCodexShimProbeShellForTests(path: string | null): void { + codexShimProbeShellForTests = path; +} + +/** Shortens the successful-launcher observation window only for focused tests. */ +export function setCodexShimProbeObservationMsForTests(value: number | null): void { + codexShimProbeObservationMs = value ?? CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS; +} + +/** Narrow deterministic seam for guarded partial-write rollback tests. */ +export function setCodexShimGuardedWriteHookForTests(hook: (() => void) | null): void { + codexShimGuardedWriteHookForTests = hook; +} + +/** Narrow deterministic seam for fresh-install partial-write rollback tests. */ +export function setCodexShimFreshWriteHookForTests(hook: (() => void) | null): void { + codexShimFreshWriteHookForTests = hook; +} + +function readProbeMetadata(path: string, maxBytes: number): string | null { + try { + if (!existsSync(path)) return ""; + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > maxBytes) return null; + return readFileSync(path, "utf8").trim(); + } catch { + return null; + } +} + +function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { + if (process.platform === "win32") return null; + const probeDir = mkdtempSync(join(tmpdir(), "opencodex-shim-probe-")); + const markerPath = join(probeDir, "result"); + const reentryPath = join(probeDir, "reentry"); + const groupPath = join(probeDir, "group"); + const stderrPath = join(probeDir, "stderr"); + const env: NodeJS.ProcessEnv = { + ...process.env, + OCX_SHIM_BYPASS: "1", + OCX_SHIM_PROBE: "1", + OCX_SHIM_PROBE_REENTRY_PATH: reentryPath, + }; + delete env.OCX_SHIM_ACTIVE_PID; + delete env.OCX_SHIM_ACTIVE_DEPTH; + delete env.OCX_SHIM_PROBE_ACTIVE; + let groupId = 0; + try { + chmodSync(probeDir, 0o700); + const result = spawnSync(process.execPath, [ + "-e", + CODEX_SHIM_INSTALL_PROBE_SCRIPT, + markerPath, + reentryPath, + groupPath, + stderrPath, + codexShimProbeShellForTests ?? "/bin/sh", + wrapperPath, + String(CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS), + String(MAX_DIAGNOSTIC_VALUE_BYTES), + String(CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS), + String(codexShimProbeObservationMs), + ], { + encoding: "utf8", + env, + timeout: CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; + const marker = readProbeMetadata(markerPath, 64); + const reentryMarker = readProbeMetadata(reentryPath, 64); + const groupText = readProbeMetadata(groupPath, 64); + const launcherStderr = readProbeMetadata(stderrPath, MAX_DIAGNOSTIC_VALUE_BYTES); + groupId = groupText === null ? 0 : Number.parseInt(groupText, 10); + if (marker === null || reentryMarker === null || groupText === null || launcherStderr === null + || !Number.isInteger(groupId) || groupId <= 0) return "cleanup"; + const groupSurvived = unixProcessGroupAlive(groupId); + if (timedOut || marker || reentryMarker || groupSurvived) { + try { + terminateUnixProcessGroup(groupId); + } catch { + return "cleanup"; + } + } + if (result.error && !timedOut) return "cleanup"; + if (timedOut || marker === "timeout") return "timeout"; + if (marker === "recursive" || reentryMarker === "recursive") return "recursive"; + if (reentryMarker !== "") return "cleanup"; + if (marker === "descendants") return "descendants"; + if (groupSurvived) return "descendants"; + if (result.status === CODEX_SHIM_REENTRY_EXIT_CODE && launcherStderr.includes(CODEX_SHIM_REENTRY_DIAGNOSTIC)) { + return "recursive"; + } + if (result.status !== 0) return "failed"; + return null; + } catch { + if (Number.isInteger(groupId) && groupId > 0) { + try { terminateUnixProcessGroup(groupId); } catch { /* cleanup classification below */ } + } + return "cleanup"; + } finally { + try { rmSync(probeDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +} + +function probeUnixShimFiles(files: readonly ShimFileState[]): UnixShimProbeResult { + if (process.platform === "win32") return null; + codexShimProbeHookForTests?.(); + return files + .filter(file => !file.preserveOnly) + .map(file => probeUnixShimInstall(file.wrapperPath)) + .find(result => result !== null) ?? null; +} + +function unixProcessGroupAlive(groupId: number): boolean { + try { + process.kill(-groupId, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function terminateUnixProcessGroup(groupId: number): void { + try { + process.kill(-groupId, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + const deadline = Date.now() + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS; + while (Date.now() < deadline && unixProcessGroupAlive(groupId)) Bun.sleepSync(10); + if (unixProcessGroupAlive(groupId)) { + throw new Error(`Codex shim install probe process group ${groupId} did not terminate`); + } +} + +interface FreshShimInstallJournalEntry { + target: ShimFileState; + movedOriginalFingerprint?: ShimPathFingerprint; + originalMovedToBackup: boolean; + writtenWrapperFingerprint?: ShimPathFingerprint; + wrapperWriteStarted: boolean; +} + +function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry[]): void { + const errors: Error[] = []; + for (const entry of [...journal].reverse()) { + const target = entry.target; + let sourceOccupied = false; + try { + const wrapper = stableShimPathProbe(target.wrapperPath); + const ownsWrapper = !target.preserveOnly + && entry.wrapperWriteStarted + && wrapper !== null + && (entry.writtenWrapperFingerprint + ? sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint) + : wrapper.prefix.includes(SHIM_MARKER)); + if (ownsWrapper) unlinkSync(target.wrapperPath); + else { + try { + lstatSync(target.originalPath); + sourceOccupied = true; + } catch (error) { + if (fileErrorCode(error) !== "ENOENT") sourceOccupied = true; + } + } + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + try { + if (entry.originalMovedToBackup && existsSync(target.backupPath)) { + const movedOriginal = stableShimPathProbe(target.backupPath); + if (!movedOriginal || !entry.movedOriginalFingerprint + || !sameFingerprint(movedOriginal.fingerprint, entry.movedOriginalFingerprint)) { + throw new Error("Codex shim fresh-install backup changed during rollback"); + } + if (sourceOccupied) { + if (!entry.writtenWrapperFingerprint) { + throw new Error("Codex shim fresh-install wrapper ownership changed during rollback"); + } + unlinkSync(target.backupPath); + } else renameSync(target.backupPath, target.originalPath); + } + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (errors.length > 0) throw new AggregateError(errors, "Codex shim install validation rollback failed"); +} + function windowsBatchValue(value: string): string { return value .replace(/%/g, "%%") @@ -684,12 +1100,37 @@ function refreshShimFile(file: ShimFileState): boolean { } if (existsSync(file.wrapperPath) && !isShim(file.wrapperPath)) { if (file.wrapperPath !== file.originalPath) return false; - replaceOwnedBackup(file.wrapperPath, file.backupPath); - writeShim(file.wrapperPath, file.realPath ?? file.backupPath); - return true; + const replacement = stableShimPathProbe(file.wrapperPath); + if (!replacement) return false; + return applyGuardedRefreshTransaction([{ + file, + expectedReplacement: replacement.fingerprint, + sourcePath: file.wrapperPath, + }]); } if (!existsSync(file.wrapperPath) && existsSync(file.backupPath)) { writeShim(file.wrapperPath, file.realPath ?? file.backupPath); + const writtenWrapper = stableShimPathProbe(file.wrapperPath); + if (!writtenWrapper || !writtenWrapper.prefix.includes(SHIM_MARKER)) { + return false; + } + let unsafe: UnixShimProbeResult = null; + let probeError: Error | null = null; + try { + unsafe = probeUnixShimFiles([file]); + } catch (error) { + probeError = error instanceof Error ? error : new Error(String(error)); + } + const currentWrapper = stableShimPathProbe(file.wrapperPath); + const wrapperChangedDuringProbe = !currentWrapper + || !sameFingerprint(currentWrapper.fingerprint, writtenWrapper.fingerprint); + if (unsafe !== null || probeError || wrapperChangedDuringProbe) { + if (currentWrapper && sameFingerprint(currentWrapper.fingerprint, writtenWrapper.fingerprint)) { + unlinkSync(file.wrapperPath); + } + if (probeError) throw probeError; + return false; + } return true; } if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) { @@ -709,7 +1150,9 @@ interface GuardedRefreshOperation { interface GuardedRefreshJournalEntry { operation: GuardedRefreshOperation; stagedOldBackupPath?: string; + movedReplacementFingerprint?: ShimPathFingerprint; replacementMovedToBackup: boolean; + writtenWrapperFingerprint?: ShimPathFingerprint; wrapperWriteStarted: boolean; } @@ -901,14 +1344,34 @@ function rollbackGuardedRefresh(journal: readonly GuardedRefreshJournalEntry[]): } }; for (const entry of [...journal].reverse()) { + let sourceOccupied = false; attempt(() => { - if (entry.wrapperWriteStarted && existsSync(entry.operation.file.wrapperPath)) { + const wrapper = stableShimPathProbe(entry.operation.file.wrapperPath); + const ownsWrapper = entry.wrapperWriteStarted + && wrapper !== null + && (entry.writtenWrapperFingerprint !== undefined + ? sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint) + : wrapper.prefix.includes(SHIM_MARKER)); + if (ownsWrapper) { unlinkSync(entry.operation.file.wrapperPath); + } else { + try { + lstatSync(entry.operation.sourcePath); + sourceOccupied = true; + } catch (error) { + if (fileErrorCode(error) !== "ENOENT") sourceOccupied = true; + } } }); attempt(() => { if (entry.replacementMovedToBackup && existsSync(entry.operation.file.backupPath)) { - renameSync(entry.operation.file.backupPath, entry.operation.sourcePath); + const movedReplacement = stableShimPathProbe(entry.operation.file.backupPath); + if (!movedReplacement || !entry.movedReplacementFingerprint + || !sameFingerprint(movedReplacement.fingerprint, entry.movedReplacementFingerprint)) { + throw new Error("Codex shim guarded refresh backup changed during rollback"); + } + if (sourceOccupied) unlinkSync(entry.operation.file.backupPath); + else renameSync(entry.operation.file.backupPath, entry.operation.sourcePath); } }); attempt(() => { @@ -928,6 +1391,8 @@ function applyGuardedRefreshTransaction( const journal: GuardedRefreshJournalEntry[] = []; let applyError: Error | null = null; let fingerprintMismatch = false; + let unsafeLauncher = false; + let wrapperChangedDuringProbe = false; const transactionId = `${process.pid}-${++guardedRefreshTransactionId}`; for (const [index, operation] of operations.entries()) { @@ -951,15 +1416,40 @@ function applyGuardedRefreshTransaction( } renameSync(operation.sourcePath, operation.file.backupPath); entry.replacementMovedToBackup = true; + const movedReplacement = stableShimPathProbe(operation.file.backupPath); + if (!movedReplacement) throw new Error("Codex shim guarded refresh could not fingerprint the staged launcher"); + entry.movedReplacementFingerprint = movedReplacement.fingerprint; entry.wrapperWriteStarted = true; writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath); + codexShimGuardedWriteHookForTests?.(); + const writtenWrapper = stableShimPathProbe(operation.file.wrapperPath); + if (!writtenWrapper || !writtenWrapper.prefix.includes(SHIM_MARKER)) { + throw new Error("Codex shim guarded refresh could not fingerprint the generated wrapper"); + } + entry.writtenWrapperFingerprint = writtenWrapper.fingerprint; } catch (error) { applyError = error instanceof Error ? error : new Error(String(error)); break; } } - if (!fingerprintMismatch && !applyError && commitState) { + if (!fingerprintMismatch && !applyError) { + try { + unsafeLauncher = probeUnixShimFiles(operations.map(operation => operation.file)) !== null; + } catch (error) { + applyError = error instanceof Error ? error : new Error(String(error)); + } + } + + if (!fingerprintMismatch && !applyError && !unsafeLauncher) { + wrapperChangedDuringProbe = journal.some(entry => { + const wrapper = stableShimPathProbe(entry.operation.file.wrapperPath); + return !wrapper || !entry.writtenWrapperFingerprint + || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint); + }); + } + + if (!fingerprintMismatch && !applyError && !unsafeLauncher && !wrapperChangedDuringProbe && commitState) { try { commitState(); } catch (error) { @@ -967,7 +1457,7 @@ function applyGuardedRefreshTransaction( } } - if (fingerprintMismatch || applyError) { + if (fingerprintMismatch || applyError || unsafeLauncher || wrapperChangedDuringProbe) { const rollbackErrors = rollbackGuardedRefresh(journal); if (applyError || rollbackErrors.length > 0) { throw new AggregateError( @@ -990,10 +1480,184 @@ function applyGuardedRefreshTransaction( return true; } +interface ObsoleteUnixShimJournalEntry { + file: ShimFileState; + stagedWrapperPath: string; + priorWrapperFingerprint: ShimPathFingerprint; + backingFingerprint: ShimPathFingerprint; + writtenWrapperFingerprint?: ShimPathFingerprint; + wrapperWriteStarted: boolean; +} + +function rollbackObsoleteUnixShimRefresh(journal: readonly ObsoleteUnixShimJournalEntry[]): Error[] { + const errors: Error[] = []; + const attempt = (operation: () => void): void => { + try { + operation(); + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + }; + for (const entry of [...journal].reverse()) { + attempt(() => { + const wrapper = stableShimPathProbe(entry.file.wrapperPath); + const ownsWrapper = entry.wrapperWriteStarted + && wrapper !== null + && (entry.writtenWrapperFingerprint + ? sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint) + : wrapper.prefix.includes(UNIX_SHIM_REVISION_MARKER)); + if (ownsWrapper) unlinkSync(entry.file.wrapperPath); + }); + attempt(() => { + if (!existsSync(entry.stagedWrapperPath)) return; + if (existsSync(entry.file.wrapperPath)) unlinkSync(entry.stagedWrapperPath); + else renameSync(entry.stagedWrapperPath, entry.file.wrapperPath); + }); + } + return errors; +} + +type ObsoleteUnixShimRefreshResult = + | { installed: true; message: string } + | { installed: false; deferred: boolean; message: string }; + +function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnixShimRefreshResult { + if (process.platform === "win32") { + return { installed: false, deferred: false, message: "Codex autostart shim is already current." }; + } + const candidates = files.filter(file => { + if (file.preserveOnly || file.wrapperPath !== file.originalPath || !existsSync(file.wrapperPath)) return false; + const probe = stableShimPathProbe(file.wrapperPath); + return probe !== null && probe.prefix.includes(SHIM_MARKER) && !isCurrentUnixShimProbe(probe); + }); + if (candidates.length === 0) { + return { installed: false, deferred: true, message: "Codex autostart shim upgrade deferred because tracked launchers changed." }; + } + + const journal: ObsoleteUnixShimJournalEntry[] = []; + const transactionId = `${process.pid}-${randomUUID()}`; + let applyError: Error | null = null; + for (const [index, file] of candidates.entries()) { + const wrapper = stableShimPathProbe(file.wrapperPath); + const backing = stableShimPathProbe(file.backupPath); + if (!wrapper || !wrapper.prefix.includes(SHIM_MARKER) || isCurrentUnixShimProbe(wrapper) || !backing) { + applyError = new Error("Codex autostart shim upgrade inputs changed before regeneration"); + break; + } + const entry: ObsoleteUnixShimJournalEntry = { + file, + stagedWrapperPath: `${file.wrapperPath}.upgrade-${transactionId}-${index}`, + priorWrapperFingerprint: wrapper.fingerprint, + backingFingerprint: backing.fingerprint, + wrapperWriteStarted: false, + }; + journal.push(entry); + try { + renameSync(file.wrapperPath, entry.stagedWrapperPath); + const stagedWrapper = stableShimPathProbe(entry.stagedWrapperPath); + if (!stagedWrapper + || !sameFingerprintAfterRename(stagedWrapper.fingerprint, entry.priorWrapperFingerprint)) { + throw new Error("Codex autostart shim upgrade could not fingerprint the staged wrapper"); + } + entry.wrapperWriteStarted = true; + writeShim(file.wrapperPath, file.realPath ?? file.backupPath); + const writtenWrapper = stableShimPathProbe(file.wrapperPath); + if (!writtenWrapper || !isCurrentUnixShimProbe(writtenWrapper)) { + throw new Error("Codex autostart shim upgrade could not fingerprint the regenerated wrapper"); + } + entry.writtenWrapperFingerprint = writtenWrapper.fingerprint; + } catch (error) { + applyError = error instanceof Error ? error : new Error(String(error)); + break; + } + } + + let unsafe: UnixShimProbeResult = null; + let probeError: Error | null = null; + if (!applyError) { + try { + unsafe = probeUnixShimFiles(candidates); + } catch (error) { + probeError = error instanceof Error ? error : new Error(String(error)); + } + } + const changedDuringProbe = !applyError && !probeError && journal.some(entry => { + const wrapper = stableShimPathProbe(entry.file.wrapperPath); + const backing = stableShimPathProbe(entry.file.backupPath); + return !wrapper || !entry.writtenWrapperFingerprint + || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint) + || !backing || !sameFingerprint(backing.fingerprint, entry.backingFingerprint); + }); + + if (applyError || probeError || changedDuringProbe) { + const rollbackErrors = rollbackObsoleteUnixShimRefresh(journal); + if (applyError || probeError || rollbackErrors.length > 0) { + throw new AggregateError( + [...(applyError ? [applyError] : []), ...(probeError ? [probeError] : []), ...rollbackErrors], + "Codex autostart shim upgrade failed", + ); + } + return { installed: false, deferred: true, message: "Codex autostart shim upgrade deferred because tracked launchers changed." }; + } + + if (unsafe) { + const cleanupErrors: Error[] = []; + for (const entry of [...journal].reverse()) { + try { + const wrapper = stableShimPathProbe(entry.file.wrapperPath); + if (!wrapper || !entry.writtenWrapperFingerprint + || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint)) { + throw new Error("Codex autostart shim upgrade lost wrapper ownership before removal"); + } + const backing = stableShimPathProbe(entry.file.backupPath); + if (!backing || !sameFingerprint(backing.fingerprint, entry.backingFingerprint)) { + throw new Error("Codex autostart shim upgrade backing launcher changed before restoration"); + } + unlinkSync(entry.file.wrapperPath); + renameSync(entry.file.backupPath, entry.file.originalPath); + if (existsSync(entry.stagedWrapperPath)) unlinkSync(entry.stagedWrapperPath); + } catch (error) { + cleanupErrors.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Codex autostart shim upgrade safety removal failed"); + } + if (existsSync(statePath())) unlinkSync(statePath()); + return { + installed: false, + deferred: false, + message: "Removed an obsolete Codex autostart shim because its saved launcher failed current validation. The original launcher was restored; reinstall Codex as a concrete executable before enabling codexAutoStart.", + }; + } + + const cleanupErrors: Error[] = []; + for (const entry of journal) { + try { + if (existsSync(entry.stagedWrapperPath)) unlinkSync(entry.stagedWrapperPath); + } catch (error) { + cleanupErrors.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Codex autostart shim upgrade cleanup failed"); + return { + installed: true, + message: `Upgraded Codex autostart shim at ${candidates.map(file => file.wrapperPath).join(", ")} and validated the saved launcher.`, + }; +} + function installCodexShimInternal(options: InstallCodexShimInternalOptions): { installed: boolean; message: string } { const existing = readState(); if (existing) { const files = stateFiles(existing); + if (!options.expectedReplacements && process.platform !== "win32") { + const hasObsoleteShim = files.some(file => { + if (file.preserveOnly) return false; + const probe = stableShimPathProbe(file.wrapperPath); + return probe !== null && probe.prefix.includes(SHIM_MARKER) && !isCurrentUnixShimProbe(probe); + }); + if (hasObsoleteShim) return refreshObsoleteUnixShims(files); + } if (options.expectedReplacements) { const operations = planGuardedRefreshTransaction(files, options.expectedReplacements); if (!operations || operations.length === 0) { @@ -1060,9 +1724,94 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i for (const target of targets) { if (existsSync(target.backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${target.backupPath}` }; } + const freshJournal: FreshShimInstallJournalEntry[] = []; + let freshApplyError: Error | null = null; for (const target of targets) { - if (existsSync(target.originalPath)) renameSync(target.originalPath, target.backupPath); - if (!target.preserveOnly) writeShim(target.wrapperPath, target.realPath ?? target.backupPath); + const entry: FreshShimInstallJournalEntry = { + target, + originalMovedToBackup: false, + wrapperWriteStarted: false, + }; + freshJournal.push(entry); + try { + if (existsSync(target.originalPath)) { + renameSync(target.originalPath, target.backupPath); + entry.originalMovedToBackup = true; + if (process.platform !== "win32") { + const movedOriginal = stableShimPathProbe(target.backupPath); + if (!movedOriginal) throw new Error("Codex shim fresh install could not fingerprint the staged launcher"); + entry.movedOriginalFingerprint = movedOriginal.fingerprint; + } + } + if (!target.preserveOnly) { + entry.wrapperWriteStarted = true; + writeShim(target.wrapperPath, target.realPath ?? target.backupPath); + codexShimFreshWriteHookForTests?.(); + if (process.platform !== "win32") { + const writtenWrapper = stableShimPathProbe(target.wrapperPath); + if (!writtenWrapper || !writtenWrapper.prefix.includes(SHIM_MARKER)) { + throw new Error("Codex shim fresh install could not fingerprint the generated wrapper"); + } + entry.writtenWrapperFingerprint = writtenWrapper.fingerprint; + } + } + } catch (error) { + freshApplyError = error instanceof Error ? error : new Error(String(error)); + break; + } + } + if (process.platform !== "win32") { + if (freshApplyError) { + try { + rollbackFreshShimInstall(freshJournal); + } catch (rollbackError) { + throw new AggregateError([freshApplyError, rollbackError], "Codex shim installation and rollback failed"); + } + throw freshApplyError; + } + let unsafe: UnixShimProbeResult = null; + let probeError: Error | null = null; + try { + unsafe = probeUnixShimFiles(targets); + } catch (error) { + probeError = error instanceof Error ? error : new Error(String(error)); + } + if (probeError) { + try { + rollbackFreshShimInstall(freshJournal); + } catch (rollbackError) { + throw new AggregateError([probeError, rollbackError], "Codex shim probe and install rollback failed"); + } + throw probeError; + } + const wrapperChangedDuringProbe = freshJournal.some(entry => { + if (entry.target.preserveOnly) return false; + const wrapper = stableShimPathProbe(entry.target.wrapperPath); + return !wrapper || !entry.writtenWrapperFingerprint + || !sameFingerprint(wrapper.fingerprint, entry.writtenWrapperFingerprint); + }); + if (unsafe || wrapperChangedDuringProbe) { + rollbackFreshShimInstall(freshJournal); + const reason = wrapperChangedDuringProbe + ? "the generated wrapper changed during its validation probe" + : unsafe === "recursive" + ? "the saved launcher resolved back to the generated shim" + : unsafe === "timeout" + ? `the saved launcher did not finish --version within ${CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS}ms` + : unsafe === "descendants" + ? "the saved launcher left background descendants running after --version" + : unsafe === "cleanup" + ? "the saved launcher's probe process group could not be terminated cleanly" + : "the saved launcher failed its --version probe"; + return { + installed: false, + message: wrapperChangedDuringProbe + ? `Refusing Codex autostart shim because ${reason}. The concurrent launcher was preserved; retry after the Codex update finishes.` + : `Refusing Codex autostart shim because ${reason}. The original launcher was restored; reinstall Codex as a concrete executable before enabling codexAutoStart.`, + }; + } + } else if (freshApplyError) { + throw freshApplyError; } writeState(primaryState(targets)); return { @@ -1095,6 +1844,7 @@ export function autoRestoreCodexShim(options: { const files = stateFiles(state); const replacementProbes = new Map(); + const obsoleteShimProbes = new Map(); const seen = new Set(); let healthyCount = 0; for (const file of files) { @@ -1109,15 +1859,19 @@ export function autoRestoreCodexShim(options: { if (!probe) return { status: "deferred" }; if (probe.prefix.includes(SHIM_MARKER)) { if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" }; + if (state.platform !== "win32" && !isCurrentUnixShimProbe(probe)) { + obsoleteShimProbes.set(file.wrapperPath, probe); + continue; + } healthyCount += 1; continue; } replacementProbes.set(file.wrapperPath, probe); } - if (replacementProbes.size === 0) return { status: "healthy" }; + if (replacementProbes.size === 0 && obsoleteShimProbes.size === 0) return { status: "healthy" }; if (!options.enabled()) return { status: "disabled" }; - if (files.length > 1 && healthyCount > 0) { + if (files.length > 1 && (healthyCount > 0 || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) { return { status: "deferred", message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.", @@ -1129,6 +1883,19 @@ export function autoRestoreCodexShim(options: { try { options.afterRestoreLockAcquired?.(); (options.stabilitySleep ?? Bun.sleepSync)(CODEX_SHIM_REPLACEMENT_STABLE_MS); + if (obsoleteShimProbes.size > 0) { + for (const [path, firstProbe] of obsoleteShimProbes) { + const secondProbe = stableShimPathProbe(path); + if (!secondProbe || isCurrentUnixShimProbe(secondProbe) + || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" }; + } + const result = refreshObsoleteUnixShims(files); + return result.installed + ? { status: "restored", message: result.message } + : result.deferred + ? { status: "deferred", message: result.message } + : { status: "ineligible", message: result.message }; + } const expectedReplacements = new Map(); for (const [path, firstProbe] of replacementProbes) { const secondProbe = stableShimPathProbe(path); diff --git a/structure/01_runtime.md b/structure/01_runtime.md index e0977229a..6d5527709 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -58,6 +58,14 @@ tracked sibling before mutation and rolls back earlier siblings in reverse order 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. +[Decision Log] +- 목적과 의도: Prevent a saved command-name launcher such as a mise trampoline from resolving back to the newly installed Codex shim and recursing indefinitely, while keeping native Codex usable after every failed install or repair. +- 기존 구현 및 제약 조건: Unix installation accepted any non-OpenCodex PATH launcher and saved it behind the shim. Shell scripts, native executables, symlinks, and dynamic launchers are all valid, and static launcher contents cannot prove what a command-name redispatch will resolve to after the shim is installed. +- 검토한 주요 대안: Reject launchers by content or known tool signatures; resolve one more executable path before installation; run an unbounded validation command; supervise every descendant across new sessions with platform-specific process enumeration. +- 선택한 방식: Validate the generated wrapper with a bounded behavioral `--version` probe. Run it in an isolated process group, use a probe-only re-entry sentinel plus an inherited lease descriptor as evidence of recursion or surviving descendants, terminate the owned process group on an unsafe result, and commit state only while the generated wrapper still matches its recorded fingerprint. Probe failures and owned-wrapper changes roll back in reverse order while preserving an observed external replacement. +- 다른 대안 대신 이 방식을 선택한 이유: Exercising the installed resolution path catches mise-style and other dynamic redispatch without parsing provider-specific launcher formats. A five-second observation window bounds installation latency and cleanup, while ownership fingerprints make rollback safe for the concurrent replacements the transaction can observe. +- 장점, 단점 및 영향: Direct executables and symlinks remain supported, deterministic shim re-entry is rejected, and failed validation restores the prior launcher instead of leaving a committed recursive shim. Installation can take up to five additional seconds. Containment is deliberately process-group scoped: a descendant that intentionally creates a new session can escape group termination, so this probe is not a general OS process supervisor; sentinel evidence still rejects an escaped descendant that re-enters the generated shim during the bounded window. + The bridge enforces a heartbeat stall deadline. It defaults to 300 seconds sampled on a 2 s tick (`src/stall-timeout.ts`) and is configurable, so treat the number as a default rather than an invariant; sidecars keep their own clocks. On expiry the stream is closed and the upstream request diff --git a/tests/codex-shim-autorestore.test.ts b/tests/codex-shim-autorestore.test.ts index 3b9b0b0ac..f6bd4b2b5 100644 --- a/tests/codex-shim-autorestore.test.ts +++ b/tests/codex-shim-autorestore.test.ts @@ -161,5 +161,5 @@ describe("Codex shim CLI auto-restore policy", () => { rmSync(binDir, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); } - }); + }, 20_000); }); diff --git a/tests/codex-shim-readiness.test.ts b/tests/codex-shim-readiness.test.ts index d81e941ef..18f0b9e59 100644 --- a/tests/codex-shim-readiness.test.ts +++ b/tests/codex-shim-readiness.test.ts @@ -140,7 +140,7 @@ describe("Codex shim install readiness", () => { } finally { rmSync(root, { recursive: true, force: true }); } - }); + }, 10_000); test("keeps install advisory when the Codex config cannot be read", () => { const root = mkdtempSync(join(tmpdir(), "ocx-shim-unreadable-config-")); @@ -177,5 +177,5 @@ describe("Codex shim install readiness", () => { } finally { rmSync(root, { recursive: true, force: true }); } - }); + }, 10_000); }); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 31137b245..90f3fb447 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -1,12 +1,63 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, uninstallCodexShim } from "../src/codex/shim"; +import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; +const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; const skipStabilityWait = () => {}; +const python3Path = process.platform === "win32" + ? "" + : spawnSync("/bin/sh", ["-c", "command -v python3"], { encoding: "utf8" }).stdout.trim(); +setCodexShimProbeObservationMsForTests(20); +afterAll(() => setCodexShimProbeObservationMsForTests(null)); +const psPath = process.platform !== "win32" && existsSync("/bin/ps") ? "/bin/ps" : ""; + +function prependPath(dir: string, current: string | undefined): string { + return [dir, current].filter(Boolean).join(delimiter); +} + +function successfulLauncher(label: string): string { + return process.platform === "win32" ? `${label}\r\n` : `#!/bin/sh\n# ${label}\nexit 0\n`; +} + +function processState(pid: number): string { + if (!psPath) throw new Error("/bin/ps is required for process-state assertions"); + const result = spawnSync(psPath, ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }); + if (result.error || typeof result.stdout !== "string") { + throw new Error(`/bin/ps failed during process-state assertion: ${String(result.error ?? "missing stdout")}`); + } + return result.stdout.trim(); +} + +function obsoleteUnixShim(current: string): string { + const obsolete = current.replace(`# ${UNIX_SHIM_REVISION_MARKER}\n`, ""); + expect(obsolete).not.toBe(current); + return obsolete; +} + +function waitForProcessStop(pid: number, timeoutMs = 1_000): string { + const deadline = Date.now() + timeoutMs; + const waiter = new Int32Array(new SharedArrayBuffer(4)); + let state = processState(pid); + while (state !== "" && !state.startsWith("Z") && Date.now() < deadline) { + Atomics.wait(waiter, 0, 0, 10); + state = processState(pid); + } + return state; +} + +function expectProcessGroupMissing(groupId: number): void { + let code: string | undefined; + try { + process.kill(-groupId, 0); + } catch (error) { + code = (error as NodeJS.ErrnoException).code; + } + expect(code).toBe("ESRCH"); +} function withInstalledShim(run: (paths: { binDir: string; @@ -23,7 +74,7 @@ function withInstalledShim(run: (paths: { ? [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")] : [join(binDir, "codex")]; try { - process.env.PATH = binDir; + process.env.PATH = prependPath(binDir, oldPath); process.env.OPENCODEX_HOME = home; for (const wrapper of wrappers) { writeFileSync(wrapper, process.platform === "win32" ? `real ${wrapper}\n` : "#!/bin/sh\necho real\n", "utf8"); @@ -214,6 +265,646 @@ describe("Codex autostart shim", () => { expect(script).toContain("OCX_SHIM_BYPASS"); }); + test("Unix shim stops same-process re-entry through a dynamic launcher", () => { + if (process.platform === "win32") return; + + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-reentry-")); + const bunPath = join(dir, "bun"); + const misePath = join(dir, "mise"); + const realCodexPath = join(dir, "codex.opencodex-real"); + const shimPath = join(dir, "codex"); + try { + writeFileSync(bunPath, "#!/usr/bin/env sh\nexit 0\n", "utf8"); + writeFileSync(misePath, `#!/usr/bin/env sh +if [ "$1" = exec ] && [ "$2" = -- ] && [ "$3" = codex ]; then + shift 3 + exec codex "$@" +fi +exit 64 +`, "utf8"); + writeFileSync(realCodexPath, `#!/usr/bin/env sh\nexec "${misePath}" exec -- codex "$@"\n`, "utf8"); + writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/cli.ts", "bundled"), "utf8"); + chmodSync(bunPath, 0o755); + chmodSync(misePath, 0o755); + chmodSync(realCodexPath, 0o755); + chmodSync(shimPath, 0o755); + const env = { ...process.env, PATH: prependPath(dir, process.env.PATH), OCX_SHIM_BYPASS: "1" }; + delete env.OCX_SHIM_ACTIVE_PID; + + const result = spawnSync(shimPath, ["--help"], { + encoding: "utf8", + env, + timeout: 4_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(126); + expect(result.stderr).toContain("saved Codex launcher resolved back to the autostart shim"); + expect(result.stderr).toContain("ocx codex-shim uninstall"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("Unix install rejects a recursive dynamic launcher and restores the original", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-reentry-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-reentry-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const misePath = join(binDir, "mise"); + const original = `#!/bin/sh\nexec "${misePath}" exec -- codex "$@"\n`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(misePath, `#!/bin/sh +if [ "$1" = exec ] && [ "$2" = -- ] && [ "$3" = codex ]; then + shift 3 + exec codex "$@" +fi +exit 64 +`, "utf8"); + writeFileSync(codexPath, original, "utf8"); + chmodSync(misePath, 0o755); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("saved launcher resolved back to the generated shim"); + expect(installed.message).toContain("original launcher was restored"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } 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 }); + } + }); + + test("Unix runtime guard stops argument-dependent child-process redispatch", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-runtime-child-reentry-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-runtime-child-reentry-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const original = `#!/bin/sh +if [ "$1" = "--version" ]; then + exit 0 +fi +codex "$@" +`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + expect(installCodexShim().installed).toBe(true); + + const result = spawnSync(codexPath, ["--help"], { + encoding: "utf8", + env: { ...process.env, OCX_SHIM_BYPASS: "1" }, + timeout: 3_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(126); + expect(result.stderr).toContain("saved Codex launcher resolved back to the autostart shim"); + } 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 }); + } + }); + + test("Unix install drains an immediate recursive diagnostic before classifying the probe", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-immediate-reentry-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-immediate-reentry-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const original = `#!/bin/sh +printf '%s\\n' 'opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart.' >&2 +exit 126 +`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("saved launcher resolved back to the generated shim"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + } 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 }); + } + }); + + test.skipIf(process.platform === "win32" || !existsSync("/bin/dash"))( + "Unix install accepts a valid launcher when the probe shell is dash", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-dash-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-dash-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, successfulLauncher("dash-valid-launcher"), "utf8"); + chmodSync(codexPath, 0o755); + setCodexShimProbeShellForTests("/bin/dash"); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(true); + expect(readFileSync(codexPath, "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(`${codexPath}.opencodex-real`, "utf8")).toBe(successfulLauncher("dash-valid-launcher")); + expect(existsSync(join(home, "codex-shim.json"))).toBe(true); + } finally { + setCodexShimProbeShellForTests(null); + 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("Unix install honors the injected probe shell path", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-missing-shell-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-missing-shell-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const original = successfulLauncher("missing-probe-shell"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + setCodexShimProbeShellForTests(join(binDir, "does-not-exist")); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + } finally { + setCodexShimProbeShellForTests(null); + 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.skipIf(process.platform === "win32" || !existsSync("/usr/bin/true"))( + "Unix install probes a concrete native executable through the generated wrapper", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-native-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-native-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + copyFileSync("/usr/bin/true", codexPath); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(true); + expect(readFileSync(codexPath, "utf8")).toContain(SHIM_MARKER); + expect(lstatSync(`${codexPath}.opencodex-real`).isFile()).toBe(true); + } 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 }); + } + }, + ); + + test.skipIf(process.platform === "win32" || !existsSync("/usr/bin/true"))( + "Unix install probes a symlinked native executable through the generated wrapper", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-native-link-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-native-link-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + symlinkSync("/usr/bin/true", codexPath); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(true); + expect(readFileSync(codexPath, "utf8")).toContain(SHIM_MARKER); + expect(lstatSync(`${codexPath}.opencodex-real`).isSymbolicLink()).toBe(true); + } 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 }); + } + }, + ); + + test("Unix install rejects a launcher that leaves a background descendant", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-child-reentry-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-child-reentry-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const childPidPath = join(home, "background-child.pid"); + const groupIdPath = join(home, "background-child-group.pid"); + const original = `#!/bin/sh +/bin/sleep 30 & +child=$! +printf '%s\\n' "$child" > "${childPidPath}" +printf '%s\\n' "$$" > "${groupIdPath}" +exit 0 +`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("left background descendants running after --version"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + const childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); + const groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + expectProcessGroupMissing(groupId); + const childState = processState(childPid); + expect(childState === "" || childState.startsWith("Z")).toBe(true); + } 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 }); + } + }); + + test.skipIf(process.platform === "win32" || !python3Path)( + "Unix install rejects delayed detached redispatch after the launcher closes its lease fd", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-detached-reentry-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-detached-reentry-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const childPidPath = join(home, "detached-reentry.pid"); + const original = `#!${python3Path} +import os +import time +os.close(3) +pid = os.fork() +if pid == 0: + os.setsid() + stderr_fd = os.open(os.devnull, os.O_WRONLY) + os.dup2(stderr_fd, 2) + os.close(stderr_fd) + time.sleep(0.5) + os.execvpe("codex", ["codex", "--version"], os.environ) +with open(${JSON.stringify(childPidPath)}, "w", encoding="utf-8") as handle: + handle.write(str(pid)) +os._exit(0) +`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + setCodexShimProbeObservationMsForTests(1_500); + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("saved launcher resolved back to the generated shim"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + const childPid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10); + const childState = waitForProcessStop(childPid); + expect(childState === "" || childState.startsWith("Z")).toBe(true); + } finally { + setCodexShimProbeObservationMsForTests(20); + 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("Unix install rolls back when launcher validation times out", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const childPidPath = join(home, "probe-child.pid"); + const groupIdPath = join(home, "probe-group.pid"); + const original = `#!/bin/sh +/bin/sleep 30 & +child=$! +printf '%s\\n' "$child" > "${childPidPath}" +printf '%s\\n' "$$" > "${groupIdPath}" +wait "$child" +`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("did not finish --version within 5000ms"); + expect(installed.message).toContain("original launcher was restored"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + const childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); + const groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + expect(Number.isInteger(childPid)).toBe(true); + expect(Number.isInteger(groupId)).toBe(true); + expectProcessGroupMissing(groupId); + const childState = processState(childPid); + expect(childState === "" || childState.startsWith("Z")).toBe(true); + } 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 }); + } + }, 10_000); + + test("Unix install preserves an existing backup without probing or mutation", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-backup-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-backup-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const backupPath = `${codexPath}.opencodex-real`; + const original = "#!/bin/sh\nprintf '%s\\n' original\n"; + const backup = "#!/bin/sh\nprintf '%s\\n' preserved-backup\n"; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + writeFileSync(backupPath, backup, "utf8"); + chmodSync(codexPath, 0o755); + chmodSync(backupPath, 0o755); + + const installed = installCodexShim(); + + expect(installed).toEqual({ installed: false, message: `Refusing to overwrite existing backup: ${backupPath}` }); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(readFileSync(backupPath, "utf8")).toBe(backup); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } 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 }); + } + }); + + test("Unix install rolls back when the saved launcher fails its version probe", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-failed-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-failed-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const original = "#!/bin/sh\nexit 127\n"; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("saved launcher failed its --version probe"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } 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 }); + } + }); + + test("Unix install rolls back when probe infrastructure throws", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-probe-error-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-probe-error-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const original = successfulLauncher("probe-error-original"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + setCodexShimProbeHookForTests(() => { throw new Error("synthetic probe infrastructure failure"); }); + + expect(() => installCodexShim()).toThrow("synthetic probe infrastructure failure"); + + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimProbeHookForTests(null); + 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("Unix fresh install removes its marker-bearing partial wrapper before rollback", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-partial-write-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-partial-write-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const original = successfulLauncher("partial-write-original"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + setCodexShimFreshWriteHookForTests(() => { + writeFileSync(codexPath, `#!/bin/sh\n# ${SHIM_MARKER}\n`, "utf8"); + throw new Error("synthetic fresh partial write failure"); + }); + + expect(() => installCodexShim()).toThrow("synthetic fresh partial write failure"); + + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimFreshWriteHookForTests(null); + 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("Unix fresh install preserves a concurrent wrapper replacement after a successful probe", () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-concurrent-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-concurrent-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const concurrent = successfulLauncher("fresh concurrent updater replacement"); + const original = `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' '#!/bin/sh' '# fresh concurrent updater replacement' 'exit 0' > "${codexPath}" + chmod 755 "${codexPath}" +fi +exit 0 +`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + + const installed = installCodexShim(); + + expect(installed.installed).toBe(false); + expect(installed.message).toContain("generated wrapper changed during its validation probe"); + expect(readFileSync(codexPath, "utf8")).toBe(concurrent); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } 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 }); + } + }); + + test("Unix shim permits a real Codex process to start a new child invocation", () => { + if (process.platform === "win32") return; + + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-child-")); + const bunPath = join(dir, "bun"); + const realCodexPath = join(dir, "codex.opencodex-real"); + const shimPath = join(dir, "codex"); + try { + writeFileSync(bunPath, "#!/usr/bin/env sh\nexit 0\n", "utf8"); + writeFileSync(realCodexPath, `#!/usr/bin/env sh +if [ -z "$OCX_TEST_CHILD" ]; then + OCX_TEST_CHILD=1 + export OCX_TEST_CHILD + "${shimPath}" --version + exit $? +fi +printf '%s\\n' child-codex +`, "utf8"); + writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/cli.ts", "bundled"), "utf8"); + chmodSync(bunPath, 0o755); + chmodSync(realCodexPath, 0o755); + chmodSync(shimPath, 0o755); + const env = { ...process.env, OCX_SHIM_BYPASS: "1" }; + delete env.OCX_SHIM_ACTIVE_PID; + + const result = spawnSync(shimPath, ["--help"], { + encoding: "utf8", + env, + timeout: 2_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toBe("child-codex\n"); + expect(result.stderr).toBe(""); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("Windows shim uses bypass env var to skip proxy start", () => { const script = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts", "bundled"); expect(script).toContain("OCX_SHIM_BYPASS"); @@ -432,9 +1123,111 @@ describe("Codex autostart shim", () => { }); }); + test("auto-restore upgrades an obsolete Unix shim and validates its saved launcher", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const current = readFileSync(wrappers[0], "utf8"); + const obsolete = obsoleteUnixShim(current); + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + expect(obsolete).not.toBe(current); + writeFileSync(wrappers[0], obsolete, "utf8"); + + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + + expect(result.status).toBe("restored"); + expect(result.message).toContain("Upgraded Codex autostart shim"); + expect(readFileSync(wrappers[0], "utf8")).toContain(UNIX_SHIM_REVISION_MARKER); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: true }); + }); + }); + + test("manual install removes an obsolete Unix shim when its saved launcher recurses", () => { + if (process.platform === "win32") return; + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + const current = readFileSync(wrappers[0], "utf8"); + const obsolete = obsoleteUnixShim(current); + const dynamicLauncher = join(binDir, "obsolete-dynamic-launcher"); + const recursiveLauncher = `#!/bin/sh\nexec "${dynamicLauncher}" "$@"\n`; + writeFileSync(dynamicLauncher, "#!/bin/sh\nexec codex \"$@\"\n", "utf8"); + writeFileSync(backups[0], recursiveLauncher, "utf8"); + writeFileSync(wrappers[0], obsolete, "utf8"); + chmodSync(dynamicLauncher, 0o755); + chmodSync(backups[0], 0o755); + + const result = installCodexShim(); + + expect(result.installed).toBe(false); + expect(result.message).toContain("Removed an obsolete Codex autostart shim"); + expect(result.message).toContain("original launcher was restored"); + expect(readFileSync(wrappers[0], "utf8")).toBe(recursiveLauncher); + expect(existsSync(backups[0])).toBe(false); + expect(existsSync(statePath)).toBe(false); + expect(diagnoseCodexShim()).toMatchObject({ installed: false, healthy: false }); + }); + }); + + test("obsolete Unix shim upgrade rolls back when probe infrastructure throws", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const current = readFileSync(wrappers[0], "utf8"); + const obsolete = obsoleteUnixShim(current); + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], obsolete, "utf8"); + setCodexShimProbeHookForTests(() => { throw new Error("synthetic obsolete upgrade probe failure"); }); + + let failure: unknown; + try { + installCodexShim(); + } catch (error) { + failure = error; + } finally { + setCodexShimProbeHookForTests(null); + } + + expect(failure).toBeInstanceOf(AggregateError); + expect(readFileSync(wrappers[0], "utf8")).toBe(obsolete); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("obsolete Unix shim upgrade preserves a concurrent wrapper replacement", () => { + if (process.platform === "win32") return; + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + const current = readFileSync(wrappers[0], "utf8"); + const obsolete = obsoleteUnixShim(current); + const concurrent = successfulLauncher("obsolete upgrade concurrent replacement"); + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], obsolete, "utf8"); + setCodexShimProbeHookForTests(() => { + writeFileSync(wrappers[0], concurrent, "utf8"); + chmodSync(wrappers[0], 0o755); + }); + + let result!: ReturnType; + try { + result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + } finally { + setCodexShimProbeHookForTests(null); + } + + expect(result.status).toBe("deferred"); + expect("message" in result && result.message).toContain("upgrade deferred because tracked launchers changed"); + expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + expect(readdirSync(binDir).some(name => name.includes(".upgrade-"))).toBe(false); + }); + }); + test("stable shim replacement restores through the shared install transaction", () => { withInstalledShim(({ wrappers, backups }) => { - const replacements = wrappers.map((wrapper, index) => `replacement-${index}\n`); + const replacements = wrappers.map((wrapper, index) => successfulLauncher(`replacement-${index}`)); wrappers.forEach((wrapper, index) => writeFileSync(wrapper, replacements[index], "utf8")); const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); @@ -445,6 +1238,237 @@ describe("Codex autostart shim", () => { }); }); + test("guarded auto-restore rejects a recursive replacement and restores both launcher generations", () => { + if (process.platform === "win32") return; + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + const dynamicLauncher = join(binDir, "dynamic-codex-launcher"); + const replacement = `#!/bin/sh\nexec "${dynamicLauncher}" "$@"\n`; + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(dynamicLauncher, "#!/bin/sh\nexec codex \"$@\"\n", "utf8"); + writeFileSync(wrappers[0], replacement, "utf8"); + chmodSync(dynamicLauncher, 0o755); + chmodSync(wrappers[0], 0o755); + + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + + expect(result.status).toBe("deferred"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("guarded auto-restore rejects a replacement that fails its version probe", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const replacement = "#!/bin/sh\nexit 127\n"; + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + + expect(result.status).toBe("deferred"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("guarded auto-restore rolls back when probe infrastructure throws", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const replacement = successfulLauncher("guarded-probe-error-replacement"); + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + setCodexShimProbeHookForTests(() => { throw new Error("synthetic guarded probe failure"); }); + + let failure: unknown; + try { + autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + } catch (error) { + failure = error; + } finally { + setCodexShimProbeHookForTests(null); + } + + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors.map(error => String(error))).toContain("Error: synthetic guarded probe failure"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("guarded auto-restore removes its unfingerprinted partial wrapper before rollback", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const replacement = successfulLauncher("guarded-partial-write-replacement"); + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + setCodexShimGuardedWriteHookForTests(() => { throw new Error("synthetic failure after wrapper write"); }); + + let failure: unknown; + try { + autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + } catch (error) { + failure = error; + } finally { + setCodexShimGuardedWriteHookForTests(null); + } + + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors.map(error => String(error))).toContain("Error: synthetic failure after wrapper write"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("guarded auto-restore preserves a concurrent wrapper replacement after a successful probe", () => { + if (process.platform === "win32") return; + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + const concurrent = successfulLauncher("concurrent updater replacement"); + const replacement = `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' '#!/bin/sh' '# concurrent updater replacement' 'exit 0' > "${wrappers[0]}" + chmod 755 "${wrappers[0]}" +fi +exit 0 +`; + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + + expect(result.status).toBe("deferred"); + expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + expect(readdirSync(binDir).filter(name => name.includes(".autorestore-"))).toEqual([]); + }); + }); + + test("missing-wrapper repair preserves a concurrent replacement after a successful probe", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const concurrent = successfulLauncher("missing-wrapper concurrent replacement"); + const mutatingBackup = `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' '#!/bin/sh' '# missing-wrapper concurrent replacement' 'exit 0' > "${wrappers[0]}" + chmod 755 "${wrappers[0]}" +fi +exit 0 +`; + const oldState = readFileSync(statePath); + writeFileSync(backups[0], mutatingBackup, "utf8"); + chmodSync(backups[0], 0o755); + rmSync(wrappers[0], { force: true }); + + const result = installCodexShim(); + + expect(result.installed).toBe(false); + expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); + expect(readFileSync(backups[0], "utf8")).toBe(mutatingBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("direct refresh rejects a recursive replacement without replacing the owned backup", () => { + if (process.platform === "win32") return; + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + const dynamicLauncher = join(binDir, "dynamic-codex-launcher"); + const replacement = `#!/bin/sh\nexec "${dynamicLauncher}" "$@"\n`; + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(dynamicLauncher, "#!/bin/sh\nexec codex \"$@\"\n", "utf8"); + writeFileSync(wrappers[0], replacement, "utf8"); + chmodSync(dynamicLauncher, 0o755); + chmodSync(wrappers[0], 0o755); + + const result = installCodexShim(); + + expect(result.installed).toBe(false); + expect(result.message).toContain("Refusing to overwrite existing backup"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("direct refresh rejects a replacement that fails its version probe", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const replacement = "#!/bin/sh\nexit 127\n"; + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + + const result = installCodexShim(); + + expect(result.installed).toBe(false); + expect(result.message).toContain("Refusing to overwrite existing backup"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("direct refresh rolls back when probe infrastructure throws", () => { + if (process.platform === "win32") return; + withInstalledShim(({ wrappers, backups, statePath }) => { + const replacement = successfulLauncher("direct-probe-error-replacement"); + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + setCodexShimProbeHookForTests(() => { throw new Error("synthetic direct probe failure"); }); + + let failure: unknown; + try { + installCodexShim(); + } catch (error) { + failure = error; + } finally { + setCodexShimProbeHookForTests(null); + } + + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors.map(error => String(error))).toContain("Error: synthetic direct probe failure"); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + }); + }); + + test("direct refresh preserves a concurrent wrapper replacement after an unsafe probe", () => { + if (process.platform === "win32") return; + withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + const concurrent = successfulLauncher("concurrent unsafe updater replacement"); + const replacement = `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' '#!/bin/sh' '# concurrent unsafe updater replacement' 'exit 0' > "${wrappers[0]}" + chmod 755 "${wrappers[0]}" +fi +exit 127 +`; + const oldBackup = readFileSync(backups[0]); + const oldState = readFileSync(statePath); + writeFileSync(wrappers[0], replacement, "utf8"); + + const result = installCodexShim(); + + expect(result.installed).toBe(false); + expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); + expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(statePath)).toEqual(oldState); + expect(readdirSync(binDir).filter(name => name.includes(".autorestore-"))).toEqual([]); + }); + }); + 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-")); @@ -453,7 +1477,7 @@ describe("Codex autostart shim", () => { 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 replacement = successfulLauncher("concurrent replacement launcher"); const oldPath = process.env.PATH; const oldHome = process.env.OPENCODEX_HOME; let first: ReturnType | undefined; @@ -470,7 +1494,8 @@ describe("Codex autostart shim", () => { const firstScript = ` import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; - const { autoRestoreCodexShim } = await import(${JSON.stringify(shimModule)}); + const { autoRestoreCodexShim, setCodexShimProbeObservationMsForTests } = await import(${JSON.stringify(shimModule)}); + setCodexShimProbeObservationMsForTests(20); const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {}, @@ -487,7 +1512,8 @@ describe("Codex autostart shim", () => { console.log(JSON.stringify(result)); `; const secondScript = ` - const { autoRestoreCodexShim } = await import(${JSON.stringify(shimModule)}); + const { autoRestoreCodexShim, setCodexShimProbeObservationMsForTests } = await import(${JSON.stringify(shimModule)}); + setCodexShimProbeObservationMsForTests(20); console.log(JSON.stringify(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }))); `; const childEnv = { ...process.env, PATH: binDir, OPENCODEX_HOME: home }; @@ -565,7 +1591,7 @@ describe("Codex autostart shim", () => { 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`); + const replacements = wrappers.map((_, index) => successfulLauncher(`dead-owner-replacement-${index}`)); wrappers.forEach((path, index) => writeFileSync(path, replacements[index], "utf8")); mkdirSync(lockPath); writeFileSync(ownerPath, `${JSON.stringify({ @@ -633,10 +1659,11 @@ describe("Codex autostart shim", () => { test("opt-out set -> no restore and explicit install remains available", () => { withInstalledShim(({ wrappers }) => { - wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `disabled-${index}\n`, "utf8")); + const replacements = wrappers.map((_, index) => successfulLauncher(`disabled-${index}`)); + wrappers.forEach((wrapper, index) => writeFileSync(wrapper, replacements[index], "utf8")); expect(autoRestoreCodexShim({ enabled: () => false, stabilitySleep: skipStabilityWait })).toEqual({ status: "disabled" }); - wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(`disabled-${index}\n`)); + wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(replacements[index])); expect(installCodexShim().installed).toBe(true); wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); });