diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1ab22dd1dc9..cf28f6f9d43 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -8,8 +8,8 @@ description: >- # (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to # 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after # finalizers ran inside an earlier call and spin past its own timeout (#4956). -# Rejoin only after the upstream runner crash is fixed and a green lane=all -# dispatch proves the unified version; then delete testRunnerBun and this role input. +# Both roles now default to the package's 1.4.4 security transition, retaining role validation. +# Promotion requires a green full cross-platform lane=all run on the exact candidate head. inputs: role: description: Bun role to install (runtime or test-runner). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63343e5f7fd..cf702ec812f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -621,12 +621,10 @@ jobs: printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" - # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs - # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). - # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch - # proves the unified version, then delete testRunnerBun and the action's role input. + # Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json. + # Keep role validation after the historical isolate preload and spawnSync failures + # (#6713, #4956). Promotion requires a green full cross-platform lane=all run on + # the exact candidate head; focused local results do not replace that proof. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4aa94f580..bbcd248f8f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for diff --git a/Dockerfile b/Dockerfile index 82fe86f4cf1..54e0153e0f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,12 @@ # Keep the runtime aligned with package.json and pin the multi-platform image index. # Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS -# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3 -# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync -# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the -# upstream crash fix passes a unified-version lane=all dispatch, then delete -# package.json's testRunnerBun pin and setup-project-bun's role input. +# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner +# defaults are both 1.4.4; keep package.json's testRunnerBun pin and +# setup-project-bun's role input independently validated. Promotion requires +# an exact-head cross-platform lane=all dispatch, including multi-file --isolate: +# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0 +# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks. ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d FROM ${BUN_IMAGE} AS manifest diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6f3d29a138b..689ade8bdc6 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -63,6 +63,7 @@ import { CODEX_CLI_TRANSPORT_ENV_KEYS, CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS, codexCliInspectionLookupSeeds, + carriesCodexCliUpdateInspectionContext, isCodexCliUpdateInspectionArgv, } from "../src/update/codex-cli-update-launch-policy.mjs"; import { @@ -1086,6 +1087,10 @@ if (updateHelpRequested) { } const codexCliUpdateInspection = isCodexCliUpdateInspectionArgv(process.argv); +// Windows inspection currently refuses before candidate I/O, so a long-lived +// Windows server does not duplicate PATH into its inherited environment block. +const codexCliUpdateInspectionContext = codexCliUpdateInspection + || (process.platform !== "win32" && carriesCodexCliUpdateInspectionContext(process.argv)); if (codexCliUpdateInspection && typeof process.versions.bun === "string") { console.error("opencodex: codex-cli-update inspection must use the published Node launcher."); process.exit(1); @@ -1217,7 +1222,7 @@ function createNodeLaunchContext() { version: 1, proof: launchProof, anthropicEnvSlots: preBunAnthropicSlots, - codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliInspectionEnv: codexCliUpdateInspectionContext ? { codexCliPath: preBunCodexCliPath, path: preBunPath, pathExt: preBunPathExt, diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831b..d0021b7f12a 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` -The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` by default. If a full run is disproportionately expensive for the task size, available machine resources, or concurrent worktrees, you must at least run focused regression tests diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 2563cdc0de9..015a3e3aafe 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. -`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows x64 prend aussi en charge un `codex.cmd` npm standard absolu lié à la preuve, avec Node/npm locaux au préfixe et une plateforme de support vérifiée dans le préfixe, en utilisant des handles maintenus qui refusent la réanalyse. Les autres dispositions Windows restent différées ou non gérées. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Une installation Windows prise en charge peut produire un plan en lecture seule déterministe avec `applicable: false` et `refusal: windows_apply_disabled` ; la sélection par PATH seul, un Node de préfixe distinct et les wrappers de démarrage automatique OpenCodex restent différés. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/cli/agents/). Sans chemins explicites, la commande observe le candidat sélectionné identifié par l’instantané du lanceur lié à la preuve ; aucune autorité de mise à jour ni sélection du runtime n’est attestée. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 1337a76d0ce..e788fa47bbe 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -287,7 +287,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows x64 prend aussi en charge un `codex.cmd` npm standard absolu lié à la preuve, avec Node/npm locaux au préfixe et une plateforme de support vérifiée dans le préfixe, en utilisant des handles maintenus qui refusent la réanalyse. Les autres dispositions Windows restent différées ou non gérées. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Une installation Windows prise en charge peut produire un plan en lecture seule déterministe avec `applicable: false` et `refusal: windows_apply_disabled` ; la sélection par PATH seul, un Node de préfixe distinct et les wrappers de démarrage automatique OpenCodex restent différés. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection. diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index c766a1cdfaf..4762c9a1fad 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 -`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows x64 は、証明にバインドされた絶対パスの標準 npm `codex.cmd` とプレフィックスローカルの Node/npm、検証済みのインプレフィックスプラットフォームバッキングもサポートし、リパースを拒否する保持ハンドルを使用します。他の Windows レイアウトは引き続き延期または非管理です。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。サポートされている Windows インストールは `applicable: false` と `refusal: windows_apply_disabled` を持つ決定論的な読み取り専用 plan を生成でき、PATH のみの選択・別プレフィックスの Node・OpenCodex 自動起動ラッパーは引き続き延期されます。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows x64 インストールの観測は [`attest` コマンド](/ja/reference/cli/agents/)を参照してください。明示パスを省略すると、証明にバインドされたランチャースナップショットが特定した選択候補を観測します。更新権限やランタイム選択は証明しません。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index ebe43edfffc..fa91fed7720 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -218,7 +218,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows x64 は、証明にバインドされた絶対パスの標準 npm `codex.cmd` とプレフィックスローカルの Node/npm、検証済みのインプレフィックスプラットフォームバッキングもサポートし、リパースを拒否する保持ハンドルを使用します。他の Windows レイアウトは引き続き延期または非管理です。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。サポートされている Windows インストールは `applicable: false` と `refusal: windows_apply_disabled` を持つ決定論的な読み取り専用 plan を生成でき、PATH のみの選択・別プレフィックスの Node・OpenCodex 自動起動ラッパーは引き続き延期されます。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。 diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 3abe8fbd115..9b02fb11fcd 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 절대 표준 npm `codex.cmd`와 프리픽스 로컬 Node/npm, 검증된 인프리픽스 플랫폼 백킹도 지원하며 reparse를 거부하는 보유 핸들을 사용합니다. 다른 Windows 레이아웃은 계속 연기되거나 관리되지 않습니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. 지원되는 Windows 설치는 `applicable: false`와 `refusal: windows_apply_disabled`를 가진 결정적 읽기 전용 plan을 생성할 수 있으며 PATH 전용 선택·분리된 프리픽스 Node·OpenCodex 자동 시작 래퍼는 계속 연기됩니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참조하세요. 명시적 경로 없이 증명에 바인딩된 런처 스냅샷이 식별한 선택 후보를 관측하며, 업데이트 권한이나 런타임 선택을 증명하지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index c8bb7e53731..8fc893a9890 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -256,7 +256,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 절대 표준 npm `codex.cmd`와 프리픽스 로컬 Node/npm, 검증된 인프리픽스 플랫폼 백킹도 지원하며 reparse를 거부하는 보유 핸들을 사용합니다. 다른 Windows 레이아웃은 계속 연기되거나 관리되지 않습니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. 지원되는 Windows 설치는 `applicable: false`와 `refusal: windows_apply_disabled`를 가진 결정적 읽기 전용 plan을 생성할 수 있으며 PATH 전용 선택·분리된 프리픽스 Node·OpenCodex 자동 시작 래퍼는 계속 연기됩니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index d6ba1d76c35..8bc2ef73168 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -152,18 +152,26 @@ manifest can then establish `managed: true` when it proves ownership and, if a c The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report -`candidate_unavailable` on POSIX or `windows_inspection_deferred` on Windows. On Windows this first slice performs no candidate or configuration filesystem I/O: -only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; -every other Windows candidate fails closed. The command does not install or repair software, execute +`candidate_unavailable` on POSIX or `windows_inspection_deferred` on Windows. Windows x64 also supports +a proof-captured absolute standard npm `codex.cmd` with prefix-local Node/npm and a verified in-prefix +platform backing. It uses reparse-refusing held handles; other Windows layouts remain deferred or unmanaged. +The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. `ocx system codex-cli-update plan --json` adds a read-only update decision for an attested POSIX npm-global installation. It resolves `latest` through the official npm registry, validates the exact version and SHA-512 integrity, and refuses active sessions or incomplete process observation. Registry queries use isolated temporary npm configuration, cache and logs with best-effort cleanup. -No software is installed and no process is signalled. Windows returns `windows_inspection_deferred`. +No software is installed and no process is signalled. A supported Windows installation can produce a +deterministic read-only plan with `applicable: false` and `refusal: windows_apply_disabled`. +PATH-only selection, separate-prefix Node and OpenCodex autostart wrappers remain deferred. See [planning details](/reference/cli/agents/#read-only-codex-cli-update-plan). +For a reviewed POSIX non-shim npm-global plan, `ocx system codex-cli-update apply --plan-id --yes` +performs the exact update and returns a durable job. `ocx system codex-cli-update status --json` +reads its result; use a stable `--request-id` for a lost-response retry. See +[confirmation and job states](/reference/cli/agents/#confirmed-posix-codex-cli-apply-and-job-status). + For Windows x64 installation observation, see [the `attest` command](/reference/cli/agents/#explicit-installation-observation-on-windows-x64). Without explicit paths it observes the selected candidate identified from the proof-bound launcher snapshot; it does not grant update authority or attest runtime selection. List or status is the default where unambiguous. Use `--json` only on leaves that support it, and diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 501f13e7bfb..622c59d181c 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1163,9 +1163,9 @@ resolver selects the same canonical environment candidate without executing it. can then establish `managed: true` when it proves ownership and, if a candidate version is reported, that version matches the manifest; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report -`candidate_unavailable` on POSIX. On Windows this first slice performs no candidate or configuration filesystem I/O: -only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; -every other Windows candidate fails closed. Because that slice never consults persisted state, a Windows run +`candidate_unavailable` on POSIX. On Windows x64, a proof-captured absolute standard npm `codex.cmd` +can be managed when held local handles bind its package, prefix-local Node/npm and the first in-prefix +platform backing. Other Windows layouts remain deferred or unmanaged. Windows never consults persisted state, so a run with no captured environment candidate reports `windows_inspection_deferred` rather than `candidate_unavailable`: the command cannot observe whether a Codex CLI is installed, so it reports the deferral instead of asserting that no candidate exists. The command does not execute Codex or a package manager, repair a shim, @@ -1182,18 +1182,55 @@ ocx system codex-cli-update plan --channel latest --json ``` Run through the published Node launcher with the intended `CODEX_CLI_PATH` in the shell environment. -The plan requires an attested POSIX npm-global installation and a linked package manifest version. +The plan requires an attested npm-global installation and a linked package manifest version. +Windows supports only an explicit absolute standard npm `codex.cmd` with prefix-local Node/npm; +PATH-only selection, separate-prefix Node, OpenCodex autostart wrappers and global/vendor fallback stay deferred. It queries the official npm registry for an exact version, SHA-512 integrity and same-origin tarball, using the launcher's captured PATH and isolated temporary npm configuration, cache and logs. Proxy settings and `NODE_EXTRA_CA_CERTS` remain supported; cleanup is best-effort. No Codex command runs. An applicable plan reports `applicable: true` and a deterministic `planId` binding platform, -installation-root digest, installed version, channel and target version/integrity. The plan is never +installation-root/content digests, installed version, channel and exact main/platform artifacts. The plan is never persisted. It refuses downgrades, already-current versions, active Codex sessions and incomplete -process observation. Windows returns `windows_inspection_deferred` before registry/process queries. +process observation. Supported Windows metadata queries use the observed Node/npm under matching +held handles. A completed Windows read-only plan reports a deterministic ID, `applicable: false` +and `refusal: windows_apply_disabled`; active/unknown sessions still refuse earlier. Unsupported +Windows layouts return `windows_inspection_deferred` before registry/process queries. A refusal exits 0: inspect `applicable` and `refusal`. The printed install argv is indicative; this -command installs nothing, changes no application state and never signals a session. No apply command -is exposed, and a plan ID is not installation authorization. +command installs nothing, changes no application state and never signals a session. Windows apply +remains disabled, and a plan ID alone is not installation authorization: apply requires separate explicit confirmation. + +#### Confirmed POSIX Codex CLI apply and job status + +```bash +ocx system codex-cli-update apply --plan-id <32-hex> --yes --request-id --json +ocx system codex-cli-update status --json +``` + +Apply supports only the selected, managed, non-shim npm-global CLI on Linux/macOS. It revalidates the +approved plan under an installation lease, verifies the exact main/platform archives, installs them +offline with lifecycle scripts disabled, and checks installed files and the selected CLI's version. +Existing Codex sessions are never stopped; active or unknown process observations refuse the update. +Windows apply, shim repair, native apps and other installation managers remain unsupported. + +Apply exits 0 only for an `applied` job; `not_applied`, `refused`, `ambiguous` and pending duplicate jobs +exit 1, including with `--json`. Status reads the +same private job record without a running proxy or GUI token. Reuse the same optional request ID after +a lost response: the same key and plan return the original job; a changed plan with that key refuses. +The private ledger retains up to 64 jobs, including finished jobs, without automatic pruning. At that +limit, new request IDs are refused with `store_full`; existing matching retries still return their +original job. Capacity and operator recovery remain a separate follow-up that must preserve this +deduplication contract. A busy admission ledger reports `job_store_busy` (HTTP 503 with `Retry-After: 1`); +permanent admission failures have no retry-delay header. Missing or unreadable CLI +status returns `job_unavailable` and a nonzero exit. Completion persistence failures do not rerun the +installer or turn an uncertain job into a confirmed success. +An ambiguous result is never automatically retried or rolled back. After an owner restart, an unfinished +job reports unknown ownership instead of claiming the old process stopped. Inspect the job's outcome, +child/lease state and cleanup errors; command exit status alone does not establish an installation result. + +The management API provides authenticated plan/status reads and confirmed trusted-loopback GUI-session +apply admission. A published Node `ocx start` supplies the required inspection snapshot; direct Bun or +compiled sidecar startup does not provide that proof. The Dashboard update panel is a separate follow-up. #### Explicit installation observation on Windows x64 @@ -1206,7 +1243,7 @@ ocx system codex-cli-update attest --candidate --npm-prefix = { "policy-worker": join(repoRoot, "src", "storage", "policy-worker.ts"), "restore-worker": join(repoRoot, "src", "storage", "restore-worker.ts"), "history-worker": join(repoRoot, "src", "codex", "history-worker.ts"), + "cli-update-worker": join(repoRoot, "src", "codex", "cli-update-worker.ts"), "request-history-rebuild-worker": join(repoRoot, "src", "routing", "history", "rebuild-worker.ts"), "windows-native-principal-worker": join(repoRoot, "src", "lib", "windows-native-principal-worker.ts"), }; diff --git a/scripts/ci/test-durations.tsv b/scripts/ci/test-durations.tsv index 7d97845ae3d..3ba1b2c3c83 100644 --- a/scripts/ci/test-durations.tsv +++ b/scripts/ci/test-durations.tsv @@ -1,7 +1,7 @@ # Per-file Bun test durations in milliseconds, read from hosted CI job logs. # Consumed by scripts/ci/run-bun-test-batches.sh to balance shards; a file without a row weighs the median. # Regenerate with scripts/ci/test-durations.ts (usage in its header); do not edit by hand. -# Source: run 38250055508 (dev dc45bb2ca9, lane=all), Linux test 1/4-4/4 and macos 1/3-3/3 +# Source: run 38250055508 (dev dc45bb2ca9, lane=all), Linux test 1/4-4/4 and macos 1/3-3/3; selected row tests/codex-integration/codex-cli-update-apply.test.ts from run 40084641010 @700dc1d8bfe3 job 120850286088 (test 2/4, Bun 1.4.4); other rows retained 271 tests/adapters/abort-race.test.ts 643 tests/adapters/adapter-buffered-tool-conformance.test.ts 270 tests/adapters/adapter-error-inline.test.ts @@ -693,6 +693,7 @@ 169 tests/codex-integration/codex-cli-install-provenance.test.ts 100 tests/codex-integration/codex-cli-installation-identity.test.ts 127 tests/codex-integration/codex-cli-installation-targets.test.ts +94979 tests/codex-integration/codex-cli-update-apply.test.ts 104 tests/codex-integration/codex-cli-update-launcher-policy.test.ts 1197 tests/codex-integration/codex-cli-update-zero-effect.test.ts 92 tests/codex-integration/codex-cli-windows-installation-files.test.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 51022965cf2..1d9c3b276fc 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,9 @@ { + "codex-cli-windows-child.test.ts": "codex-integration", + "codex-cli-windows-plan.test.ts": "codex-integration", + "codex-cli-update-artifacts.test.ts": "codex-integration", + "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-worker.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", "codex-account-slot-identity.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 48362d5a839..2c6cab6bca0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -30,7 +30,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [accounts](01_surface_accounts.md) | 42 | | [agents-routing](01_surface_agents-routing.md) | 67 | | [integrations](01_surface_integrations.md) | 41 | -| [observe-system](01_surface_observe-system.md) | 96 | +| [observe-system](01_surface_observe-system.md) | 98 | | [access-remote](01_surface_access-remote.md) | 33 | | [lab](01_surface_lab.md) | 21 | @@ -279,6 +279,14 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-system-codex-cli-update-plan) +### `ocx system codex-cli-update apply` + +[State-changing task](01_surface_observe-system.md#ocx-system-codex-cli-update-apply) + +### `ocx system codex-cli-update status` + +[Read-oriented task](01_surface_observe-system.md#ocx-system-codex-cli-update-status) + ### `ocx system codex-restart` [State-changing task](01_surface_observe-system.md#ocx-system-codex-restart) @@ -1501,6 +1509,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 363 -- of those, state-changing: 222 +- declared capabilities: 365 +- of those, state-changing: 223 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 7407f5bb74d..d3cea04a9b8 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 96. +Declared capabilities: 98. ### `ocx companion` @@ -392,12 +392,53 @@ Drives no management route. | `--channel` | string | Registry channel to resolve. Only the stable latest channel is offered. | | `--json` | boolean | Emit the plan as JSON. | +- Local counterpart of GET /api/codex/cli-update/plan: runs the same proof-bound plan builder without HTTP transport. - Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision. - The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer. - Installs nothing and mutates no application state; registry evidence is gathered under an isolated temporary npm root (npmrc, cwd, cache and logs) removed best-effort afterwards. A refusal is a normal dry-run answer and still exits 0. -- The plan id is a digest of the evidence the decision rests on — ownership, the installed version, a digest of the canonical install root, the resolved target — not a stored job. There is no plan state on disk to expire, collide or clean up. +- The plan id is a digest of the evidence; apply re-observes it before any mutation. +- Apply durably admits a bounded, same-user job before running; request IDs deduplicate retries and a conflicting payload is rejected. Unresolved records are retained rather than expired. - An unreadable process table refuses rather than reading as no live session. +### `ocx system codex-cli-update apply` + +Usage: `ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]` + +Apply a confirmed Codex CLI update as a durable job. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--plan-id` | string | Required 32-hex id from the current plan. | +| `--yes` | boolean | Required confirmation. | +| `--request-id` | string | Optional stable retry key. | +| `--json` | boolean | Emit the public job status. | + +- Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session. +- Admission is stored under the protected current-user config root before the installer can run. +- Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json. +- Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused. + +### `ocx system codex-cli-update status` + +Usage: `ocx system codex-cli-update status [--json]` + +Read a Codex CLI update job by opaque id. + +State-changing: no. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the path-free job status. | + +- Local counterpart of GET /api/codex/cli-update/jobs/{jobId}: reads the same path-free job status without HTTP transport. +- Reads the protected job record directly; the job id is not an authorization credential. + ### `ocx system codex-restart` Usage: `ocx system codex-restart --yes [--json]` diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 3bda7c1e295..3b91b4dc5df 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -602,14 +602,31 @@ export const CAPABILITIES: readonly Capability[] = [ mutates: false, json: "envelope", details: [ + "Local counterpart of GET /api/codex/cli-update/plan: runs the same proof-bound plan builder without HTTP transport.", "Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision.", "The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer.", "Installs nothing and mutates no application state; registry evidence is gathered under an isolated temporary npm root (npmrc, cwd, cache and logs) removed best-effort afterwards. A refusal is a normal dry-run answer and still exits 0.", - "The plan id is a digest of the evidence the decision rests on — ownership, the installed version, a digest of the canonical install root, the resolved target — not a stored job. There is no plan state on disk to expire, collide or clean up.", + "The plan id is a digest of the evidence; apply re-observes it before any mutation.", + "Apply durably admits a bounded, same-user job before running; request IDs deduplicate retries and a conflicting payload is rejected. Unresolved records are retained rather than expired.", "An unreadable process table refuses rather than reading as no live session.", ], usage: "ocx system codex-cli-update plan [--channel latest] [--json]", }, + { + command: ["system", "codex-cli-update", "apply"], + summary: "Apply a confirmed Codex CLI update as a durable job.", + routes: [], + flags: [{ name: "--plan-id", value: "string", summary: "Required 32-hex id from the current plan." }, { name: "--yes", value: "boolean", summary: "Required confirmation." }, { name: "--request-id", value: "string", summary: "Optional stable retry key." }, { name: "--json", value: "boolean", summary: "Emit the public job status." }], + mutates: true, json: "envelope", + details: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session.", "Admission is stored under the protected current-user config root before the installer can run.", "Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], + usage: "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", + }, + { + command: ["system", "codex-cli-update", "status"], + summary: "Read a Codex CLI update job by opaque id.", routes: [], flags: [{ name: "--json", value: "boolean", summary: "Emit the path-free job status." }], + mutates: false, json: "envelope", details: ["Local counterpart of GET /api/codex/cli-update/jobs/{jobId}: reads the same path-free job status without HTTP transport.", "Reads the protected job record directly; the job id is not an authorization credential."], + usage: "ocx system codex-cli-update status [--json]", + }, { command: ["system","codex-restart"], summary: "Restart the Codex app-servers, and the desktop app when its model picker predates the catalog.", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 11bb7b95f46..fed27c06ea4 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -1,6 +1,5 @@ import { inspectCodexCliInstall, - observeCodexCliSelectedCommand, type CodexCliInstallProvenanceDeps, type CodexCliInstallReport, } from "../codex/cli-install-provenance"; @@ -13,30 +12,41 @@ import type { CodexCliInstallationTargetDerivation, } from "../codex/cli-installation-targets"; import { - createCodexCliUpdatePlan, type CodexCliUpdateChannel, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, } from "../codex/cli-update-plan"; +import { runCodexCliUpdateApplyInWorker, runCodexCliUpdatePlanInWorker } from "../codex/cli-update-job-worker"; import { type ResolveCodexRuntimeDeps, type ResolveCodexRuntimeResult, } from "../codex/runtime"; import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; +import { codexCliUpdateInspectionDeps } from "../codex/cli-update-inspection-context"; +export { codexCliUpdateInspectionDeps } from "../codex/cli-update-inspection-context"; import { trustedNodeLauncherContext } from "./launcher-context"; +import type { CodexCliApplyDeps, CodexCliApplyResult } from "../codex/cli-update-apply"; +import { randomUUID } from "node:crypto"; +import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../codex/cli-update-job"; +import { CodexCliJobResultPersistenceError, normalizeCodexCliJobResult } from "../codex/cli-update-job-execution"; export const CODEX_CLI_UPDATE_USAGE = `Usage: ocx system codex-cli-update check [--json] ocx system codex-cli-update attest [--json] ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json] - ocx system codex-cli-update plan [--channel latest] [--json]`; + ocx system codex-cli-update plan [--channel latest] [--json] + ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json] + ocx system codex-cli-update status [--json]`; export type ParsedCodexCliUpdateArgs = | Readonly<{ action: "check"; json: boolean }> | Readonly<{ action: "plan"; json: boolean; channel: CodexCliUpdateChannel }> + | Readonly<{ action: "apply"; json: boolean; planId: string; requestId: string }> + | Readonly<{ action: "status"; json: boolean; jobId: string }> | Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>; export interface CodexCliUpdateCommandDeps { + readonly readJob?: typeof readCodexCliUpdateJob; readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise; readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise; readonly deriveInstallationInput?: ( @@ -44,6 +54,7 @@ export interface CodexCliUpdateCommandDeps { ) => Promise; readonly createPlan?: (deps: CodexCliUpdatePlanDeps) => Promise; readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; + readonly applyUpdate?: (planId: string, deps: CodexCliApplyDeps) => Promise; } function identitySummary(report: CodexCliInstallationIdentityReport): string[] { @@ -93,7 +104,7 @@ function planSummary(plan: CodexCliUpdatePlan): string[] { `session: ${plan.session.state}${plan.session.matches === null ? "" : ` (${plan.session.matches})`}`, ]; if (plan.planId) lines.push(`plan: ${plan.planId}`); - if (plan.command) lines.push(`command: ${plan.command.join(" ")} (indicative — a future apply phase verifies the packed tarball and installs the verified file)`); + if (plan.command) lines.push(`command: ${plan.command.join(" ")} (indicative — apply verifies both packed archives and installs their local files)`); return lines; } @@ -161,8 +172,44 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli }) }); } const action = rest[0]; - if (action !== "check" && action !== "plan") { - throw new CliUsageError("codex-cli-update action must be check, attest or plan", CODEX_CLI_UPDATE_USAGE); + if (action !== "check" && action !== "plan" && action !== "apply" && action !== "status") { + throw new CliUsageError("codex-cli-update action must be check, attest, plan, apply or status", CODEX_CLI_UPDATE_USAGE); + } + + if (action === "apply") { + let planId: string | undefined; + let requestId: string = randomUUID(); + let seenRequestId = false; + let yes = false; + for (let index = 1; index < rest.length;) { + const token = rest[index]!; + if (token === "--yes" && !yes) { yes = true; index++; continue; } + if (isOption(token, "plan-id") && planId === undefined) { + const value = optionValue(rest, index, "plan-id"); + planId = value.value; + index = value.next; + continue; + } + if (isOption(token, "request-id") && !seenRequestId) { + const value = optionValue(rest, index, "request-id"); + requestId = value.value; seenRequestId = true; index = value.next; continue; + } + throw new CliUsageError("unsupported or duplicate apply option", CODEX_CLI_UPDATE_USAGE); + } + if (!yes || !planId || !/^[0-9a-f]{32}$/.test(planId)) { + throw new CliUsageError("apply requires --plan-id <32 lowercase hex> and --yes", CODEX_CLI_UPDATE_USAGE); + } + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(requestId)) { + throw new CliUsageError("--request-id must be 8-128 safe characters", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ action, json, planId, requestId }); + } + + if (action === "status") { + if (rest.length !== 2 || !/^[0-9a-f-]{36}$/.test(rest[1]!)) { + throw new CliUsageError("status requires one job id", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ action, json, jobId: rest[1]! }); } if (action === "check") { @@ -203,44 +250,6 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli * evidence, not selected-runtime admission. A direct Bun or source launch has no such * proof, so nothing ambient or persisted is inspected at all. */ -function inspectionDeps(commandDeps: CodexCliUpdateCommandDeps): CodexCliInstallProvenanceDeps { - const trusted = trustedNodeLauncherContext()?.codexCliInspectionEnv; - if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; - // Attestation also requires the runtime resolver to pick the snapshot's - // candidate. probeVersion: false keeps this read-only — no codex --version - // executes on a check/plan path — while still refusing attestation when the - // resolver would settle on a different executable. - const selectedCommand = observeCodexCliSelectedCommand({ - env: { - CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, - PATH: trusted.path ?? undefined, - PATHEXT: trusted.pathExt ?? undefined, - }, - configDir: trusted.configDir, - }, commandDeps.resolveSelectedRuntime); - return { - env: { - ...trusted.managerRoots, - ...trusted.transportEnv, - CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, - PATH: trusted.path ?? undefined, - PATHEXT: trusted.pathExt ?? undefined, - // The lookup seeds the inspection models against the launch-time - // capture; ambient values are never consulted. - HOME: trusted.home ?? undefined, - NODE_PATH: trusted.nodePath ?? undefined, - }, - configDir: trusted.configDir, - // Content observation picks the platform backing by the Node launcher's - // arch, which can differ from this Bun process's under Rosetta. - arch: trusted.arch ?? undefined, - // The snapshot is proof-bound to the launcher, but the report may only attest - // the environment candidate when the resolver picks the same canonical path — - // managed requires both. - selectionAttested: true, - selectedCommand, - }; -} export async function handleCodexCliUpdateCommand( argv: readonly string[], @@ -259,6 +268,11 @@ export async function handleCodexCliUpdateCommand( } // A refusal or an unapplied update is a legitimate answer rather than a crash, so the // outcome exit code is decided here and only a thrown error is left to runCliAction. + let resultCode = 0; + const unavailableJob = () => { + printData({ error: "job_unavailable" }, parsed.json, ["Codex CLI update job was not found or is unavailable."]); + resultCode = 1; + }; const code = await runCliAction(async () => { if ("attest" in parsed) { let report: CodexCliInstallationIdentityReport; @@ -291,19 +305,59 @@ export async function handleCodexCliUpdateCommand( return; } if (parsed.action === "check") { - const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps(deps)); + const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(codexCliUpdateInspectionDeps(deps)); printData(report, parsed.json, installSummary(report)); return; } + if (parsed.action === "status") { + let job: ReturnType; + try { job = (deps.readJob ?? readCodexCliUpdateJob)(parsed.jobId); } + catch { unavailableJob(); return; } + if (!job) { unavailableJob(); return; } + printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, + `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); + return; + } if (parsed.action === "plan") { - const plan = await (deps.createPlan ?? createCodexCliUpdatePlan)({ + const plan = await (deps.createPlan ?? ((planDeps: CodexCliUpdatePlanDeps) => + runCodexCliUpdatePlanInWorker({ channel: planDeps.channel })))({ channel: parsed.channel, - inspectionDeps: inspectionDeps(deps), + inspectionDeps: codexCliUpdateInspectionDeps(deps), inspect: deps.inspectInstall, }); printData(plan, parsed.json, planSummary(plan)); return; } + if (parsed.action === "apply") { + const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId, confirmed: true }); + const apply = deps.applyUpdate + ?? ((planId: string) => runCodexCliUpdateApplyInWorker(planId, { jobId: admitted.job.jobId })); + let job: Awaited>; + try { + job = admitted.kind === "accepted" + ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) + : admitted.job; + } catch (error) { + if (!(error instanceof CodexCliJobResultPersistenceError)) throw error; + // Preserve the computed outcome without claiming durable completion or + // retrying work. Project fixed fields, never the raw result/error object. + const safe = normalizeCodexCliJobResult(error.result); + const receipt = { error: "job_result_persistence_failed", jobId: admitted.job.jobId, + outcome: safe.outcome, reason: safe.reason, leaseRetained: safe.leaseRetained, + cleanupErrors: safe.cleanupErrors }; + printData(receipt, parsed.json, [`job: ${receipt.jobId}`, + "persistence: unverified (job_result_persistence_failed)", `outcome: ${receipt.outcome}`, + `reason: ${receipt.reason}`, `lease-retained: ${receipt.leaseRetained}`, + `cleanup-errors: ${receipt.cleanupErrors.join(", ") || "none"}`]); + resultCode = 1; + return; + } + if (!job) { unavailableJob(); return; } + printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, + `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); + resultCode = job.status === "applied" ? 0 : 1; + } }); - return code; + return code || resultCode; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 2544078424b..d4b90290831 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -547,7 +547,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "system", usage: "ocx system ...", - summary: "Manage headless runtime settings, health, startup, sync, diagnostics, OpenCodex updates, and read-only Codex CLI inspection and planning.", + summary: "Manage headless runtime settings, health, startup, sync, diagnostics, OpenCodex updates, and Codex CLI update plans and jobs.", details: [ "system update manages OpenCodex itself.", "system health reads authenticated service and spend-ledger diagnostics; root health remains liveness.", @@ -555,6 +555,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "ocx system codex-cli-update attest [--json]", "ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json]", "ocx system codex-cli-update plan [--channel latest] [--json]", + "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", + "ocx system codex-cli-update status [--json]", "check and attest make no package-registry request, do not execute Codex or npm, and write nothing. plan adds a registry query and a process-table read and mutates no application state (temporary isolated npm files only, removed best-effort).", ], }, diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index c109e93d8bd..def126a39a3 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -29,6 +29,8 @@ const USAGE = `Usage: ocx system codex-cli-update attest [--json] ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json] ocx system codex-cli-update plan [--channel latest] [--json] + ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json] + ocx system codex-cli-update status [--json] ocx system update check [--channel ] [--json] ocx system update run [--channel ] [--restart ] --yes [--json] ocx system update status [--json] diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index b0757976d57..31b1664fc24 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -395,7 +395,6 @@ function listUnixProcSnapshots(uid: number | undefined, requireComplete = true): } function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { - const out: ProcessSnapshot[] = []; const commandOutput = uid !== undefined ? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, @@ -410,6 +409,28 @@ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { : execFileSync("/bin/ps", ["-axo", "pid=,comm="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, }); + return parseDarwinSnapshotOutputs(commandOutput, executableOutput, uid); +} + +/** + * Request-path twin of {@link listDarwinSnapshots}: the two `ps` children run off the + * event loop, so a stalled process-table read cannot park the proxy. Failure still + * propagates as a rejected read, matching the synchronous contract. + */ +async function listDarwinSnapshotsAsync(uid: number | undefined): Promise { + const [commandOutput, executableOutput] = await Promise.all([ + uid !== undefined + ? execFileTextAsync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], 5_000) + : execFileTextAsync("/bin/ps", ["-axo", "pid=,uid=,command="], 5_000), + uid !== undefined + ? execFileTextAsync("/bin/ps", ["-u", String(uid), "-o", "pid=,comm="], 5_000) + : execFileTextAsync("/bin/ps", ["-axo", "pid=,comm="], 5_000), + ]); + return parseDarwinSnapshotOutputs(commandOutput, executableOutput, uid); +} + +function parseDarwinSnapshotOutputs(commandOutput: string, executableOutput: string, uid: number | undefined): ProcessSnapshot[] { + const out: ProcessSnapshot[] = []; const executableByPid = new Map(); for (const raw of executableOutput.split(/\r?\n/)) { const match = /^\s*(\d+)\s+(.+)$/.exec(raw); @@ -552,6 +573,22 @@ function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | return listUnixProcSnapshots(getuid(), requireComplete); } +/** + * Request-path twin of {@link defaultListSnapshots}: the child-process enumerations + * (Windows PowerShell, darwin ps) wait off the event loop. Linux /proc reads are + * bounded filesystem I/O rather than a subprocess deadline, so they stay synchronous + * underneath without reintroducing a multi-second park. + */ +async function defaultListSnapshotsAsync( + platform: NodeJS.Platform, + getuid: () => number | undefined, + requireComplete = true, +): Promise { + if (platform === "win32") return listWindowsSnapshotsAsync(); + if (platform === "darwin") return listDarwinSnapshotsAsync(getuid()); + return listUnixProcSnapshots(getuid(), requireComplete); +} + export interface ListProcessSnapshotsOptions { platform?: NodeJS.Platform; getuid?: () => number | undefined; @@ -639,6 +676,43 @@ function scanProcessSnapshots( } catch { return Object.freeze({ kind: "unavailable" as const }); } + return matchProcessSnapshots(snapshots, matches); +} + +/** + * Request-path twin of {@link scanProcessSnapshots}: an injected async source, or the + * real enumeration's child processes, resolve off the event loop. The fail-closed + * contract is unchanged — an unreadable process table is still `unavailable`. + */ +async function scanProcessSnapshotsAsync( + io: CodexAppServerProcessIo, + matches: (commandLine: string, executable?: string) => boolean, +): Promise { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + let snapshots: ProcessSnapshot[]; + try { + snapshots = io.listSnapshotsAsync + ? await io.listSnapshotsAsync() + : io.listSnapshots + ? io.listSnapshots() + : await defaultListSnapshotsAsync(platform, getuid); + } catch { + return Object.freeze({ kind: "unavailable" as const }); + } + return matchProcessSnapshots(snapshots, matches); +} + +function matchProcessSnapshots( + snapshots: readonly ProcessSnapshot[], + matches: (commandLine: string, executable?: string) => boolean, +): CodexAppServerProcessScan { const seen = new Set(); const matched: CodexAppServerProcess[] = []; for (const snapshot of snapshots) { @@ -671,6 +745,15 @@ export function scanCodexSessionProcesses(io: CodexAppServerProcessIo = {}): Cod return scanProcessSnapshots({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); } +/** + * Async twin of {@link scanCodexSessionProcesses} for request paths: planning and the + * apply re-scan must not park the proxy event loop on a process-table child wait. + * Same broader read-only scope, same fail-closed contract. + */ +export function scanCodexSessionProcessesAsync(io: CodexAppServerProcessIo = {}): Promise { + return scanProcessSnapshotsAsync({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); +} + export function formatStaleCodexAppServerWarning( processes: readonly { pid: number }[], ): string { diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index 81822401ec3..997aa3ccb2f 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -15,6 +15,8 @@ import { posix, win32 } from "node:path"; import { getConfigDir } from "../config"; import { parseStrictSemver } from "../lib/strict-semver"; import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs"; +import { MAX_NATIVE_BACKING_BYTES } from "./cli-installation-limits"; +export { MAX_NATIVE_BACKING_BYTES } from "./cli-installation-limits"; import { isSpawnableCodexCandidate } from "./exec-invocation"; import { codexRuntimeStatePath, @@ -36,15 +38,6 @@ const MAX_RUNTIME_STATE_BYTES = 256 * 1024; const MAX_MANIFEST_ANCESTORS = 12; /** The published package entrypoint is a few KB; 1 MiB leaves wide headroom. */ const MAX_PACKAGE_ENTRYPOINT_BYTES = 1024 * 1024; -/** - * The native companion binary is streamed in HELD_READ_CHUNK_BYTES chunks and - * never buffered whole, so this bound limits read time, not memory. Published - * backings outgrew the former 256 MiB: `@openai/codex@0.163.0-linux-x64` ships a - * 296,865,480-byte binary, in platform packages of 341-430 MB unpacked. 1 GiB - * leaves more than 3x headroom over that binary while staying finite; larger - * files are refused by fstat before any read. - */ -export const MAX_NATIVE_BACKING_BYTES = 1024 * 1024 * 1024; const HELD_READ_CHUNK_BYTES = 1024 * 1024; export type CodexCliInstallKind = @@ -128,6 +121,26 @@ export interface CodexCliInstallReport { readonly evidence: readonly CodexCliInstallEvidence[]; } +/** Process-local evidence; deliberately absent from the public/redacted report. */ +export interface CodexCliMutationEvidence { + readonly prefix: string; + readonly launcher: string; + readonly canonicalLauncher: string; + readonly packageRoot: string; + readonly backingRoot: string | null; +} +const mutationEvidence = new WeakMap(); +export function codexCliMutationEvidence(report: CodexCliInstallReport): CodexCliMutationEvidence | null { + return mutationEvidence.get(report) ?? null; +} +/** Final synchronous selector/link observation after a potentially long full-file readback. */ +export function codexCliMutationSelectionMatches(evidence: CodexCliMutationEvidence, deps: CodexCliInstallProvenanceDeps): boolean { + const selected = observeCodexCliSelectedCommand(deps); + const path = selected ? resolveCandidateCommandPath(selected, deps) : null; + return !!path && canonicalize(path, deps) === evidence.canonicalLauncher + && canonicalize(evidence.launcher, deps) === evidence.canonicalLauncher; +} + export interface CodexCliInstallProvenanceDeps { readonly env?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; @@ -414,6 +427,7 @@ export function isAppBundledCodexPath(path: string, platform: NodeJS.Platform): if (platform === "win32") { return normalized.includes("/windowsapps/") || normalized.includes("/microsoft/windowsapps/") + || normalized.includes("/openai/codex/bin/") || normalized.includes("/packages/openai.codex_"); } if (platform === "darwin") return /[.]app\/contents\//i.test(normalized); @@ -700,6 +714,9 @@ const CODEX_PLATFORM_BACKING: Readonly void, ): string | null { const arch = deps.arch ?? process.arch; const backing = CODEX_PLATFORM_BACKING[platform + ":" + arch]; @@ -1022,15 +1047,10 @@ function observeInstallContent( return null; } } - const record = { - layout: "npm-global" as const, - platform, - arch, - triple: backing.triple, - backingKind: backingManifestPath ? "optional-package" as const : "package-vendor" as const, - files: requests.map(req => ({ role: req.role, digest: observed.get(req.role)?.digest })), - }; - return sha256("codex-cli-install-content-v1", JSON.stringify(record)); + observedBacking?.(backingPackageDir); + return codexCliContentSetDigest(platform, arch, backing.triple, + backingManifestPath ? "optional-package" : "package-vendor", + requests.map(req => ({ role: req.role, digest: observed.get(req.role)?.digest }))); } /** Inspect ownership of one configured Codex CLI candidate, without mutation. */ @@ -1053,11 +1073,9 @@ export async function inspectCodexCliInstall( const env = deps.env ?? process.env; if (platform === "win32") { - // This first slice never opens a candidate-controlled Windows pathname. - // A boolean precheck followed by lstat/realpath/open is raceable when an - // ancestor can be replaced with a remote reparse point. Preserve only - // lexical, report-only classifications until a handle-bound inspector is - // introduced; every result remains unattested and unmanaged. + // Lexical exclusions precede the restricted handle-bound plan predicate. + // Other Windows layouts remain observation-only; pathname prechecks alone + // cannot prove a writable ancestor stayed local and non-reparse. const lexicalCandidatePath = win32.isAbsolute(candidate.command) && isLocalAbsoluteInspectionPath(candidate.command, platform) ? win32.normalize(candidate.command) @@ -1090,9 +1108,12 @@ export async function inspectCodexCliInstall( evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]), }); } - return unknownWindowsReport("windows_inspection_deferred", candidate, { + const deferred = unknownWindowsReport("windows_inspection_deferred", candidate, { location: publicExecutableLocation(lexicalCandidatePath, platform), }); + return (await import("./cli-windows-plan-provenance")).inspectWindowsCodexCliPlanInstall(deps, deferred, + path => isAppBundledCodexPath(path, platform) || isCodexCliUpdateVersionManagerPath(path, platform) + || isWithinConfiguredVersionManagerRoot(path, managerRoots, platform)); } const configDir = deps.configDir ?? getConfigDir(); if (!isSafeLocalInspectionPath(configDir, deps)) { @@ -1227,6 +1248,7 @@ export async function inspectCodexCliInstall( const npmPrefix = manifestOwned ? manifest.root.slice(0, manifest.root.length - "/lib/node_modules/@openai/codex".length) : ""; + let backingRoot: string | null = null; const installContentDigest = manifestOwned && deps.observeInstallContent === true // The selection's own lexical->canonical link is part of the claim: // verified again at the end of the observation so a retargeted launcher @@ -1234,14 +1256,14 @@ export async function inspectCodexCliInstall( ? observeInstallContent(manifest, npmPrefix, platform, env, deps, { lexical: ownershipPath, canonical: canonicalOwnershipPath, - }) + }, root => { backingRoot = root; }) : null; const versionMatches = candidate.version === null || candidate.version === manifest.version; const reason: CodexCliInstallReason = !global || !linked ? "npm_global_unverified" : !versionMatches ? "version_mismatch" : selectionAttested ? "managed_npm_global" : "selection_unattested"; - return freezeReport({ + const report = freezeReport({ schemaVersion: 1, candidateAvailable: true, candidateVersion: candidate.version, @@ -1272,6 +1294,11 @@ export async function inspectCodexCliInstall( ...(installContentDigest ? ["install_content_digest" as const] : []), ]), }); + if (report.managed && report.installContentDigest && shim.status === "not-tracked") { + mutationEvidence.set(report, Object.freeze({ prefix: npmPrefix, launcher: ownershipPath, + canonicalLauncher: canonicalOwnershipPath, packageRoot: manifest.root, backingRoot })); + } + return report; } const stat = deps.stat ?? statSync; diff --git a/src/codex/cli-installation-identity.ts b/src/codex/cli-installation-identity.ts index da06ad91738..2c9dcd6ac08 100644 --- a/src/codex/cli-installation-identity.ts +++ b/src/codex/cli-installation-identity.ts @@ -113,7 +113,7 @@ function manifest(file: ObservedInstallationFile, name: string, bin: string, exp } /** Only the standard npm cmd-shim is linked here; arbitrary wrappers remain unverified. */ -function npmCodexCommandShim(bytes: Uint8Array): boolean { +export function npmCodexCommandShim(bytes: Uint8Array): boolean { const expected = `@ECHO off GOTO start :find_dp0 diff --git a/src/codex/cli-installation-limits.ts b/src/codex/cli-installation-limits.ts new file mode 100644 index 00000000000..60b0868e3de --- /dev/null +++ b/src/codex/cli-installation-limits.ts @@ -0,0 +1,7 @@ +/** + * Shared finite observation bound, independent of platform/native API modules. + * Published npm backings exceed 256 MiB: Linux 0.163.0 is 296,865,480 bytes + * and Windows 0.153.4 is 295,408,944 bytes. Both readers hash native files in + * bounded chunks; 1 GiB limits read time without allocating the whole file. + */ +export const MAX_NATIVE_BACKING_BYTES = 1024 * 1024 * 1024; diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts new file mode 100644 index 00000000000..f6497bace2a --- /dev/null +++ b/src/codex/cli-update-apply.ts @@ -0,0 +1,342 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, + realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectCodexCliInstall, observeCodexCliSelectedCommand, codexCliMutationEvidence, codexCliMutationSelectionMatches, + type CodexCliInstallProvenanceDeps, type CodexCliInstallReport } from "./cli-install-provenance"; +import { createCodexCliUpdatePlan, createNpmConfigIsolation, isolatedNpmTarget, + type CodexCliUpdatePlanDeps } from "./cli-update-plan"; +import { codexCliPlatformBacking } from "./cli-install-provenance"; +import { verifyCodexCliArchive, + verifiedCodexCliContent, type CodexCliArtifact } from "./cli-update-artifacts"; +import { acquireCodexCliApplyLease } from "./cli-update-lease"; +import { scanCodexSessionProcessesAsync } from "./app-server-processes"; +import { observeCodexCliFileSet, observeCodexCliPreimage, sameCodexCliPreimage, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; +import type { CodexCliUpdateCommitWindow } from "./cli-update-selection-gate"; + +export interface CodexCliApplyResult { + readonly outcome: "refused" | "applied" | "not_applied" | "ambiguous"; + readonly reason: string; + readonly planId: string; + readonly installedVersion: string | null; + readonly targetVersion: string | null; + readonly installerExitCode: number | null; + readonly leaseRetained: boolean; + readonly verificationPid: number | null; + readonly cleanupErrors: readonly CodexCliApplyCleanupError[]; +} +export type CodexCliApplyCleanupError = "lease_release_unverified" | "lease_release_exception" | "staging_cleanup_failed"; +/** Every cleanup phase is attempted even if another fails. Preserve the + * computed readback outcome and expose path-free cleanup uncertainty separately. + */ +export function cleanupCodexCliApply(lease: { release(): boolean } | null, staging?: string) { + const cleanupErrors: CodexCliApplyCleanupError[] = []; + let leaseRetained = false; + try { if (lease && !lease.release()) { leaseRetained = true; cleanupErrors.push("lease_release_unverified"); } } + catch { leaseRetained = true; cleanupErrors.push("lease_release_exception"); } + try { if (staging) rmSync(staging, { recursive: true }); } + catch { cleanupErrors.push("staging_cleanup_failed"); } + return Object.freeze({ leaseRetained, cleanupErrors: Object.freeze(cleanupErrors) }); +} +interface VersionResult { readonly terminal: boolean; readonly ok: boolean; readonly pid: number | null } +export interface CodexCliApplyDeps extends CodexCliUpdatePlanDeps { + readonly signal?: AbortSignal; + /** + * Shared-fence cancellation checkpoint. Unlike signal.aborted this can flip + * DURING a synchronous section: the commit fence lives in shared memory the + * parent CASes, so a cancel landing mid-hash is seen at the next checkpoint + * rather than only on the next event-loop turn. + */ + readonly isCancelled?: () => boolean; + /** + * The worker's short selection commit window. When present, the final + * process scan and the synchronous guard block run while the shared gate is + * held, and commit()'s fence CAS is the last check before the installer + * spawn. Absent preserves the ungated in-process behavior tests rely on. + */ + readonly commitWindow?: CodexCliUpdateCommitWindow; + readonly temporaryRoot?: string; + readonly fetchArtifact?: (artifact: CodexCliArtifact, signal?: AbortSignal) => Promise; + readonly install?: (invocation: { bin: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv }) => Promise<{ terminal: boolean; exitCode: number | null }>; + readonly acquireLease?: typeof acquireCodexCliApplyLease; + readonly verifyVersion?: (command: string, version: string, cwd: string, env: NodeJS.ProcessEnv) => Promise; +} +/** Only this owned verification child may be stopped. A deadline/kill request + * is not terminal evidence: without close the installation lease stays held. + * A new POSIX process group contains the launcher and its native child. + * CODEX_HOME/HOME/TMPDIR isolate even --version's state writes in staging. + */ +export function verifyInstalledCodexVersion(command: string, version: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + const home = join(cwd, "version-home"); + // A setup failure before any child exists is terminal evidence, not an + // unknown lifetime: return it so the caller's lease stays releasable. + try { + mkdirSync(home, { mode: 0o700 }); + } catch { + return Promise.resolve({ terminal: true, ok: false, pid: null }); + } + return new Promise(resolve => { + let terminal = false, failed = false, stopping = false, output = ""; + let deadline: ReturnType | undefined, reap: ReturnType | undefined; + // A synchronous spawn throw created no child, so it is the same terminal + // setup evidence; asynchronous spawn errors still arrive as error+close. + let child: ReturnType; + try { + child = spawn(command, ["--version"], { cwd, env: { ...env, CODEX_HOME: home, HOME: home, TMPDIR: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_DATA_HOME: home }, + stdio: ["ignore", "pipe", "ignore"], windowsHide: true, detached: process.platform !== "win32" }); + } catch { + resolve({ terminal: true, ok: false, pid: null }); + return; + } + const groupGone = () => { + if (process.platform === "win32" || !child.pid) return true; + try { process.kill(-child.pid, 0); return false; } + catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH"; } + }; + const finish = (closed: boolean, code: number | null) => { + if (terminal) return; + terminal = true; clearTimeout(deadline); clearTimeout(reap); + const gone = closed && groupGone(); + if (!gone) child.unref(); // bounded caller; retained lease/staging and PID fence the unresolved child + resolve({ terminal: gone, ok: gone && !failed && code === 0 && output.trim() === `codex-cli ${version}`, + pid: gone ? null : child.pid ?? null }); + }; + const stop = () => { + if (stopping) return; + stopping = true; failed = true; + // Kill only this invocation's new group, never an existing Codex session. + try { if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL"); else child.kill("SIGKILL"); } + catch { /* close/group observation remains the exit proof */ } + child.stdout?.destroy(); + reap = setTimeout(() => finish(false, null), 1_000); + }; + child.stdout?.on("data", bytes => { + if (Buffer.byteLength(output) + bytes.length > 16 * 1024) stop(); + else if (!failed) output += bytes.toString(); + }); + child.once("error", () => { failed = true; }); + child.once("close", code => finish(true, code)); + deadline = setTimeout(stop, 8_000); + }); +} +/** A close event, not a timeout/error alone, settles a spawned npm child's + * lifetime. Abort during a running child is intentionally deferred: do not + * kill it or release its lease/staging before terminal readback. + */ +function runNpm(invocation: { bin: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv }, capture = false) { + return new Promise<{ terminal: boolean; exitCode: number | null; stdout: string }>(resolve => { + try { + const child = spawn(invocation.bin, [...invocation.args], { cwd: invocation.cwd, env: invocation.env, + stdio: ["ignore", capture ? "pipe" : "ignore", "ignore"], windowsHide: true }); + let stdout = ""; + let overflow = false; + child.stdout?.on("data", chunk => { + if (stdout.length + chunk.length > 1024 * 1024) overflow = true; + else if (!overflow) stdout += chunk.toString(); + }); + child.on("error", () => { /* close follows even when spawning failed */ }); + child.once("close", code => resolve({ terminal: true, exitCode: overflow ? null : code, stdout })); + } catch { resolve({ terminal: true, exitCode: null, stdout: "" }); } + }); +} +async function packArtifact(artifact: CodexCliArtifact, dir: string, env?: NodeJS.ProcessEnv): Promise { + const isolation = createNpmConfigIsolation(dir, env); + const invocation = isolatedNpmTarget(["pack", `${artifact.name}@${artifact.version}`, "--json", "--ignore-scripts", + "--pack-destination", dir, "--fetch-retries=0", "--fetch-timeout=120000"], isolation); + if (!invocation) throw new Error("npm_unavailable"); + const packed = await runNpm({ bin: invocation.bin, args: invocation.args, cwd: isolation.dir, env: isolation.env }, true); + if (packed.exitCode !== 0) throw new Error("artifact_pack_failed"); + const files = JSON.parse(packed.stdout); + if (!Array.isArray(files) || files.length !== 1 || typeof files[0]?.filename !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(files[0].filename)) throw new Error("artifact_pack_unverified"); + const path = join(dir, files[0].filename); + if (realpathSync.native(path) !== path) throw new Error("artifact_pack_unverified"); + return path; +} +function freshInspection(deps: CodexCliApplyDeps): CodexCliInstallProvenanceDeps { + const inspection = { ...(deps.inspectionDeps ?? {}), platform: deps.platform ?? deps.inspectionDeps?.platform, + observeInstallContent: true }; + return { ...inspection, selectedCommand: observeCodexCliSelectedCommand(inspection) }; +} + +/** POSIX non-shim only. No restart, kill, automatic retry or rollback. */ +export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDeps = {}): Promise { + let installedVersion: string | null = null; + let targetVersion: string | null = null; + let exitCode: number | null = null; + let lease: { release(): boolean } | null = null; + let keepLease = false; + let staging: string | undefined; + let started = false; + let verificationPid: number | null = null; + let output: CodexCliApplyResult | undefined; + const result = (outcome: CodexCliApplyResult["outcome"], reason: string): CodexCliApplyResult => + output = { outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, leaseRetained: keepLease, verificationPid, cleanupErrors: [] }; + // Both cancellation channels are consulted at every checkpoint: the plain + // AbortSignal for in-process callers and the shared-fence isCancelled for a + // worker whose cancel lands mid-synchronous-section on the parent's loop. + const cancelled = (): boolean => deps.signal?.aborted === true || deps.isCancelled?.() === true; + try { + if (!/^[0-9a-f]{32}$/.test(planId)) return result("refused", "invalid_plan_id"); + if ((deps.platform ?? process.platform) === "win32") return result("refused", "windows_apply_disabled"); + if (cancelled()) return result("refused", "cancelled"); + const inspect = deps.inspect ?? inspectCodexCliInstall; + const first = await inspect(freshInspection(deps)); + installedVersion = first.packageVersion; + const evidence = codexCliMutationEvidence(first); + if (!first.managed || !evidence || first.shim.status !== "not-tracked") return result("refused", "mutation_evidence_unverified"); + const prefix = realpathSync.native(evidence.prefix); + if (prefix !== evidence.prefix) return result("refused", "installation_prefix_changed"); + const prefixIdentity = lstatSync(prefix); + const acquired = (deps.acquireLease ?? acquireCodexCliApplyLease)(prefix); + if (acquired.kind === "prefix_unsupported") return result("refused", "installation_prefix_unsupported"); + if (acquired.kind === "busy") return result("refused", "installation_busy"); + if (acquired.kind !== "acquired") return result("refused", "installation_lease_unavailable"); + lease = acquired; + const freshPlan = () => createCodexCliUpdatePlan({ ...deps, inspectionDeps: freshInspection(deps) }); + const plan = await freshPlan(); + targetVersion = plan.targetVersion; + if (!plan.applicable) return result("refused", plan.refusal ?? "plan_refused"); + if (plan.planId !== planId) return result("refused", "plan_changed"); + const artifacts = plan.targetArtifacts; + if (!artifacts || artifacts.platform !== (deps.platform ?? process.platform) + || artifacts.arch !== (deps.inspectionDeps?.arch ?? process.arch)) return result("refused", "target_artifacts_unverified"); + staging = mkdtempSync(join(deps.temporaryRoot ?? tmpdir(), "ocx-codex-cli-apply-")); + staging = realpathSync.native(staging); + const paths: string[] = []; + for (const [index, artifact] of [artifacts.main, artifacts.backing].entries()) { + let path: string; + if (deps.fetchArtifact) { + const bytes = await deps.fetchArtifact(artifact, deps.signal); + path = join(staging, `${index}.tgz`); + writeFileSync(path, bytes, { flag: "wx", mode: 0o400 }); + } else path = await packArtifact(artifact, staging, deps.inspectionDeps?.env); + paths.push(path); + } + if (cancelled()) return result("refused", "cancelled"); + const main = await verifyCodexCliArchive(paths[0]!, artifacts.main); + const backing = await verifyCodexCliArchive(paths[1]!, artifacts.backing); + const expectedContent = verifiedCodexCliContent(artifacts, main, backing); + const targetMainRoot = join(prefix, "lib", "node_modules", ...artifacts.main.installName.split("/")); + const backingRoot = join(prefix, "lib", "node_modules", ...artifacts.backing.installName.split("/")); + const originalRoots = [evidence.packageRoot, ...(evidence.backingRoot ? [evidence.backingRoot] : []), targetMainRoot, backingRoot]; + const original = observeCodexCliPreimage(originalRoots); + // Metadata, content, selection and process observation are renewed after + // potentially slow downloads; the awaited process scan is the last yield + // before install, and a synchronous identity/preimage/selection guard runs + // in the same event-loop turn as the spawn. + const finalPlan = await freshPlan(); + if (!finalPlan.applicable) return result("refused", finalPlan.refusal ?? "plan_refused"); + if (finalPlan.planId !== planId) return result("refused", "plan_changed"); + const beforeInstall = await inspect(freshInspection(deps)); + const selected = codexCliMutationEvidence(beforeInstall); + const currentPrefix = lstatSync(prefix); + if (!selected || selected.prefix !== prefix || selected.launcher !== evidence.launcher + || selected.canonicalLauncher !== evidence.canonicalLauncher || selected.packageRoot !== evidence.packageRoot + || beforeInstall.installContentDigest !== plan.installContentDigest || !beforeInstall.managed + || realpathSync.native(prefix) !== prefix || currentPrefix.dev !== prefixIdentity.dev + || currentPrefix.ino !== prefixIdentity.ino) return result("refused", "installation_changed"); + if (cancelled()) return result("refused", "cancelled"); + const isolation = createNpmConfigIsolation(staging, deps.inspectionDeps?.env); + const args = ["install", "-g", "--prefix", prefix, "--offline", "--ignore-scripts", "--omit=optional", + "--no-audit", "--no-fund", "--package-lock=false", "--update-notifier=false", + `${artifacts.main.installName}@file:${paths[0]}`, `${artifacts.backing.installName}@file:${paths[1]}`]; + const invocation = isolatedNpmTarget(args, isolation); + if (!invocation) return result("refused", "npm_unavailable"); + // The shared selection gate serializes this commit window against the + // parent's own selection writers and session-spawn paths, which a worker + // realm can no longer exclude just by occupying its own event-loop turn. + // waitEnter can yield while a parent writer holds the gate, so the final + // process observation is taken only after the window is held; a gate that + // stays busy or a cancel that lands first refuses before the spawn. + const commitWindow = deps.commitWindow; + let windowHeld = false; + if (commitWindow) { + windowHeld = commitWindow.tryEnter() || await commitWindow.waitEnter(); + if (!windowHeld) return result("refused", cancelled() ? "cancelled" : "installation_busy"); + } + try { + const lastScan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); + if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); + if (lastScan.processes.length) return result("refused", "blocked_active_session"); + // Every earlier await could have yielded the event loop to a mutation of + // the install tree, the prefix or the selected command, so the last turn + // before spawn re-verifies them synchronously: prefix identity, the + // byte-level preimage taken before the downloads, the launcher's live + // canonical selection and the signal. Nothing can interleave between + // this block and the install invocation on the same event-loop turn, + // and the held gate keeps the gated parent operations out of the window. + if (cancelled()) return result("refused", "cancelled"); + const lastPrefix = lstatSync(prefix); + if (realpathSync.native(prefix) !== prefix || lastPrefix.dev !== prefixIdentity.dev + || lastPrefix.ino !== prefixIdentity.ino + || !sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) + || !codexCliMutationSelectionMatches(evidence, freshInspection(deps))) { + return result("refused", "installation_changed"); + } + // The commit CAS is the final synchronous guard: a cancel already + // landed wins and no spawn happens; once COMMITTED, a later cancel is + // deferred to the live installer's close and readback. + if (commitWindow && !commitWindow.commit()) return result("refused", "cancelled"); + started = true; + keepLease = true; + // The install implementation contract requires the child spawn to start + // synchronously inside this call (runNpm's spawn runs in the Promise + // executor), so returning means the spawn already happened under the + // held gate and the window may close while the child runs. This is a + // property of these two callables, not of arbitrary async callbacks. + const installPromise = (deps.install ?? runNpm)({ bin: invocation.bin, args: invocation.args, + cwd: isolation.dir, env: isolation.env }); + if (commitWindow) { commitWindow.exit(); windowHeld = false; } + const installed = await installPromise; + exitCode = installed.exitCode; + if (!installed.terminal) return result("ambiguous", "installer_state_unknown"); + } finally { + if (windowHeld) commitWindow?.exit(); + } + keepLease = false; + // Readback outranks exit code. Partial or unreadable installs are never retried. + const after: CodexCliInstallReport = await inspect(freshInspection(deps)); + const afterEvidence = codexCliMutationEvidence(after); + installedVersion = after.packageVersion; + const prefixAfter = lstatSync(prefix); + if (realpathSync.native(prefix) !== prefix || prefixAfter.dev !== prefixIdentity.dev || prefixAfter.ino !== prefixIdentity.ino) { + return result("ambiguous", "installation_prefix_changed"); + } + if (after.managed && afterEvidence?.prefix === prefix && afterEvidence.launcher === evidence.launcher + && after.packageVersion === targetVersion && after.installContentDigest === expectedContent + && sameCodexCliFiles(codexCliExpectedFiles([[afterEvidence.packageRoot, main], [backingRoot, backing]]), + observeCodexCliFileSet([afterEvidence.packageRoot, backingRoot])) + && codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) { + const layout = codexCliPlatformBacking(artifacts.platform, artifacts.arch)!; + accessSync(afterEvidence.launcher, constants.X_OK); + accessSync(join(backingRoot, "vendor", layout.triple, "bin", "codex"), constants.X_OK); + keepLease = true; + const version = await (deps.verifyVersion ?? verifyInstalledCodexVersion)(afterEvidence.launcher, targetVersion!, staging, isolation.env); + verificationPid = version.pid; + if (!version.terminal) return result("ambiguous", "version_process_state_unknown"); + keepLease = false; + if (!version.ok || !codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) return result("ambiguous", "version_readback_unverified"); + return result("applied", "verified_target"); + } + if (afterEvidence?.prefix === prefix && after.managed && after.packageVersion === first.packageVersion + && after.installContentDigest === plan.installContentDigest + && sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) + && codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) return result("not_applied", "original_install_unchanged"); + return result("ambiguous", "installation_readback_unverified"); + } catch { + return result(started ? "ambiguous" : "refused", started ? "installer_or_readback_error" : "preparation_failed"); + } finally { + if (!keepLease) { + const cleanup = cleanupCodexCliApply(lease, staging); + if (output) Object.assign(output, cleanup); + } + if (output) { Object.freeze(output.cleanupErrors); Object.freeze(output); } + } +} diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts new file mode 100644 index 00000000000..fcd2ddeacec --- /dev/null +++ b/src/codex/cli-update-artifacts.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { createGunzip } from "node:zlib"; +import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync } from "node:fs"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { parseStrictSemver } from "../lib/strict-semver"; +import { codexCliContentFileDigest, codexCliContentSetDigest, codexCliPlatformBacking } from "./cli-install-provenance"; + +export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org"; +export interface CodexCliArtifact { + readonly name: string; + readonly installName: string; + readonly version: string; + readonly integrity: string; + readonly tarball: string; +} +export interface CodexCliArtifactSet { + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly main: CodexCliArtifact; + readonly backing: CodexCliArtifact; +} +export function isSha512Integrity(token: string): boolean { + if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(token)) return false; + const digest = Buffer.from(token.slice(7), "base64"); + return digest.length === 64 && "sha512-" + digest.toString("base64") === token; +} +export function officialCodexTarball(value: string): boolean { + try { + const url = new URL(value); + return url.origin === CODEX_CLI_REGISTRY && !url.username && !url.password && !url.search && !url.hash; + } catch { return false; } +} +type Metadata = Record; +function artifact(meta: Metadata, installName: string): CodexCliArtifact { + if (typeof meta.name !== "string" || typeof meta.version !== "string" || !parseStrictSemver(meta.version) + || typeof meta.dist?.integrity !== "string" || !isSha512Integrity(meta.dist.integrity) + || typeof meta.dist?.tarball !== "string" || !officialCodexTarball(meta.dist.tarball)) throw new Error("artifact_metadata_unverified"); + return Object.freeze({ name: meta.name, installName, version: meta.version, + integrity: meta.dist.integrity, tarball: meta.dist.tarball }); +} +/** Only exact optional-package versions or the publisher's exact npm alias are modeled. */ +export function backingReference(main: Metadata, installName: string): { name: string; version: string; spec: string } { + const dependency = main.optionalDependencies?.[installName]; + const direct = dependency === main.version; + const alias = `npm:@openai/codex@${main.version}-${installName.slice("@openai/codex-".length)}`; + if (!direct && dependency !== alias) throw new Error("backing_reference_unverified"); + const name = direct ? installName : "@openai/codex"; + const version = direct ? main.version : alias.slice("npm:@openai/codex@".length); + return { name, version, spec: `${name}@${version}` }; +} +/** Main-metadata guard + reference derivation shared by the sync and async set builders. */ +function codexCliArtifactMain(main: Metadata, platform: NodeJS.Platform, arch: string) { + const layout = codexCliPlatformBacking(platform, arch); + if (!layout || main.name !== "@openai/codex") throw new Error("artifact_platform_unverified"); + const mainArtifact = artifact(main, "@openai/codex"); + const reference = backingReference(main, layout.package); + return { layout, mainArtifact, reference }; +} +function codexCliArtifactFinish(mainArtifact: CodexCliArtifact, + reference: { name: string; version: string; spec: string }, + layout: { package: string }, backingMeta: Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + const backing = artifact(backingMeta, layout.package); + if (backing.name !== reference.name || backing.version !== reference.version) throw new Error("backing_metadata_unverified"); + return Object.freeze({ platform, arch, main: mainArtifact, backing }); +} +export function codexCliArtifactSet(main: Metadata, query: (spec: string) => Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + // Main integrity is refused BEFORE the backing query is issued — the tarball query + // must never run on already-unverified metadata (integrity gate, 2 queries total). + const { layout, mainArtifact, reference } = codexCliArtifactMain(main, platform, arch); + return codexCliArtifactFinish(mainArtifact, reference, layout, query(reference.spec), platform, arch); +} +/** + * The request-path twin of {@link codexCliArtifactSet}: the backing query resolves off the + * event loop, so a waiting registry child cannot stall the proxy. The platform guard and + * main-artifact check still run before any backing query is issued. + */ +export async function codexCliArtifactSetAsync(main: Metadata, query: (spec: string) => Promise, + platform: NodeJS.Platform, arch: string): Promise { + const { layout, mainArtifact, reference } = codexCliArtifactMain(main, platform, arch); + return codexCliArtifactFinish(mainArtifact, reference, layout, await query(reference.spec), platform, arch); +} +export function codexCliArtifactSetDigest(set: CodexCliArtifactSet): string { + return createHash("sha256").update(JSON.stringify([set.platform, set.arch, + ...[set.main, set.backing].map(file => [file.name, file.installName, file.version, file.integrity, file.tarball])])).digest("hex"); +} + +export const MAX_CODEX_ARCHIVE_BYTES = 256 * 1024 * 1024; +export const MAX_CODEX_UNPACKED_BYTES = 512 * 1024 * 1024; +export const MAX_CODEX_MEMBER_BYTES = 384 * 1024 * 1024; +export interface CodexArchiveFile { readonly size: number; readonly mode: number; readonly digest: string; readonly bytes: Buffer | null } +export type CodexArchiveFiles = Map; +/** Stream gzip and tar. Only package.json (256 KiB) and the JS entrypoint (1 MiB) + * are retained; native binaries and other files contribute a streaming digest. + * Official 0.163.0 linux-x64: 162,055,867 compressed, 429,938,138 unpacked, + * largest member 296,865,480 bytes. Limits are independent, not a heap budget. + */ +export async function verifyCodexCliArchive(source: Buffer | string, target: CodexCliArtifact): Promise { + if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch"); + let fd: number | undefined; + let before: ReturnType | undefined; + if (typeof source === "string") { + fd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { before = fstatSync(fd); } catch (error) { closeSync(fd); throw error; } + if (!before.isFile() || before.size > MAX_CODEX_ARCHIVE_BYTES) { closeSync(fd); throw new Error("artifact_file_unverified"); } + } + let input: Readable; + try { + input = typeof source === "string" ? createReadStream(source, { fd, autoClose: false, emitClose: true, highWaterMark: 64 * 1024 }) : Readable.from([source]); + } catch (error) { if (fd !== undefined) closeSync(fd); throw error; } + // The stream exclusively owns fd after construction. In Bun destroy closes + // even with autoClose:false; that option keeps the fd held through our final + // identity check, not through an explicit destroy. Never close the raw fd too. + let inputError: Error | undefined; + input.on("error", error => { inputError = error; }); + const inputClosed = new Promise(resolve => input.once("close", resolve)); + const archiveHash = createHash("sha512"); + let compressed = 0, unpacked = 0, count = 0, headerSize = 0, padding = 0, trailer = 0; + let ended = false; + const header = Buffer.alloc(512); + const files: CodexArchiveFiles = new Map(); + const names = new Set(); + let current: { name: string; size: number; mode: number; remaining: number; keep: boolean; + chunks: Buffer[]; hash: ReturnType } | undefined; + let tracker: Transform | undefined, gunzip: ReturnType | undefined, pumping: Promise | undefined; + let verified = false; + try { + tracker = new Transform({ transform(chunk: Buffer, _encoding, callback) { + compressed += chunk.length; + if (compressed > MAX_CODEX_ARCHIVE_BYTES) return callback(new Error("artifact_compressed_limit")); + archiveHash.update(chunk); callback(null, chunk); + } }); + gunzip = createGunzip({ chunkSize: 64 * 1024 }); + pumping = pipeline(input, tracker, gunzip); + void pumping.catch(() => {}); + for await (const data of gunzip) { + const chunk = Buffer.from(data); + unpacked += chunk.length; + if (unpacked > MAX_CODEX_UNPACKED_BYTES) throw new Error("artifact_unpacked_limit"); + let position = 0; + while (position < chunk.length) { + if (ended) { + const tail = chunk.subarray(position); + if (!tail.every(byte => byte === 0)) throw new Error("artifact_tar_trailing_data"); + trailer += tail.length; break; + } + if (padding) { const take = Math.min(padding, chunk.length - position); padding -= take; position += take; continue; } + if (current) { + const take = Math.min(current.remaining, chunk.length - position); + const bytes = chunk.subarray(position, position + take); + current.hash.update(bytes); + if (current.keep) current.chunks.push(Buffer.from(bytes)); + current.remaining -= take; position += take; + if (!current.remaining) { + files.set(current.name, { size: current.size, mode: current.mode, digest: current.hash.digest("hex"), + bytes: current.keep ? Buffer.concat(current.chunks) : null }); + padding = (512 - current.size % 512) % 512; current = undefined; + } + continue; + } + const take = Math.min(512 - headerSize, chunk.length - position); + chunk.copy(header, headerSize, position, position + take); position += take; headerSize += take; + if (headerSize < 512) continue; + headerSize = 0; + if (header.every(byte => byte === 0)) { ended = true; continue; } + const field = (start: number, length: number) => header.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, ""); + const octal = (start: number, length: number) => { + const value = field(start, length).trim(); + if (!/^[0-7]+$/.test(value)) throw new Error("artifact_tar_invalid"); + return parseInt(value, 8); + }; + const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0); + if (sum !== octal(148, 8)) throw new Error("artifact_tar_invalid"); + const prefix = field(345, 155); + const name = (prefix ? prefix + "/" : "") + field(0, 100); + const type = field(156, 1), size = octal(124, 12), mode = octal(100, 8); + if (++count > 4096 || !name.startsWith("package/") || /[\\\x00-\x1f]/.test(name) + || name.replace(/\/$/, "").split("/").some(part => !part || part === ".." || part === ".") || names.has(name) + || !["", "0", "5"].includes(type) || (type === "5" && size !== 0) + || (type !== "5" && name.endsWith("/")) || size > MAX_CODEX_MEMBER_BYTES) throw new Error("artifact_tar_invalid"); + names.add(name); + if (type === "5") continue; + const relative = name.slice(8); + const keep = relative === "package.json" || relative === "bin/codex.js"; + if (keep && size > (relative === "package.json" ? 256 * 1024 : 1024 * 1024)) throw new Error("artifact_metadata_limit"); + current = { name: relative, size, mode, remaining: size, keep, chunks: [], + hash: createHash("sha256").update("codex-cli-observed-file-v1\0") }; + if (!size) { + files.set(relative, { size, mode, digest: current.hash.digest("hex"), bytes: keep ? Buffer.alloc(0) : null }); current = undefined; + } + } + } + await pumping; + if (!ended || trailer < 512 || current || padding || headerSize) throw new Error("artifact_tar_truncated"); + if ("sha512-" + archiveHash.digest("base64") !== target.integrity) throw new Error("artifact_integrity_mismatch"); + if (fd !== undefined && before && typeof source === "string") { + const after = fstatSync(fd), lexical = lstatSync(source); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || lexical.isSymbolicLink() + || lexical.dev !== before.dev || lexical.ino !== before.ino) throw new Error("artifact_file_changed"); + } + verified = true; + } finally { + input.destroy(); tracker?.destroy(); gunzip?.destroy(); + await pumping?.catch(() => {}); + // Reawaiting the pipeline alone does not wait for ReadStream's async close. + await inputClosed; + if (verified && inputError) throw new Error("artifact_close_unverified"); + } + const manifestBytes = files.get("package.json")?.bytes; + if (!manifestBytes || manifestBytes.length > 256 * 1024 || files.has("npm-shrinkwrap.json") || files.has("package-lock.json")) throw new Error("artifact_manifest_unverified"); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + if (manifest.name !== target.name || manifest.version !== target.version) throw new Error("artifact_manifest_unverified"); + for (const key of ["dependencies", "peerDependencies", "bundledDependencies", "bundleDependencies"]) { + if (manifest[key] && Object.keys(manifest[key]).length) throw new Error("artifact_dependency_unverified"); + } + return files; +} +export function verifiedCodexCliContent(set: CodexCliArtifactSet, main: CodexArchiveFiles, backing: CodexArchiveFiles): string { + const layout = codexCliPlatformBacking(set.platform, set.arch); + if (!layout) throw new Error("artifact_platform_unverified"); + const manifest = JSON.parse(main.get("package.json")!.bytes!.toString("utf8")); + const ref = backingReference(manifest, layout.package); + for (const name of Object.keys(manifest.optionalDependencies ?? {})) { + if (!/^@openai\/codex-(linux|darwin|win32)-(x64|arm64)$/.test(name)) throw new Error("artifact_dependency_unverified"); + backingReference(manifest, name); + } + if (ref.name !== set.backing.name || ref.version !== set.backing.version + || (typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex)?.replace(/^\.\//, "") !== "bin/codex.js") throw new Error("artifact_backing_unverified"); + const files = [ + { role: "manifest" as const, file: main.get("package.json") }, + { role: "entrypoint" as const, file: main.get("bin/codex.js") }, + { role: "backing-manifest" as const, file: backing.get("package.json") }, + { role: "backing-binary" as const, file: backing.get(`vendor/${layout.triple}/bin/codex`) }, + ]; + const backingManifest = JSON.parse(backing.get("package.json")!.bytes!.toString("utf8")); + if (backingManifest.exports || backingManifest.bin || Object.keys(backingManifest.optionalDependencies ?? {}).length + || files.some(file => !file.file) || !(main.get("bin/codex.js")!.mode & 0o111) + || !(backing.get(`vendor/${layout.triple}/bin/codex`)!.mode & 0o111)) throw new Error("artifact_backing_unverified"); + return codexCliContentSetDigest(set.platform, set.arch, layout.triple, "optional-package", + files.map(file => ({ role: file.role, digest: file.role === "manifest" || file.role === "backing-manifest" + ? codexCliContentFileDigest(file.role, file.file!.bytes!) : file.file!.digest }))); +} diff --git a/src/codex/cli-update-files.ts b/src/codex/cli-update-files.ts new file mode 100644 index 00000000000..de7bf446b02 --- /dev/null +++ b/src/codex/cli-update-files.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { MAX_CODEX_MEMBER_BYTES, MAX_CODEX_UNPACKED_BYTES, type CodexArchiveFiles } from "./cli-update-artifacts"; + +interface FileProof { readonly size: number; readonly digest: string; readonly executable: boolean; readonly mode?: number } +export type CodexCliFileSet = Map; +export interface CodexCliPreimage { + readonly presentRoots: readonly string[]; + readonly absentRoots: readonly string[]; + readonly files: CodexCliFileSet; +} +/** An absent planned root is evidence too. Reject link/invalid ancestor lanes + * instead of treating an inaccessible or redirected tree as simply absent. + */ +function rootAbsent(path: string): boolean { + try { lstatSync(path); return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + let ancestor = dirname(path); + for (;;) { + try { + const stat = lstatSync(ancestor); + if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync.native(ancestor) !== ancestor) throw new Error("install_tree_unverified"); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(ancestor) === ancestor) throw error; + ancestor = dirname(ancestor); + } + } +} +export function observeCodexCliPreimage(roots: readonly string[]): CodexCliPreimage { + const presentRoots: string[] = [], absentRoots: string[] = []; + for (const root of new Set(roots)) (rootAbsent(root) ? absentRoots : presentRoots).push(root); + const files = observeCodexCliFileSet(presentRoots); + if (absentRoots.some(root => !rootAbsent(root))) throw new Error("install_tree_changed"); + return { presentRoots, absentRoots, files }; +} +export function sameCodexCliPreimage(left: CodexCliPreimage, right: CodexCliPreimage): boolean { + const sameRoots = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every(root => b.includes(root)); + return sameRoots(left.presentRoots, right.presentRoots) && sameRoots(left.absentRoots, right.absentRoots) + && sameCodexCliFiles(left.files, right.files); +} +/** Full bounded regular-file observation of the selected package roots only. + * Ancestor/path links, nonregular files and changed identities refuse it. + * Big members are hashed in 64 KiB chunks, never retained in memory. + */ +export function observeCodexCliFileSet(roots: readonly string[]): CodexCliFileSet { + const files: CodexCliFileSet = new Map(); + let total = 0, entries = 0; + const walk = (path: string) => { + if (++entries > 8192 || realpathSync.native(path) !== path) throw new Error("install_tree_unverified"); + const lexical = lstatSync(path); + if (lexical.isSymbolicLink()) throw new Error("install_tree_unverified"); + if (lexical.isDirectory()) { + for (const name of readdirSync(path)) walk(join(path, name)); + const after = lstatSync(path); + if (lexical.dev !== after.dev || lexical.ino !== after.ino || lexical.mtimeMs !== after.mtimeMs) throw new Error("install_tree_changed"); + return; + } + if (!lexical.isFile() || lexical.size > MAX_CODEX_MEMBER_BYTES) throw new Error("install_tree_unverified"); + total += lexical.size; + if (total > MAX_CODEX_UNPACKED_BYTES || files.size >= 4096) throw new Error("install_tree_limit"); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = fstatSync(fd); + if (!before.isFile() || before.dev !== lexical.dev || before.ino !== lexical.ino || before.size !== lexical.size) throw new Error("install_tree_changed"); + const hash = createHash("sha256").update("codex-cli-observed-file-v1\0"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + for (;;) { + const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - offset + 1), offset); + if (!count) break; + offset += count; + if (offset > before.size) throw new Error("install_tree_changed"); + hash.update(chunk.subarray(0, count)); + } + const after = fstatSync(fd), final = lstatSync(path); + if (offset !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs + || before.ctimeMs !== after.ctimeMs || final.isSymbolicLink() || final.dev !== before.dev || final.ino !== before.ino + || final.mode !== before.mode) throw new Error("install_tree_changed"); + let executable = false; + if (before.mode & 0o111) { try { accessSync(path, constants.X_OK); executable = true; } catch { /* current user cannot execute it */ } } + files.set(path, { size: before.size, digest: hash.digest("hex"), executable, mode: before.mode & 0o7777 }); + } finally { closeSync(fd); } + }; + const unique = [...new Set(roots)].filter(root => !roots.some(other => other !== root && root.startsWith(other + "/"))); + for (const root of unique) walk(root); + return files; +} +export function codexCliExpectedFiles(roots: readonly [string, CodexArchiveFiles][]): CodexCliFileSet { + return new Map(roots.flatMap(([root, files]) => [...files].map(([path, proof]) => [join(root, path), + { size: proof.size, digest: proof.digest, executable: !!(proof.mode & 0o111) }] as const))); +} +export function sameCodexCliFiles(left: CodexCliFileSet, right: CodexCliFileSet): boolean { + return left.size === right.size && [...left].every(([path, proof]) => { + const other = right.get(path); + return other?.size === proof.size && other.digest === proof.digest && other.executable === proof.executable + && (proof.mode === undefined || proof.mode === other.mode); + }); +} diff --git a/src/codex/cli-update-inspection-context.ts b/src/codex/cli-update-inspection-context.ts new file mode 100644 index 00000000000..e325e43e49f --- /dev/null +++ b/src/codex/cli-update-inspection-context.ts @@ -0,0 +1,49 @@ +import type { CodexCliInstallProvenanceDeps } from "./cli-install-provenance"; +import { observeCodexCliSelectedCommand } from "./cli-install-provenance"; +import type { ResolveCodexRuntimeDeps, ResolveCodexRuntimeResult } from "./runtime"; +import { trustedNodeLauncherContext } from "../cli/launcher-context"; +import type { TrustedNodeLaunchContext } from "../cli/launcher-context"; + +/** + * The allowlisted, immutable launcher snapshot an update worker may receive. + * It is the proof-bound pre-dotenv capture validated and frozen by + * initializeNodeLauncherContext; a Worker realm never reads the parent's + * trustedContext, so the snapshot itself is the structured-clone payload. + */ +export type CodexCliUpdateInspectionSnapshot = TrustedNodeLaunchContext["codexCliInspectionEnv"]; + +/** + * Build the updater's production inspection context from one supplied + * launcher snapshot. The selected-command observation runs in the CALLING + * realm: a worker receives only the frozen snapshot and must derive its own + * fresh selector evidence rather than trust a parent's pre-computed value. + * Missing fields are never backfilled from the ambient environment; an absent + * or untrusted snapshot fails closed exactly like a direct Bun/source launch. + */ +export function codexCliUpdateInspectionDepsFromSnapshot( + snapshot: CodexCliUpdateInspectionSnapshot, + deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; + } = {}, +): CodexCliInstallProvenanceDeps { + const trusted = snapshot; + if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; + const selectedCommand = observeCodexCliSelectedCommand({ + env: { CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, PATH: trusted.path ?? undefined, PATHEXT: trusted.pathExt ?? undefined }, + configDir: trusted.configDir, + }, deps.resolveSelectedRuntime); + return { + env: { ...trusted.managerRoots, ...trusted.transportEnv, CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, + PATH: trusted.path ?? undefined, PATHEXT: trusted.pathExt ?? undefined, + HOME: trusted.home ?? undefined, NODE_PATH: trusted.nodePath ?? undefined }, + configDir: trusted.configDir, arch: trusted.arch ?? undefined, + selectionAttested: true, selectedCommand, + }; +} + +/** Build the updater's only production inspection context from the Node launcher's proof-bound snapshot. */ +export function codexCliUpdateInspectionDeps(deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; +} = {}): CodexCliInstallProvenanceDeps { + return codexCliUpdateInspectionDepsFromSnapshot(trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null, deps); +} diff --git a/src/codex/cli-update-job-execution.ts b/src/codex/cli-update-job-execution.ts new file mode 100644 index 00000000000..7d3eac94d08 --- /dev/null +++ b/src/codex/cli-update-job-execution.ts @@ -0,0 +1,72 @@ +import type { CodexCliApplyResult } from "./cli-update-apply"; +import type { CodexCliUpdateJobDto } from "./cli-update-job"; + +export const CODEX_CLI_JOB_REASONS: ReadonlySet = new Set(["invalid_plan_id", "windows_inspection_deferred", "windows_apply_disabled", "cancelled", "mutation_evidence_unverified", + "installation_prefix_changed", "installation_prefix_unsupported", "installation_lease_unavailable", "installation_busy", "plan_refused", "plan_changed", "target_artifacts_unverified", + "installation_changed", "npm_unavailable", "blocked_process_state_unknown", "blocked_active_session", + "installer_state_unknown", "version_process_state_unknown", "version_readback_unverified", "verified_target", + "original_install_unchanged", "installation_readback_unverified", "installer_or_readback_error", + "preparation_failed", "job_result_unknown", "not_managed", "installed_version_unverified", + "install_content_unverified", "target_unresolved", "already_current", "target_not_newer"]); +export const CODEX_CLI_JOB_CLEANUP_ERRORS: ReadonlySet = new Set(["lease_release_unverified", "lease_release_exception", "staging_cleanup_failed"]); + +export class CodexCliJobStoreBusyError extends Error { + constructor() { super("job_store_busy"); } +} +export class CodexCliJobResultPersistenceError extends Error { + constructor(readonly result: CodexCliApplyResult) { super("job_result_persistence_failed"); } +} +export function normalizeCodexCliJobResult(result: CodexCliApplyResult): CodexCliApplyResult { + const cleanup = Array.isArray(result.cleanupErrors) ? result.cleanupErrors : []; + const safe = CODEX_CLI_JOB_REASONS.has(result.reason) + && ["applied", "not_applied", "refused", "ambiguous"].includes(result.outcome) + && typeof result.leaseRetained === "boolean" && Array.isArray(result.cleanupErrors) + && cleanup.length <= 8 && cleanup.every(error => CODEX_CLI_JOB_CLEANUP_ERRORS.has(error)); + return safe ? result : { ...result, outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + cleanupErrors: cleanup.filter(error => CODEX_CLI_JOB_CLEANUP_ERRORS.has(error)).slice(0, 8) }; +} +export async function retryCodexCliJobStoreOperation(operation: () => T, + delay: (milliseconds: number) => Promise = ms => new Promise(resolve => setTimeout(resolve, ms)), + persistence = false): Promise { + for (let attempt = 0; ; attempt++) { + try { return operation(); } + catch (error) { + const transientIo = persistence && ["EIO", "ENOSPC", "EBUSY", "EAGAIN", "EINTR", "EMFILE", "ENFILE"] + .includes((error as NodeJS.ErrnoException)?.code ?? ""); + if (attempt === 7 || (!(error instanceof CodexCliJobStoreBusyError) && !transientIo)) throw error; + await delay(25); + } + } +} +interface JobWork { + begin(): number; + finish(revision: number, result: CodexCliApplyResult): CodexCliUpdateJobDto | null; + read(): CodexCliUpdateJobDto | null; + release(): void; +} +/** Execution and persistence are separate ownership phases. */ +export async function runCodexCliUpdateJobWork(run: () => Promise, work: JobWork): Promise { + let released = false; + try { + let revision: number; + try { revision = await retryCodexCliJobStoreOperation(() => work.begin()); } + catch { + work.release(); released = true; + try { return work.read(); } + catch (error) { + const code = error instanceof Error ? error.message : ""; + throw new Error(["unsafe_store", "invalid_store", "unsupported_store"].includes(code) ? code : "job_state_unavailable"); + } + } + let result: CodexCliApplyResult; + try { result = await run(); } + catch { + result = { outcome: "ambiguous", reason: "job_result_unknown", planId: "", + installedVersion: null, targetVersion: null, installerExitCode: null, leaseRetained: true, + verificationPid: null, cleanupErrors: [] }; + } + try { + return await retryCodexCliJobStoreOperation(() => work.finish(revision, result) ?? work.read(), undefined, true); + } catch { throw new CodexCliJobResultPersistenceError(result); } + } finally { if (!released) work.release(); } +} diff --git a/src/codex/cli-update-job-worker.ts b/src/codex/cli-update-job-worker.ts new file mode 100644 index 00000000000..05de9e6cacd --- /dev/null +++ b/src/codex/cli-update-job-worker.ts @@ -0,0 +1,378 @@ +/** + * Parent-side dispatch for Codex CLI update plan/apply workers. + * + * The HTTP route and CLI keep their existing contracts: admission and the + * generation-fenced durable ledger stay in this realm, the spawned Worker + * realm owns the heavy inspection, hashing and installer lifetime. A job is + * one fresh Worker plus one unique requestId plus the ledger generation; no + * wire generation is needed because the Worker runs once, ignores later + * messages and closes. + * + * Cancellation uses BOTH channels: the per-job shared fence's + * PREPARING -> CANCELLED compare-exchange (the only word a synchronous + * worker section can observe) and a "cancel" message that aborts the real + * AbortSignal handed to apply's fetchArtifact path. The message is posted + * only when the fence CAS won, so a cancel arriving after COMMITTED defers + * to the live installer's close exactly like the in-process contract. + * + * Settlement requires BOTH a valid reply AND the Worker's close. A reply + * alone is never terminal evidence: a legitimate result can still report + * installer_state_unknown or version_process_state_unknown with a retained + * lease and a live verification child, so done-without-close means the Worker's + * own exit could not be observed and the job resolves ambiguous with its lease + * state unknown. This host never calls terminate(): an unresponsive Worker may + * legitimately own a running npm child, and killing the thread would orphan + * the very lifetime the ledger must keep reporting as unknown. Workers that + * settle ambiguous while still alive are tracked in the unjoined set so their + * count is observable evidence rather than a silent leak. + */ +import { randomUUID } from "node:crypto"; +import type { CodexCliApplyResult } from "./cli-update-apply"; +import type { CodexCliUpdateChannel, CodexCliUpdatePlan } from "./cli-update-plan"; +import type { CodexCliUpdateInspectionSnapshot } from "./cli-update-inspection-context"; +import { trustedNodeLauncherContext } from "../cli/launcher-context"; +import { spawnWorker } from "../lib/worker-embed"; +import { + CODEX_CLI_UPDATE_GATE_FREE, + CODEX_CLI_UPDATE_GATE_PARENT_WRITE, + CODEX_CLI_UPDATE_GATE_WORKER_COMMIT, + cancelCodexCliUpdateFence, + codexCliUpdateSelectionGate, + createCodexCliUpdateFence, +} from "./cli-update-selection-gate"; +import type { CodexCliUpdateWorkerReply, CodexCliUpdateWorkerRunMessage } from "./cli-update-worker"; + +/** Grace for the Worker's close after a valid reply before the run goes ambiguous. */ +const WORKER_CLOSE_GRACE_MS = 10_000; + +interface LiveJobRecord { + /** Fence CAS plus async cancel delivery; false when the fence already moved. */ + readonly cancel: () => boolean; + readonly settled: () => boolean; + readonly workerClosed: () => boolean; +} + +const liveJobs = new Map(); +const unjoinedWorkers = new Set(); +let unjoinedWorkerClosedTotal = 0; + +/** + * Parent cancellation for one in-flight job: CASes that job's fence + * PREPARING -> CANCELLED and, only when that word was won, posts the async + * "cancel" message that aborts the worker's real AbortSignal. False when the + * job is unknown here or its fence already committed/cancelled — a lost CAS + * never reaches the worker, so a post-COMMITTED cancel stays deferred. + */ +export function cancelCodexCliUpdateWorkerJob(jobId: string): boolean { + return liveJobs.get(jobId)?.cancel() ?? false; +} + +/** Workers that settled ambiguous while still alive; evidence, not a leak counter. */ +export function codexCliUpdateUnjoinedWorkerCount(): number { + return unjoinedWorkers.size; +} + +/** + * Cumulative count of workers that settled without close evidence and were + * later observed closed — removed from the live set but retained as evidence. + */ +export function codexCliUpdateUnjoinedWorkerClosedTotal(): number { + return unjoinedWorkerClosedTotal; +} + +/** + * Operator-facing snapshot for diagnosing a held selection gate. The gate + * cell is one Int32 word with no owner field, so this reports CORRELATION + * evidence only: which update jobs are in flight here and whether each + * Worker's close was actually observed. A job listed beside a worker_commit + * gate is a candidate holder, never a proven one; an empty list beside a + * held gate means the holder is unknown to this process — possibly a dead + * Worker whose window never released (the gate is never auto-released). + */ +export interface CodexCliUpdateWorkerDiagnostics { + readonly gate: "free" | "parent_write" | "worker_commit" | "unrecognized"; + readonly liveJobs: readonly { + readonly jobId: string; + readonly settled: boolean; + readonly workerClosed: boolean; + }[]; + readonly unjoinedWorkers: number; + readonly unjoinedWorkerClosedTotal: number; +} + +export function codexCliUpdateWorkerDiagnostics(): CodexCliUpdateWorkerDiagnostics { + const raw = Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0); + const gate = raw === CODEX_CLI_UPDATE_GATE_FREE ? "free" + : raw === CODEX_CLI_UPDATE_GATE_PARENT_WRITE ? "parent_write" + : raw === CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" + : "unrecognized"; + return { + gate, + liveJobs: [...liveJobs.entries()].map(([jobId, record]) => ({ + jobId, settled: record.settled(), workerClosed: record.workerClosed(), + })), + unjoinedWorkers: unjoinedWorkers.size, + unjoinedWorkerClosedTotal, + }; +} + +export interface CodexCliUpdateWorkerSpawnOptions { + /** Frozen launcher snapshot; default captures the trusted context at call time. */ + readonly snapshot?: CodexCliUpdateInspectionSnapshot; + /** Optional external abort: fires the same fence CAS as cancelCodexCliUpdateWorkerJob. */ + readonly signal?: AbortSignal; + /** Test seam: spawn a fixture Worker instead of the bundled entrypoint. */ + readonly spawnWorkerFn?: (devUrl: string, key: string) => Worker; + readonly closeGraceMs?: number; + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +type DispatchSettlement = + | { readonly kind: "reply"; readonly reply: CodexCliUpdateWorkerReply } + | { readonly kind: "spawn_failed" } + | { readonly kind: "unsettled" }; + +const APPLY_OUTCOMES: ReadonlySet = new Set(["refused", "applied", "not_applied", "ambiguous"]); + +/** + * Reject a reply that names our ids but lacks its payload. Accepting a + * shapeless "done" would read success out of work that may not have happened; + * the ledger's downstream normalizer is a second clamp, not the only check. + */ +function isPlausibleWorkerReply( + data: unknown, + requestId: string, + jobId: string, + operation: "plan" | "apply", +): data is CodexCliUpdateWorkerReply { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + if (o.requestId !== requestId || o.jobId !== jobId) return false; + if (o.type === "error") return typeof o.message === "string"; + if (o.type !== "done" || o.operation !== operation) return false; + if (operation === "plan") { + return !!o.plan && typeof o.plan === "object" && !Array.isArray(o.plan); + } + const result = o.result; + return !!result && typeof result === "object" && !Array.isArray(result) + && APPLY_OUTCOMES.has((result as { outcome?: unknown }).outcome as string) + && typeof (result as { reason?: unknown }).reason === "string"; +} + +function trackUnjoined(worker: Worker, alreadyClosed: boolean): void { + if (unjoinedWorkers.has(worker)) return; + if (alreadyClosed) { + // The close is already evidenced; keep the diagnostic without pretending + // a dead Worker is a live leak. + unjoinedWorkerClosedTotal++; + warnIfCommitGateHeld(); + return; + } + unjoinedWorkers.add(worker); + worker.addEventListener("close", () => { + if (!unjoinedWorkers.delete(worker)) return; + unjoinedWorkerClosedTotal++; + warnIfCommitGateHeld(); + }, { once: true }); +} + +/** + * A Worker that dies while holding the commit gate leaves the cell at + * worker_commit forever — there is no auto-release by design. Report the + * correlation honestly: an observed close does not prove that Worker held it. + */ +function warnIfCommitGateHeld(): void { + if (Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0) + !== CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) return; + console.warn( + "[opencodex] a Codex CLI update worker closed while the shared selection gate still reads" + + " worker_commit; the holder is unknown and the gate is never auto-released — inspect" + + " codexCliUpdateWorkerDiagnostics() before treating a persist/shim/restart busy as stuck", + ); +} + +interface DispatchState { + settled: boolean; + closed: boolean; +} + +function dispatchWorker( + worker: Worker, + message: CodexCliUpdateWorkerRunMessage, + options: CodexCliUpdateWorkerSpawnOptions, + state: DispatchState, +): Promise { + return new Promise(resolve => { + let reply: CodexCliUpdateWorkerReply | undefined; + let grace: ReturnType | undefined; + const finish = (value: DispatchSettlement) => { + if (state.settled) return; + state.settled = true; + if (grace) clearTimeout(grace); + resolve(value); + }; + // Every non-reply terminal signal keeps the possibly-live Worker tracked: + // it can still own an installer child the ledger must report as unknown. + const unsettled = () => { + if (state.settled) return; + trackUnjoined(worker, state.closed); + finish({ kind: "unsettled" }); + }; + worker.onmessage = (event: MessageEvent) => { + // A settled or already-closed dispatch ignores late traffic entirely: + // a duplicate or post-close message must not re-arm the grace timer, + // attach listeners or re-add the Worker to the unjoined set. Once a + // valid reply is stored it is authoritative: a run-once Worker cannot + // legitimately send a second result, so a late "better" duplicate or a + // trailing error reply never replaces the first correlated payload. + if (state.settled || state.closed || reply !== undefined) return; + const data = event.data; + if (!data || typeof data !== "object" || Array.isArray(data)) { + unsettled(); + return; + } + const m = data as Record; + // A reply naming different ids belongs to someone else; ignoring it is + // not the same as accepting it. + if (m.requestId !== message.requestId || m.jobId !== message.jobId) return; + if (!isPlausibleWorkerReply(data, message.requestId, message.jobId, message.operation)) { + unsettled(); + return; + } + // done alone is not terminal evidence: the Worker's own close still has + // to be observed, and within the grace a late close still settles reply. + // The reply is stored once; the single close handler below decides. + reply = data as CodexCliUpdateWorkerReply; + grace = setTimeout(() => unsettled(), options.closeGraceMs ?? WORKER_CLOSE_GRACE_MS); + }; + worker.addEventListener("messageerror", () => unsettled()); + worker.addEventListener("close", () => { + state.closed = true; + if (grace) clearTimeout(grace); + // One close handler decides everything: a stored valid reply wins, any + // other close — before a reply, or after only implausible traffic — is + // unknown work, not a clean refusal. + if (reply !== undefined) finish({ kind: "reply", reply }); + else unsettled(); + }, { once: true }); + worker.onerror = () => unsettled(); + try { + worker.postMessage(message); + } catch { + // A synchronous post failure leaves delivery uncertain: the thread is + // alive and may (or may not) have received the run. Unknown work must + // never be downgraded to a provable pre-run refusal. + unsettled(); + } + }); +} + +function applyFallback(planId: string, settled: DispatchSettlement): CodexCliApplyResult { + return Object.freeze( + settled.kind === "spawn_failed" + ? { + outcome: "refused" as const, reason: "preparation_failed", planId, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: false, verificationPid: null, cleanupErrors: Object.freeze([]), + } + : { + outcome: "ambiguous" as const, reason: "job_result_unknown", planId, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: true, verificationPid: null, cleanupErrors: Object.freeze([]), + }, + ); +} + +async function runInWorker( + operation: "plan" | "apply", + fields: { readonly planId?: string; readonly channel?: CodexCliUpdateChannel }, + options: CodexCliUpdateWorkerSpawnOptions & { readonly jobId?: string }, +): Promise { + const jobId = options.jobId ?? randomUUID(); + const requestId = randomUUID(); + const fence = createCodexCliUpdateFence(); + const snapshot = options.snapshot !== undefined + ? options.snapshot + : trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null; + let worker: Worker; + try { + const injectedSpawn = options.spawnWorkerFn; + worker = injectedSpawn === undefined || injectedSpawn === null + ? spawnWorker(new URL("./cli-update-worker.ts", import.meta.url).href, "cli-update-worker") + : injectedSpawn(new URL("./cli-update-worker.ts", import.meta.url).href, "cli-update-worker"); + } catch { + // Spawn threw before the run message existed: provably no job code ran. + return { kind: "spawn_failed" }; + } + const state: DispatchState = { settled: false, closed: false }; + const cancel = () => { + if (!cancelCodexCliUpdateFence(fence)) return false; + // Async cancel delivery only rides a won CAS: the worker's real + // AbortSignal aborts its in-flight fetchArtifact download, while the + // fence remains the synchronous-section channel. A post-COMMITTED cancel + // loses the CAS and is never posted, so the live child keeps its close. + try { worker.postMessage({ type: "cancel", requestId, jobId }); } catch { /* already closing */ } + return true; + }; + liveJobs.set(jobId, { + cancel, + settled: () => state.settled, + workerClosed: () => state.closed, + }); + const signal = options.signal; + const onAbort = () => { cancel(); }; + try { + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + const message: CodexCliUpdateWorkerRunMessage = { + type: "run", + requestId, + jobId, + operation, + ...(fields.planId !== undefined ? { planId: fields.planId } : {}), + ...(fields.channel !== undefined ? { channel: fields.channel } : {}), + snapshot, + gate: codexCliUpdateSelectionGate(), + fence, + ...(options.env ? { env: options.env } : {}), + }; + return await dispatchWorker(worker, message, options, state); + } finally { + liveJobs.delete(jobId); + if (signal) signal.removeEventListener("abort", onAbort); + } +} + +/** + * Run one confirmed apply in a fresh Worker. Results are the worker realm's + * own CodexCliApplyResult verbatim; dispatch failures synthesize the two + * honest fallbacks (refused preparation_failed only when no job code provably + * ran, otherwise ambiguous job_result_unknown with the lease unknown). + */ +export async function runCodexCliUpdateApplyInWorker( + planId: string, + options: CodexCliUpdateWorkerSpawnOptions & { readonly jobId?: string } = {}, +): Promise { + const settled = await runInWorker("apply", { planId }, options); + if (settled.kind === "reply" && settled.reply.type === "done" && settled.reply.operation === "apply") { + return settled.reply.result; + } + return applyFallback(planId, settled); +} + +/** + * Run one read-only plan in a fresh Worker. There is no ledger for a plan, so + * a dispatch failure surfaces exactly like today's thrown inspection error: + * the route translates it into plan_unavailable. + */ +export async function runCodexCliUpdatePlanInWorker( + options: CodexCliUpdateWorkerSpawnOptions & { readonly channel?: CodexCliUpdateChannel } = {}, +): Promise { + const settled = await runInWorker("plan", { channel: options.channel ?? "latest" }, options); + if (settled.kind === "reply" && settled.reply.type === "done" && settled.reply.operation === "plan") { + return settled.reply.plan; + } + throw new Error("plan_unavailable"); +} diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts new file mode 100644 index 00000000000..0e8d1821c6f --- /dev/null +++ b/src/codex/cli-update-job.ts @@ -0,0 +1,332 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync, closeSync, constants, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, + readFileSync, readSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { getConfigDir } from "../config"; +import type { CodexCliApplyResult } from "./cli-update-apply"; +import { CODEX_CLI_JOB_REASONS as SAFE_REASONS, CODEX_CLI_JOB_CLEANUP_ERRORS, + CodexCliJobStoreBusyError, normalizeCodexCliJobResult, runCodexCliUpdateJobWork } from "./cli-update-job-execution"; + +const MAX_BYTES = 256 * 1024; +const MAX_JOBS = 64; +const JOB_DIR = "codex-cli-update-jobs"; +const FILE = "jobs.json"; +const LOCK = "admission.lock"; +type JobStatus = "queued" | "running" | "applied" | "not_applied" | "refused" | "ambiguous"; + +interface StoredJob { + id: string; requestKey: string; payloadHash: string; generation: string; revision: number; + status: JobStatus; createdAt: string; updatedAt: string; planId: string; + outcome: CodexCliApplyResult["outcome"] | null; reason: string | null; + cleanupErrors: readonly string[]; leaseRetained: boolean; +} +interface Store { version: 1; jobs: StoredJob[] } +export interface CodexCliUpdateJobDto { + readonly jobId: string; readonly status: "queued" | "running" | "applied" | "not_applied" | "refused" | "ambiguous"; + readonly ownerState: "active" | "unknown" | "finished"; readonly lifetimeUncertain: boolean; + readonly childState: "not_started" | "active_or_not_observed" | "terminal" | "unknown"; + readonly leaseState: "not_acquired_or_released" | "retained" | "unknown"; + readonly createdAt: string; readonly updatedAt: string; readonly planId: string; + readonly outcome: CodexCliApplyResult["outcome"] | null; readonly reason: string | null; + readonly cleanupErrors: readonly string[]; +} + +const live = new Map(); +const started = new Set(); +const digest = (text: string) => createHash("sha256").update(text).digest("hex"); +const now = () => new Date().toISOString(); + +function paths(configDir = getConfigDir()) { + const config = resolve(configDir); + const dir = join(config, JOB_DIR); + return { config, dir, file: join(dir, FILE), lock: join(dir, LOCK) }; +} +function assertTrustedConfigAncestry(parent: string) { + const euid = process.geteuid?.(); + if (euid === undefined) throw new Error("unsupported_store"); + let current = resolve(parent); + for (;;) { + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.uid !== euid && stat.uid !== 0) + || realpathSync.native(current) !== current) throw new Error("unsafe_store"); + const sharedWritable = (stat.mode & 0o022) !== 0; + const stickyRootDirectory = (stat.mode & 0o1000) !== 0 && stat.uid === 0; + if (sharedWritable && !stickyRootDirectory) throw new Error("unsafe_store"); + const ancestor = dirname(current); + if (ancestor === current) return; + current = ancestor; + } +} +function prepare(configDir?: string, create = false, createConfigRoot = false) { + const p = paths(configDir); + if (process.platform === "win32" || process.geteuid?.() === undefined) throw new Error("unsupported_store"); + const parent = dirname(p.config); + const parentStat = lstatSync(parent); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink() || parentStat.uid !== process.geteuid() + || (parentStat.mode & 0o022) !== 0 || (parentStat.mode & 0o300) !== 0o300 + || realpathSync.native(parent) !== parent) throw new Error("unsafe_store"); + assertTrustedConfigAncestry(parent); + let configStat: ReturnType; + try { configStat = lstatSync(p.config); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !createConfigRoot) throw error; + const parentIdentity = { dev: parentStat.dev, ino: parentStat.ino }; + let created = false; + try { mkdirSync(p.config, { mode: 0o700 }); created = true; } + catch (mkdirError) { if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; } + const parentAfter = lstatSync(parent); + if (parentAfter.dev !== parentIdentity.dev || parentAfter.ino !== parentIdentity.ino + || !parentAfter.isDirectory() || parentAfter.isSymbolicLink() || parentAfter.uid !== process.geteuid() + || (parentAfter.mode & 0o022) !== 0 || (parentAfter.mode & 0o300) !== 0o300 + || realpathSync.native(parent) !== parent) { + throw new Error("unsafe_store"); + } + configStat = lstatSync(p.config); + if (!configStat.isDirectory() || configStat.isSymbolicLink() || configStat.uid !== process.geteuid() + || realpathSync.native(p.config) !== p.config) throw new Error("unsafe_store"); + if (created && ((configStat.mode & 0o077) !== 0 || (configStat.mode & 0o700) !== 0o700)) { + throw new Error("unsafe_store"); + } + configStat = lstatSync(p.config); + } + if (!configStat.isDirectory() || configStat.isSymbolicLink() || realpathSync.native(p.config) !== p.config + || (configStat.mode & 0o022) !== 0) throw new Error("unsafe_store"); + if (create) { + if (configStat.uid !== process.geteuid()) throw new Error("unsupported_store"); + try { mkdirSync(p.dir, { mode: 0o700 }); chmodSync(p.dir, 0o700); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + } + const stat = lstatSync(p.dir); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== configStat.uid || (stat.mode & 0o077) !== 0 + || realpathSync.native(p.dir) !== p.dir) throw new Error("unsafe_store"); + return p; +} +function readStore(configDir?: string): Store { + let p: ReturnType; + try { p = prepare(configDir); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, jobs: [] }; throw error; } + let fd: number | undefined; + try { + fd = openSync(p.file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_BYTES || (stat.mode & 0o077) !== 0 + || stat.uid !== process.geteuid?.() || stat.nlink !== 1) throw new Error("unsafe_store"); + const lexical = lstatSync(p.file); + if (lexical.isSymbolicLink() || lexical.dev !== stat.dev || lexical.ino !== stat.ino) throw new Error("unsafe_store"); + const bytes = Buffer.alloc(MAX_BYTES + 1); + const length = readSync(fd, bytes, 0, bytes.length, 0); + if (length > MAX_BYTES) throw new Error("unsafe_store"); + const value = JSON.parse(bytes.subarray(0, length).toString("utf8")) as Store; + if (value.version !== 1 || !Array.isArray(value.jobs) || value.jobs.length > MAX_JOBS + || value.jobs.some(job => !job || typeof job !== "object" || !/^[0-9a-f-]{36}$/.test(job.id) + || !/^[0-9a-f]{64}$/.test(job.requestKey) || !/^[0-9a-f]{64}$/.test(job.payloadHash) + || !/^[0-9a-f-]{36}$/.test(job.generation) || !Number.isSafeInteger(job.revision) || job.revision < 0 + || !["queued", "running", "applied", "not_applied", "refused", "ambiguous"].includes(job.status) + || typeof job.planId !== "string" || !/^[0-9a-f]{32}$/.test(job.planId) + || (job.reason !== null && (typeof job.reason !== "string" || !SAFE_REASONS.has(job.reason))) + || !Array.isArray(job.cleanupErrors) || job.cleanupErrors.length > 8 + || job.cleanupErrors.some((item: unknown) => typeof item !== "string" || !CODEX_CLI_JOB_CLEANUP_ERRORS.has(item)) + || typeof job.leaseRetained !== "boolean" + || typeof job.createdAt !== "string" || typeof job.updatedAt !== "string")) throw new Error("invalid_store"); + return value; + } finally { if (fd !== undefined) closeSync(fd); } +} +function rollbackInitializingLock(p: ReturnType, lockIdentity: { dev: number; ino: number }, ownerIdentity?: { dev: number; ino: number }) { + try { + const ownsDirectory = () => { + const stat = lstatSync(p.lock); + return stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === lockIdentity.dev && stat.ino === lockIdentity.ino + && stat.uid === process.geteuid?.() && (stat.mode & 0o077) === 0 && realpathSync.native(p.lock) === p.lock; + }; + if (!ownsDirectory()) return; + if (ownerIdentity) { + const owner = join(p.lock, "owner.json"); + const fd = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd), lexical = lstatSync(owner); + if (!stat.isFile() || stat.uid !== process.geteuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 + || stat.dev !== ownerIdentity.dev || stat.ino !== ownerIdentity.ino || lexical.isSymbolicLink() + || lexical.dev !== stat.dev || lexical.ino !== stat.ino || !ownsDirectory()) return; + } finally { closeSync(fd); } + unlinkSync(owner); + } + // This call created the directory. Never remove a replacement or nonempty lock. + if (ownsDirectory()) rmdirSync(p.lock); + } catch { /* Unprovable initialization ownership stays fail-closed. */ } +} +function withLock(configDir: string | undefined, fn: (p: ReturnType) => T, createConfigRoot = false): T { + const p = prepare(configDir, true, createConfigRoot); + let lockIdentity: { dev: number; ino: number } | undefined; + let ownerIdentity: { dev: number; ino: number } | undefined; + const owner = join(p.lock, "owner.json"); + const record = JSON.stringify({ version: 1, token: randomUUID() }); + try { + try { mkdirSync(p.lock, { mode: 0o700 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new CodexCliJobStoreBusyError(); + throw error; + } + const lockStat = lstatSync(p.lock); + if (!lockStat.isDirectory() || lockStat.isSymbolicLink() || lockStat.uid !== process.geteuid?.() + || (lockStat.mode & 0o077) !== 0 || realpathSync.native(p.lock) !== p.lock) throw new Error("unsafe_store"); + lockIdentity = { dev: lockStat.dev, ino: lockStat.ino }; + try { + const fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { ownerIdentity = fstatSync(fd); writeFileSync(fd, record); fsyncSync(fd); } finally { closeSync(fd); } + } catch (error) { + rollbackInitializingLock(p, lockIdentity, ownerIdentity); + lockIdentity = undefined; + throw error; + } + return fn(p); + } finally { + if (lockIdentity && ownerIdentity) { + try { + const dir = lstatSync(p.lock); + const fd = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd); + if (dir.dev === lockIdentity.dev && dir.ino === lockIdentity.ino && stat.isFile() + && stat.size <= 1024 && stat.uid === process.geteuid?.() && stat.nlink === 1 + && stat.dev === ownerIdentity.dev && stat.ino === ownerIdentity.ino + && readFileSync(fd, "utf8") === record) { + unlinkSync(owner); rmdirSync(p.lock); + } + } finally { closeSync(fd); } + } catch { /* Fail closed; a leftover lock blocks future admission. */ } + } + } +} +function writeStore(p: ReturnType, store: Store) { + const body = JSON.stringify(store); + if (Buffer.byteLength(body) > MAX_BYTES) throw new Error("store_full"); + const temp = join(p.dir, `.jobs-${randomUUID()}.tmp`); + let fd: number | undefined; + try { + fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + writeFileSync(fd, body); fsyncSync(fd); closeSync(fd); fd = undefined; + renameSync(temp, p.file); + const dirFd = openSync(p.dir, constants.O_RDONLY); + try { fsyncSync(dirFd); } finally { closeSync(dirFd); } + } finally { + if (fd !== undefined) closeSync(fd); + try { unlinkSync(temp); } catch { /* already renamed */ } + } +} +function validStore(configDir?: string): Store { + try { return readStore(configDir); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, jobs: [] }; + throw error; + } +} +type LifecycleInput = Pick & { active: boolean }; +type LifecycleProjection = Pick; + +/** Project only states supported by persisted evidence; retention booleans alone are ambiguous. */ +export function projectCodexCliUpdateJobLifecycle(job: LifecycleInput): LifecycleProjection { + const ownerUnknown = job.status === "queued" || job.status === "running" ? !job.active : false; + const uncertain = ownerUnknown || job.outcome === "ambiguous"; + const leaseReleaseUnconfirmed = job.cleanupErrors.some(error => + error === "lease_release_unverified" || error === "lease_release_exception"); + const retainedWithEvidence = job.leaseRetained && !leaseReleaseUnconfirmed + && (job.reason === "installer_state_unknown" || job.reason === "version_process_state_unknown" + || job.reason === "installer_or_readback_error"); + const childUnknown = ownerUnknown || job.reason === "job_result_unknown" + || job.reason === "installer_state_unknown" || job.reason === "version_process_state_unknown" + || (job.reason === "installer_or_readback_error" && job.leaseRetained); + const childState = childUnknown ? "unknown" : job.status === "queued" || job.outcome === "refused" ? "not_started" + : job.status === "running" ? "active_or_not_observed" : "terminal"; + const leaseState = ownerUnknown || job.status === "queued" || job.status === "running" + || job.reason === "job_result_unknown" || leaseReleaseUnconfirmed ? "unknown" + : retainedWithEvidence ? "retained" : job.leaseRetained ? "unknown" : "not_acquired_or_released"; + return Object.freeze({ status: uncertain ? "ambiguous" : job.status, + ownerState: job.active ? "active" : ownerUnknown ? "unknown" : "finished", lifetimeUncertain: uncertain, + childState, leaseState, outcome: ownerUnknown ? "ambiguous" : job.outcome, + reason: ownerUnknown ? "owner_state_unknown" : job.reason }); +} + +function dto(job: StoredJob): CodexCliUpdateJobDto { + const lifecycle = projectCodexCliUpdateJobLifecycle({ ...job, active: live.get(job.id) === job.generation }); + return Object.freeze({ jobId: job.id, ...lifecycle, + createdAt: job.createdAt, updatedAt: job.updatedAt, planId: job.planId, + cleanupErrors: Object.freeze([...job.cleanupErrors]) }); +} + +export function readCodexCliUpdateJob(jobId: string, configDir?: string): CodexCliUpdateJobDto | null { + if (!/^[0-9a-f-]{36}$/.test(jobId)) return null; + const job = readStore(configDir).jobs.find(item => item.id === jobId); + return job ? dto(job) : null; +} + +/** Persist dedupe admission before returning; a duplicate key never schedules a second installer. */ +export function admitCodexCliUpdateJob(input: { requestId: string; planId: string; confirmed: true; configDir?: string }): + { kind: "accepted" | "duplicate"; job: CodexCliUpdateJobDto; generation: string } { + if (input.confirmed !== true || !/^[A-Za-z0-9._:-]{8,128}$/.test(input.requestId) + || !/^[0-9a-f]{32}$/.test(input.planId)) throw new Error("invalid_request"); + const requestKey = digest(input.requestId); + const payloadHash = digest(JSON.stringify({ planId: input.planId })); + return withLock(input.configDir, p => { + const store = validStore(input.configDir); + const existing = store.jobs.find(job => job.requestKey === requestKey); + if (existing) { + if (existing.payloadHash !== payloadHash) throw new Error("idempotency_conflict"); + return { kind: "duplicate", job: dto(existing), generation: existing.generation }; + } + if (store.jobs.length >= MAX_JOBS) throw new Error("store_full"); + const stamp = now(); + const record: StoredJob = { id: randomUUID(), requestKey, payloadHash, generation: randomUUID(), revision: 0, + status: "queued", createdAt: stamp, updatedAt: stamp, planId: input.planId, outcome: null, + reason: null, cleanupErrors: [], leaseRetained: false }; + store.jobs.push(record); writeStore(p, store); + live.set(record.id, record.generation); + return { kind: "accepted", job: dto(record), generation: record.generation }; + }, true); +} + +/** Updates are generation/revision fenced so an old worker cannot replace a newer state. */ +export function finishCodexCliUpdateJob(jobId: string, generation: string, expectedRevision: number, + result: CodexCliApplyResult, configDir?: string): CodexCliUpdateJobDto | null { + return withLock(configDir, p => { + const store = validStore(configDir); + const index = store.jobs.findIndex(job => job.id === jobId); + if (index < 0) return null; + const current = store.jobs[index]!; + const safe = normalizeCodexCliJobResult(result); + // A rename can succeed before a directory-fsync error. Retry the same + // completed revision while this generation still owns it, never the engine. + if (current.generation === generation && current.revision === expectedRevision + 1 + && live.get(jobId) === generation && started.has(jobId) + && current.outcome === safe.outcome && current.reason === safe.reason + && current.leaseRetained === safe.leaseRetained + && JSON.stringify(current.cleanupErrors) === JSON.stringify(safe.cleanupErrors)) { + writeStore(p, store); live.delete(jobId); started.delete(jobId); return dto(current); + } + if (current.generation !== generation || current.revision !== expectedRevision + || live.get(jobId) !== generation || !started.has(jobId)) return dto(current); + const next: StoredJob = { ...current, revision: current.revision + 1, status: safe.outcome, + outcome: safe.outcome, reason: safe.reason, cleanupErrors: safe.cleanupErrors, leaseRetained: safe.leaseRetained, updatedAt: now() }; + store.jobs[index] = next; writeStore(p, store); live.delete(jobId); started.delete(jobId); + return dto(next); + }); +} + +export async function runCodexCliUpdateJob(jobId: string, generation: string, run: () => Promise, configDir?: string): Promise { + let ownsExecution = false; + return runCodexCliUpdateJobWork(run, { + begin: () => withLock(configDir, p => { + const store = validStore(configDir); const index = store.jobs.findIndex(job => job.id === jobId); + if (index < 0 || store.jobs[index]!.generation !== generation) throw new Error("job_generation_changed"); + if (live.get(jobId) !== generation || started.has(jobId)) throw new Error("job_not_owned"); + const current = store.jobs[index]!; const next = { ...current, revision: current.revision + 1, status: "running" as const, updatedAt: now() }; + store.jobs[index] = next; writeStore(p, store); started.add(jobId); ownsExecution = true; return next.revision; + }), + finish: (revision, result) => finishCodexCliUpdateJob(jobId, generation, revision, result, configDir), + read: () => readCodexCliUpdateJob(jobId, configDir), + release: () => { + if (live.get(jobId) === generation && (ownsExecution || !started.has(jobId))) { + live.delete(jobId); if (ownsExecution) started.delete(jobId); + } + }, + }); +} diff --git a/src/codex/cli-update-lease.ts b/src/codex/cli-update-lease.ts new file mode 100644 index 00000000000..90de2d5e705 --- /dev/null +++ b/src/codex/cli-update-lease.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, + realpathSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type CodexCliApplyLeaseResult = { readonly kind: "acquired"; release(): boolean } + | { readonly kind: "busy" | "prefix_unsupported" | "unavailable" }; +/** Installation-unit mutex. No stale/empty-owner recovery and no token delegation. */ +export function acquireCodexCliApplyLease(prefix: string): CodexCliApplyLeaseResult { + let fd: number | undefined; + let lockIdentity: { dev: number; ino: number } | undefined; + let ownerIdentity: { dev: number; ino: number } | undefined; + const path = join(prefix, ".ocx-codex-cli-apply.lock"); + const owner = join(path, "owner.json"); + const record = JSON.stringify({ version: 1, pid: process.pid, token: randomUUID() }); + const release = () => { + let current: number | undefined; + let released = false; + try { + if (!lockIdentity || !ownerIdentity) return false; + const directory = lstatSync(path); + if (!directory.isDirectory() || directory.isSymbolicLink() + || directory.dev !== lockIdentity.dev || directory.ino !== lockIdentity.ino) return false; + current = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const identity = fstatSync(current); + if (!identity.isFile() || identity.size !== Buffer.byteLength(record) + || identity.dev !== ownerIdentity.dev || identity.ino !== ownerIdentity.ino + || readFileSync(current, "utf8") !== record) return false; + const lexical = lstatSync(owner); + if (lexical.isSymbolicLink() || lexical.dev !== identity.dev || lexical.ino !== identity.ino) return false; + unlinkSync(owner); + rmdirSync(path); + lockIdentity = undefined; + released = true; + } catch { return false; } + finally { + if (current !== undefined) { try { closeSync(current); } catch { released = false; } } + } + return released; + }; + try { + const root = lstatSync(prefix); + if (!root.isDirectory() || root.isSymbolicLink() || realpathSync.native(prefix) !== prefix + || (root.mode & 0o022) !== 0 || root.uid !== process.geteuid?.()) return { kind: "prefix_unsupported" }; + try { mkdirSync(path, { mode: 0o700 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return { kind: "busy" }; + throw error; + } + lockIdentity = lstatSync(path); + fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + ownerIdentity = fstatSync(fd); + writeFileSync(fd, record); + const completed = fd; + fd = undefined; + closeSync(completed); + return { kind: "acquired", release }; + } catch { + if (fd !== undefined) { + const owned = fd; fd = undefined; + try { closeSync(owned); } catch { return { kind: "unavailable" }; } + } + // Only a positively identified owner is eligible for cleanup. Empty or + // suspicious directories intentionally remain busy for operator inspection. + if (ownerIdentity) release(); + return { kind: "unavailable" }; + } +} diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index d3c4835b749..12e136f422b 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -1,4 +1,4 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { execFile, type ExecFileException } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -7,6 +7,9 @@ import { join, resolve } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs"; +import { CODEX_CLI_REGISTRY, isSha512Integrity, codexCliArtifactSetAsync, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; +export { CODEX_CLI_REGISTRY } from "./cli-update-artifacts"; +import { windowsCodexCliPlanEvidence, withWindowsCodexCliPlanToolchain } from "./cli-windows-plan-provenance"; import { inspectCodexCliInstall, type CodexCliInstallKind, @@ -14,7 +17,7 @@ import { type CodexCliInstallReport, } from "./cli-install-provenance"; import { - scanCodexSessionProcesses, + scanCodexSessionProcessesAsync, type CodexAppServerProcessIo, type CodexAppServerProcessScan, } from "./app-server-processes"; @@ -43,23 +46,12 @@ import { export const CODEX_CLI_PACKAGE = "@openai/codex"; export const CODEX_CLI_UPDATE_SCHEMA_VERSION = 1 as const; -/** - * The one registry this workflow is allowed to resolve, pack and install from. - * - * npm view/pack inherit the operator's npm configuration by default, so a project or - * user .npmrc — or an npm_config_* env var — can redirect "@openai/codex" to another - * registry whose version query, integrity query and tarball all agree with each - * other. Every npm call below pins this registry explicitly AND runs with the - * registry-affecting configuration isolated, so the pinned origin is the only source - * the evidence can come from. - */ -export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org"; - /** Only the stable channel is exposed; a moving dist-tag is resolved before it is used. */ export type CodexCliUpdateChannel = "latest"; export type CodexCliUpdateRefusal = | "windows_inspection_deferred" + | "windows_apply_disabled" | "not_managed" | "installed_version_unverified" | "install_content_unverified" @@ -83,7 +75,7 @@ export interface CodexCliUpdateSession { } export type CodexCliUpdateTarget = - | Readonly<{ kind: "resolved"; version: string; integrity: string }> + | Readonly<{ kind: "resolved"; version: string; integrity: string; artifacts?: CodexCliArtifactSet }> | Readonly<{ kind: "unresolved"; reason: string }>; export interface CodexCliUpdatePlan { @@ -92,7 +84,7 @@ export interface CodexCliUpdatePlan { readonly channel: CodexCliUpdateChannel; readonly applicable: boolean; readonly refusal: CodexCliUpdateRefusal | null; - /** Present only for an applicable plan; there is nothing to quote for a refusal. */ + /** Also present for a proven Windows read-only plan whose apply remains disabled. */ readonly planId: string | null; readonly provenance: CodexCliInstallKind; readonly managed: boolean; @@ -101,6 +93,7 @@ export interface CodexCliUpdatePlan { readonly location: string | null; readonly targetVersion: string | null; readonly targetIntegrity: string | null; + readonly targetArtifacts?: CodexCliArtifactSet; readonly session: CodexCliUpdateSession; /** * The bounded content observation the installation report produced: digest of @@ -119,25 +112,17 @@ export interface CodexCliUpdatePlanDeps { readonly platform?: NodeJS.Platform; readonly channel?: CodexCliUpdateChannel; readonly scanProcesses?: (io?: CodexAppServerProcessIo) => CodexAppServerProcessScan; + /** Preferred request-path seam: the real session scan runs its child processes async. */ + readonly scanProcessesAsync?: (io?: CodexAppServerProcessIo) => Promise; readonly processIo?: CodexAppServerProcessIo; - readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget; - readonly spawnProcess?: typeof spawnSync; + readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget | Promise; + readonly spawnProcess?: CodexCliUpdateSpawn; } const PLAN_ID_LENGTH = 32; const PLAN_ID_RE = /^[0-9a-f]{32}$/; const REGISTRY_TIMEOUT_MS = 12_000; -const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/; - -function isSha512Integrity(token: string): boolean { - if (!SHA512_INTEGRITY_RE.test(token)) return false; - const encoded = token.slice("sha512-".length); - // Base64 decoders accept truncated input and nonzero unused padding bits. - // Require exactly 64 bytes and the canonical encoding of those same bytes. - const digest = Buffer.from(encoded, "base64"); - return digest.byteLength === 64 && digest.toString("base64") === encoded; -} /** The install argv this plan quotes for the operator to read, in argv form. */ export function codexCliUpdateCommand(version: string): readonly string[] { @@ -170,6 +155,7 @@ export function codexCliUpdatePlanId(bound: { readonly channel: CodexCliUpdateChannel; readonly targetVersion: string; readonly targetIntegrity: string; + readonly targetArtifactsDigest?: string; }): string { // Ordered pairs, not object key order: the digest must not depend on how a caller // happened to build the record. @@ -187,6 +173,7 @@ export function codexCliUpdatePlanId(bound: { ]; const hash = createHash("sha256"); for (const [key, value] of fields) hash.update(`${key}=${value}\n`); + if (bound.targetArtifactsDigest) hash.update(`targetArtifactsDigest=${bound.targetArtifactsDigest}\n`); return hash.digest("hex").slice(0, PLAN_ID_LENGTH); } @@ -317,7 +304,7 @@ interface NpmConfigIsolation { * and the env filter removes the env-var channel. What remains is exactly the pinned * --registry flag plus proxy/CA values captured before project dotenv loading. */ -function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation { +export function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation { const ownsRoot = dir === undefined; const root = dir ?? mkdtempSync(join(tmpdir(), "ocx-codex-cli-meta-")); try { @@ -330,6 +317,7 @@ function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): N const env = codexCliUpdateBoundEnv(boundEnv); // npm enables Node's compile cache itself. Dropping the ambient output path // alone recreates /node-compile-cache outside this owned lifetime. + // The version verification child inherits this same controlled path. env.NODE_COMPILE_CACHE = resolve(root, "node-compile-cache"); return { dir: root, npmrc, globalNpmrc, cache, env }; } catch (error) { @@ -351,7 +339,7 @@ function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation) ]; } -function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null { +export function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null { const target = npmTarget(isolatedNpmArgs(args, isolation), isolation.env); if (!target) return null; return { @@ -361,6 +349,96 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio }; } +/** The option fields one registry-metadata query passes to its spawn. */ +export interface CodexCliUpdateSpawnOptions { + readonly encoding: "utf8"; + readonly timeout: number; + readonly maxBuffer?: number; + readonly windowsHide: true; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly windowsVerbatimArguments?: boolean; +} + +/** The fields a resolver reads off one spawn result; sync injections may carry more. */ +export interface CodexCliUpdateSpawnResult { + readonly status: number | null; + readonly stdout: string; +} + +/** + * One registry-metadata invocation. The production runner is asynchronous so a slow + * npm cannot park the proxy event loop; a synchronous SpawnSync-shaped injection + * remains assignable, so tests asserting argv/env without real I/O are unchanged. + */ +export type CodexCliUpdateSpawn = ( + bin: string, + args: readonly string[], + options: CodexCliUpdateSpawnOptions, +) => CodexCliUpdateSpawnResult | Promise; + +/** + * A single bounded registry child whose wait happens off the event loop. + * + * The previous spawnSync blocked the whole proxy for up to the per-query deadline: + * a slow registry stalled unrelated requests and streaming for seconds at a time, + * and the background apply repeated the same stall. The observable contract is + * unchanged — status null on timeout or spawn failure, stdout bounded by + * maxBuffer — but only the awaited caller observes the child. + */ +export function spawnCodexCliUpdateMetadata( + bin: string, + args: readonly string[], + options: CodexCliUpdateSpawnOptions, +): Promise { + return new Promise(resolve => { + let timedOut = false; + let deadline: ReturnType | undefined; + let child: ReturnType; + try { + // execFile's callback is the only terminal observation: it runs after the + // child terminated and its piped stdio was fully consumed, so this result — + // and the caller's isolation cleanup in its finally — never outrun a live + // child, and no arbitrary reap window can fake completion. encoding "utf8" + // decodes the buffered output once, so a multibyte character split across + // OS pipe chunks stays intact, while maxBuffer bounds both captured + // streams in bytes exactly like spawnSync did. This proves only the + // child's own termination; any descendants it left behind are outside the + // observation, as they were under spawnSync. + child = execFile(bin, [...args], { + encoding: "utf8", + timeout: options.timeout, + maxBuffer: options.maxBuffer ?? 1024 * 1024, + killSignal: "SIGKILL", + windowsHide: true, + cwd: options.cwd, + env: options.env, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + }, (error: ExecFileException | null, stdout: string) => { + if (deadline) clearTimeout(deadline); + // spawnSync reported status null whenever the child was signal-killed + // (timeout or maxBuffer) or never spawned; a numeric error.code is the + // plain exit code. timedOut is latched so a late code-0 close after the + // deadline still refuses, matching the synchronous timeout lane. + const status = timedOut ? null + : error === null ? 0 + : typeof error.code === "number" ? error.code : null; + resolve(Object.freeze({ status, stdout })); + }); + } catch { + resolve(Object.freeze({ status: null, stdout: "" })); + return; + } + // A deadline of our own keeps the same per-query bound even on a runtime + // that does not honor execFile's timeout option. It only latches the + // refusal and signals the child; resolution still waits for the callback. + deadline = setTimeout(() => { + timedOut = true; + try { child.kill("SIGKILL"); } catch { /* the close event still settles */ } + }, options.timeout); + }); +} + /** * Resolve the channel to one exact version plus its sha512 integrity token. * @@ -369,12 +447,12 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio * acceptable for a foreign package we are about to install on the operator's behalf, so * every failure lane here is a refusal. */ -export function resolveCodexCliUpdateTarget( +export async function resolveCodexCliUpdateTarget( channel: CodexCliUpdateChannel, - spawn: typeof spawnSync = spawnSync, + spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, /** Proof-bound pre-dotenv PATH/PATHEXT and proxy/CA env; absent settings stay absent. */ boundEnv?: NodeJS.ProcessEnv, -): CodexCliUpdateTarget { +): Promise { // The pinned registry and the isolation directory together are the boundary: the // version, the integrity token and the tarball URL all come from npmjs or the // target is unresolved — a redirected answer can never be the evidence. @@ -387,7 +465,7 @@ export function resolveCodexCliUpdateTarget( return Object.freeze({ kind: "unresolved" as const, reason: "npm configuration isolation setup failed" }); } try { - const runView = (field: string, spec: string): SpawnSyncReturns | null => { + const runView = async (field: string, spec: string): Promise => { const target = isolatedNpmTarget(["view", spec, field], isolation); if (!target) return null; return spawn(target.bin, target.args, { @@ -398,7 +476,7 @@ export function resolveCodexCliUpdateTarget( }); }; - const versionRun = runView("version", `${CODEX_CLI_PACKAGE}@${channel}`); + const versionRun = await runView("version", `${CODEX_CLI_PACKAGE}@${channel}`); if (!versionRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); // status === null covers a timeout as well as a spawn failure. if (versionRun.status !== 0) { @@ -409,7 +487,7 @@ export function resolveCodexCliUpdateTarget( return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no exact version" }); } - const integrityRun = runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`); + const integrityRun = await runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`); if (!integrityRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); if (integrityRun.status !== 0) { return Object.freeze({ kind: "unresolved" as const, reason: `registry integrity query failed (status ${integrityRun.status ?? "timeout"})` }); @@ -424,7 +502,7 @@ export function resolveCodexCliUpdateTarget( // The digest binds the tarball's CONTENT; this binds its ORIGIN. A registry // answer whose tarball lives outside the pinned registry is not the artifact // the plan approved, even if its sha512 matched. - const tarballRun = runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`); + const tarballRun = await runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`); if (!tarballRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); if (tarballRun.status !== 0) { return Object.freeze({ kind: "unresolved" as const, reason: `registry tarball query failed (status ${tarballRun.status ?? "timeout"})` }); @@ -447,6 +525,39 @@ export function resolveCodexCliUpdateTarget( } } +/** Resolve both exact archives, including the publisher's platform-package alias. */ +export async function resolveCodexCliUpdateArtifactTarget(channel: CodexCliUpdateChannel, + spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, boundEnv?: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, arch: string = process.arch, + windowsToolchain?: { readonly node: string; readonly npmCli: string }): Promise { + let isolation: NpmConfigIsolation | undefined; + try { + isolation = createNpmConfigIsolation(undefined, boundEnv); + const query = async (spec: string) => { + const args = isolatedNpmArgs(["view", spec, "--json"], isolation!); + const invocation = windowsToolchain + ? { bin: windowsToolchain.node, args: [windowsToolchain.npmCli, ...args], options: { cwd: isolation!.dir, env: isolation!.env } } + : isolatedNpmTarget(["view", spec, "--json"], isolation!); + if (!invocation) throw new Error("npm unavailable"); + const result = await spawn(invocation.bin, invocation.args, { encoding: "utf8", timeout: REGISTRY_TIMEOUT_MS, + maxBuffer: 1024 * 1024, windowsHide: true, ...invocation.options }); + if (result.status !== 0) throw new Error("registry query failed"); + return JSON.parse(result.stdout); + }; + const latest = await query(`${CODEX_CLI_PACKAGE}@${channel}`); + if (!parseStrictSemver(latest.version)) throw new Error("target version unavailable"); + const pinned = await query(`${CODEX_CLI_PACKAGE}@${latest.version}`); + if (pinned.version !== latest.version) throw new Error("target version drift"); + const artifacts = await codexCliArtifactSetAsync(pinned, query, platform, arch); + return Object.freeze({ kind: "resolved", version: artifacts.main.version, + integrity: artifacts.main.integrity, artifacts }); + } catch { + return Object.freeze({ kind: "unresolved", reason: "main/platform artifact metadata unverified" }); + } finally { + if (isolation) { try { rmSync(isolation.dir, { recursive: true }); } catch { /* owned metadata residue */ } } + } +} + function refusedPlan( refusal: CodexCliUpdateRefusal, report: CodexCliInstallReport, @@ -494,11 +605,10 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} // pays the bounded content read; a bare ownership check leaves it unobserved. const report = await inspect({ ...(deps.inspectionDeps ?? {}), observeInstallContent: true }); - // Phase 1 performs no candidate filesystem I/O at all on Windows, so there is no - // ownership evidence to build a plan on. This stays dormant until the handle-bound - // provenance layer exists; pretending otherwise would require exactly the reads phase 1 - // refused to perform. - if (platform === "win32" || report.reason === "windows_inspection_deferred") { + // Windows planning requires private evidence from the production handle-bound + // predicate. A synthetic/copyable public managed flag cannot open this lane. + const windowsEvidence = windowsCodexCliPlanEvidence(report); + if ((platform === "win32" && !windowsEvidence) || report.reason === "windows_inspection_deferred") { return refusedPlan("windows_inspection_deferred", report, channel, null, NOT_EVALUATED); } if (!report.managed || report.provenance !== "npm-global") { @@ -525,8 +635,12 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} // When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed // { PATH: "" } form, so this resolution cannot silently fall back to ambient. const resolveTarget = deps.resolveTarget - ?? (ch => resolveCodexCliUpdateTarget(ch, deps.spawnProcess ?? spawnSync, deps.inspectionDeps?.env)); - const target = resolveTarget(channel); + ?? (ch => resolveCodexCliUpdateArtifactTarget(ch, deps.spawnProcess ?? spawnCodexCliUpdateMetadata, deps.inspectionDeps?.env, + platform, windowsEvidence?.arch ?? deps.inspectionDeps?.arch ?? process.arch, windowsEvidence ?? undefined)); + const target = windowsEvidence + ? await withWindowsCodexCliPlanToolchain(report, () => resolveTarget(channel)) + ?? Object.freeze({ kind: "unresolved" as const, reason: "Windows toolchain identity changed" }) + : await resolveTarget(channel); if (target.kind !== "resolved") { return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); } @@ -545,7 +659,13 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("target_not_newer", report, channel, target, NOT_EVALUATED); } - const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + // The request-path scan keeps registry and process-table waits off the event loop; + // a synchronous injection remains honored for tests that never spawn a real child. + const scan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); if (scan.kind !== "observed") { // An unreadable process table is not "no sessions". `listCodexAppServerProcesses` // maps that failure to an empty list because its kill contract must never signal a @@ -561,8 +681,8 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} schemaVersion: CODEX_CLI_UPDATE_SCHEMA_VERSION, package: CODEX_CLI_PACKAGE, channel, - applicable: true, - refusal: null, + applicable: platform !== "win32", + refusal: platform === "win32" ? "windows_apply_disabled" : null, planId: codexCliUpdatePlanId({ platform, provenance: report.provenance, @@ -572,6 +692,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} channel, targetVersion: target.version, targetIntegrity: target.integrity, + targetArtifactsDigest: target.artifacts ? codexCliArtifactSetDigest(target.artifacts) : undefined, }), provenance: report.provenance, managed: report.managed, @@ -580,6 +701,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} location: report.location, targetVersion: target.version, targetIntegrity: target.integrity, + ...(target.artifacts ? { targetArtifacts: target.artifacts } : {}), session: Object.freeze({ state: "none" as const, matches: 0 }), installContentDigest: report.installContentDigest, command: codexCliUpdateCommand(target.version), diff --git a/src/codex/cli-update-selection-gate.ts b/src/codex/cli-update-selection-gate.ts new file mode 100644 index 00000000000..5b0b0eeda16 --- /dev/null +++ b/src/codex/cli-update-selection-gate.ts @@ -0,0 +1,171 @@ +/** + * Shared commit fence and selection gate for the Codex CLI update worker. + * + * The update's last synchronous turn (final process scan, prefix/preimage/ + * selection guards, installer spawn) runs inside a Worker realm now, so the + * old same-event-loop-turn argument no longer excludes the parent's own + * selection-mutating operations or its session-spawn paths. Two shared-memory + * primitives carry that exclusion across realms: + * + * - FENCE (one per dispatched job): the parent's only cancellation word is + * PREPARING -> CANCELLED, and the worker's only commit word is + * PREPARING -> COMMITTED. Exactly one compareExchange wins. A cancel that + * lands first is observed by the worker's isCancelled checkpoints and by + * the commit CAS losing; a commit that lands first means the npm spawn + * already happened and a late cancel is deferred to readback. + * + * - GATE (one process-wide cell): FREE -> PARENT_WRITE is taken by the five + * parent selection writers (runtime pin persist/clear, shim install/ + * auto-restore/uninstall) and the two parent session-spawn paths (remote + * workspace child, desktop relaunch). FREE -> WORKER_COMMIT is taken by the + * update worker for only its short final window. Writers report busy rather + * than wait: the window is seconds at most and a synchronous parent writer + * must never park the event loop behind it. The gate is intentionally one + * cell for the whole process rather than per-configDir: alias/junction/case + * spellings of the same target must not split the exclusion, so a writer on + * a different configDir can also see a brief busy. + * + * This module claims nothing about processes it does not own: an external + * installer, a late descendant of an earlier child, or an OS snapshot race + * stays the documented residual it always was. + */ + +export const CODEX_CLI_UPDATE_FENCE_PREPARING = 0; +export const CODEX_CLI_UPDATE_FENCE_CANCELLED = 1; +export const CODEX_CLI_UPDATE_FENCE_COMMITTED = 2; + +export const CODEX_CLI_UPDATE_GATE_FREE = 0; +export const CODEX_CLI_UPDATE_GATE_PARENT_WRITE = 1; +export const CODEX_CLI_UPDATE_GATE_WORKER_COMMIT = 2; + +/** Bounded wait for a parent writer's window; the worker then refuses busy. */ +const WORKER_COMMIT_WAIT_DEADLINE_MS = 30_000; + +export class CodexCliSelectionMutationBusyError extends Error { + constructor(message = "codex_cli_selection_busy") { + super(message); + this.name = "CodexCliSelectionMutationBusyError"; + } +} + +let selectionGate: SharedArrayBuffer | null = null; + +/** The single process-wide gate cell. Both realms wrap it in an Int32Array. */ +export function codexCliUpdateSelectionGate(): SharedArrayBuffer { + selectionGate ??= new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + return selectionGate; +} + +/** Test seam: drop the cell so each case starts from FREE on a fresh buffer. */ +export function resetCodexCliSelectionGatesForTests(): void { + selectionGate = null; +} + +/** Fresh per-job fence; SharedArrayBuffer zero-initializes to PREPARING. */ +export function createCodexCliUpdateFence(): SharedArrayBuffer { + return new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); +} + +/** Parent cancel word. True only when this CAS took PREPARING -> CANCELLED. */ +export function cancelCodexCliUpdateFence(fence: SharedArrayBuffer): boolean { + return Atomics.compareExchange(new Int32Array(fence), 0, + CODEX_CLI_UPDATE_FENCE_PREPARING, CODEX_CLI_UPDATE_FENCE_CANCELLED) === CODEX_CLI_UPDATE_FENCE_PREPARING; +} + +/** + * The worker-side cancellation checkpoint fed to apply's isCancelled hook. + * COMMITTED is deliberately not "cancelled": once the commit CAS lands the + * installer is a live child whose close must be observed, not a cancel. + */ +export function codexCliUpdateIsCancelled(fence: SharedArrayBuffer): () => boolean { + const view = new Int32Array(fence); + return () => Atomics.load(view, 0) === CODEX_CLI_UPDATE_FENCE_CANCELLED; +} + +export interface CodexCliSelectionParentWrite { + readonly entered: boolean; + /** Idempotent release; safe to call from both branches of a spawn attempt. */ + exit(): void; +} + +/** + * Non-blocking acquisition for the parent selection writers and session-spawn + * paths. entered === false means a commit window (or another writer) is in + * flight and the caller must surface busy immediately, never wait. + */ +export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrite { + const view = new Int32Array(codexCliUpdateSelectionGate()); + if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_PARENT_WRITE) + !== CODEX_CLI_UPDATE_GATE_FREE) { + return Object.freeze({ entered: false, exit() { /* nothing held */ } }); + } + let held = true; + return Object.freeze({ + entered: true, + exit() { + if (!held) return; + held = false; + Atomics.store(view, 0, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} + +/** + * The worker's short final window. tryEnter covers the uncontended same-turn + * case; waitEnter is the bounded retry for a held parent write. commit() is + * the fence CAS issued in the same event-loop turn as the installer spawn; + * exit() releases the gate once the spawn call has returned. + */ +export interface CodexCliUpdateCommitWindow { + tryEnter(): boolean; + waitEnter(): Promise; + commit(): boolean; + exit(): void; +} + +export function createWorkerCommitWindow(options: { + readonly gate: SharedArrayBuffer; + readonly fence: SharedArrayBuffer; + readonly deadlineMs?: number; + /** Test seam: observe/back-off without real sleeps. */ + readonly sleep?: (ms: number) => Promise; +}): CodexCliUpdateCommitWindow { + const gate = new Int32Array(options.gate); + const fence = new Int32Array(options.fence); + const deadlineMs = options.deadlineMs ?? WORKER_COMMIT_WAIT_DEADLINE_MS; + const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); + const cancelled = () => Atomics.load(fence, 0) === CODEX_CLI_UPDATE_FENCE_CANCELLED; + let held = false; + return Object.freeze({ + tryEnter() { + if (!held + && Atomics.compareExchange(gate, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) + === CODEX_CLI_UPDATE_GATE_FREE) { + held = true; + } + return held; + }, + async waitEnter() { + const deadline = Date.now() + deadlineMs; + while (!held && !cancelled()) { + if (Atomics.compareExchange(gate, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) + === CODEX_CLI_UPDATE_GATE_FREE) { + held = true; + break; + } + if (Date.now() >= deadline) break; + await sleep(1); + } + return held; + }, + commit() { + return Atomics.compareExchange(fence, 0, CODEX_CLI_UPDATE_FENCE_PREPARING, CODEX_CLI_UPDATE_FENCE_COMMITTED) + === CODEX_CLI_UPDATE_FENCE_PREPARING; + }, + exit() { + if (!held) return; + held = false; + Atomics.store(gate, 0, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} diff --git a/src/codex/cli-update-worker.ts b/src/codex/cli-update-worker.ts new file mode 100644 index 00000000000..aba494fa3ce --- /dev/null +++ b/src/codex/cli-update-worker.ts @@ -0,0 +1,143 @@ +/** + * Worker-thread entry for one Codex CLI update plan or apply job. + * + * The parent owns admission and the durable job ledger; this realm owns the + * heavy inspection, hashing, staging and installer lifetime. The mutation + * evidence WeakMap is per-realm, which is exactly why the whole plan/apply + * must run here rather than only its hashing: report objects and their + * internal evidence cannot cross the realm boundary together. + * + * The message carries the allowlisted launcher snapshot (never the parent's + * trusted context) plus two shared-memory cells: the process-wide selection + * GATE the worker holds for its short final window, and the per-job FENCE + * whose PREPARING -> CANCELLED / PREPARING -> COMMITTED compare-exchanges + * decide cancel-vs-spawn atomically. A "cancel" message aborts the real + * AbortSignal handed to apply's fetchArtifact path — the asynchronous half + * of cancellation; the fence remains the synchronous-section half, and a + * cancel that loses the commit CAS is never posted. Any other second + * message is ignored: one worker runs exactly one job and then closes, so + * a late duplicate or a stale generation cannot attach new work to a dying + * realm. + */ +import { createCodexCliUpdatePlan, type CodexCliUpdateChannel, type CodexCliUpdatePlan } from "./cli-update-plan"; +import { applyCodexCliUpdate, type CodexCliApplyResult } from "./cli-update-apply"; +import { + codexCliUpdateInspectionDepsFromSnapshot, + type CodexCliUpdateInspectionSnapshot, +} from "./cli-update-inspection-context"; +import { codexCliUpdateIsCancelled, createWorkerCommitWindow } from "./cli-update-selection-gate"; + +export interface CodexCliUpdateWorkerRunMessage { + readonly type: "run"; + readonly requestId: string; + readonly jobId: string; + readonly operation: "plan" | "apply"; + readonly planId?: string; + readonly channel?: CodexCliUpdateChannel; + /** Frozen allowlisted launcher snapshot; never supplemented from ambient env. */ + readonly snapshot: CodexCliUpdateInspectionSnapshot; + /** Process-wide selection gate cell (see cli-update-selection-gate). */ + readonly gate: SharedArrayBuffer; + /** Per-job cancel/commit fence cell. */ + readonly fence: SharedArrayBuffer; + /** Env snapshot: a Worker may not observe parent mutations on every platform. */ + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +/** Async half of cancellation; the fence CAS is the synchronous half. */ +export interface CodexCliUpdateWorkerCancelMessage { + readonly type: "cancel"; + readonly requestId?: string; + readonly jobId?: string; +} + +export type CodexCliUpdateWorkerReply = + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly operation: "plan"; readonly plan: CodexCliUpdatePlan } + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly operation: "apply"; readonly result: CodexCliApplyResult } + | { readonly type: "error"; readonly requestId: string; readonly jobId: string; + readonly message: string }; + +function isSharedBuffer(value: unknown): value is SharedArrayBuffer { + return typeof SharedArrayBuffer !== "undefined" + && value instanceof SharedArrayBuffer + && value.byteLength >= Int32Array.BYTES_PER_ELEMENT; +} + +function isRunMessage(data: unknown): data is CodexCliUpdateWorkerRunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + return o.type === "run" + && typeof o.requestId === "string" + && typeof o.jobId === "string" + && (o.operation === "plan" || o.operation === "apply") + && (o.planId === undefined || typeof o.planId === "string") + && (o.channel === undefined || o.channel === "latest") + && (o.snapshot === null || (typeof o.snapshot === "object" && !Array.isArray(o.snapshot))) + && isSharedBuffer(o.gate) + && isSharedBuffer(o.fence); +} + +declare const self: Worker; + +// One real AbortSignal per realm for apply's fetchArtifact path; aborting it +// is idempotent, so a cancel that arrives before the run still refuses the +// apply at its first checkpoint via signal.aborted. +const applyAbort = new AbortController(); +let ran = false; + +// Guarded so the module can be imported directly by tests without a Worker host. +if (typeof Bun !== "undefined" && Bun.isMainThread === false + && typeof self !== "undefined" && typeof self.postMessage === "function") { + self.onmessage = async (event: MessageEvent) => { + const data = event.data; + if (data && typeof data === "object" && !Array.isArray(data) + && (data as CodexCliUpdateWorkerCancelMessage).type === "cancel") { + applyAbort.abort(); + return; + } + if (ran || !isRunMessage(data)) return; + ran = true; + const message = data; + try { + // Home overrides apply before anything resolves a home-derived path in + // this realm; they are transport for the job, never snapshot fields. + if (message.env?.CODEX_HOME) process.env.CODEX_HOME = message.env.CODEX_HOME; + if (message.env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = message.env.OPENCODEX_HOME; + const inspectionDeps = codexCliUpdateInspectionDepsFromSnapshot(message.snapshot); + if (message.operation === "plan") { + const plan = await createCodexCliUpdatePlan({ + channel: message.channel ?? "latest", + inspectionDeps, + }); + self.postMessage({ + type: "done", requestId: message.requestId, jobId: message.jobId, + operation: "plan", plan, + } satisfies CodexCliUpdateWorkerReply); + } else { + if (typeof message.planId !== "string") throw new Error("cli_update_worker_missing_plan_id"); + const result = await applyCodexCliUpdate(message.planId, { + inspectionDeps, + signal: applyAbort.signal, + isCancelled: codexCliUpdateIsCancelled(message.fence), + commitWindow: createWorkerCommitWindow({ gate: message.gate, fence: message.fence }), + }); + self.postMessage({ + type: "done", requestId: message.requestId, jobId: message.jobId, + operation: "apply", result, + } satisfies CodexCliUpdateWorkerReply); + } + } catch (error) { + self.postMessage({ + type: "error", requestId: message.requestId, jobId: message.jobId, + message: error instanceof Error ? error.message : "cli_update_worker_failed", + } satisfies CodexCliUpdateWorkerReply); + } finally { + // Bun 1.4.4 has no self.close(). Remove this realm's message keepalive; + // pending referenced child/I/O work must drain before natural termination. + // The parent's close event is not proof that every OS descendant exited. + self.onmessage = null; + } + }; +} diff --git a/src/codex/cli-windows-plan-provenance.ts b/src/codex/cli-windows-plan-provenance.ts new file mode 100644 index 00000000000..9071eccccd7 --- /dev/null +++ b/src/codex/cli-windows-plan-provenance.ts @@ -0,0 +1,165 @@ +import { createHash } from "node:crypto"; +import { win32 } from "node:path"; +import { parseStrictSemver } from "../lib/strict-semver"; +import { npmCodexCommandShim } from "./cli-installation-identity"; +import { MAX_NATIVE_BACKING_BYTES } from "./cli-installation-limits"; +import { deriveCodexCliInstallationInput } from "./cli-installation-targets"; +import type { CodexCliInstallProvenanceDeps, CodexCliInstallReport } from "./cli-install-provenance"; +import { resolveCodexRuntime } from "./runtime"; +import { inspectWindowsInstallationFiles, type WindowsInstallationFilesResult } from "./windows-installation-files"; + +/** Private plan evidence. Explicit `attest` observations never enter this map. */ +interface WindowsPlanEvidence { readonly node: string; readonly npmCli: string; readonly arch: "x64" | "arm64"; + readonly nodeEvidence: string; readonly npmEvidence: string } +const evidence = new WeakMap(); +export const windowsCodexCliPlanEvidence = (report: CodexCliInstallReport): WindowsPlanEvidence | null => evidence.get(report) ?? null; +const hash = (value: string): string => createHash("sha256").update(value).digest("hex"); +type File = Extract["files"][number]; +const id = (file: File): string => JSON.stringify([file.path, file.identity, file.digest]); +const sameWindowsPath = (left: string, right: string): boolean => win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase(); +/** Metadata queries execute only while the observed Node/npm and ancestors deny replacement. */ +export async function withWindowsCodexCliPlanToolchain(report: CodexCliInstallReport, + run: () => R): Promise | null> { + const toolchain = evidence.get(report); + if (!toolchain) return null; + let result: Awaited | null = null; + const observed = await inspectWindowsInstallationFiles([ + { path: toolchain.node, maxBytes: 128 * 1024 * 1024, hashOnly: true }, + { path: toolchain.npmCli, maxBytes: 256 * 1024 }, + ], async files => { + if (id(files[0]!) !== toolchain.nodeEvidence || id(files[1]!) !== toolchain.npmEvidence) return false; + result = await run(); return true; + }); + return observed.kind === "observed" ? result : null; +} +const json = (file: File): Record | null => { + try { const item = JSON.parse(Buffer.from(file.bytes).toString("utf8")); + return item && typeof item === "object" && !Array.isArray(item) ? item : null; + } catch { return null; } +}; +const entry = (item: Record, name: string): unknown => typeof item.bin === "string" ? item.bin + : item.bin && typeof item.bin === "object" ? (item.bin as Record)[name] : null; +function peArch(bytes: Uint8Array): "x64" | "arm64" | null { + const b = Buffer.from(bytes); + if (b.length < 64 || b.toString("ascii", 0, 2) !== "MZ") return null; + const offset = b.readUInt32LE(60); + if (offset > b.length - 6 || b.toString("ascii", offset, offset + 4) !== "PE\0\0") return null; + return b.readUInt16LE(offset + 4) === 0x8664 ? "x64" : b.readUInt16LE(offset + 4) === 0xaa64 ? "arm64" : null; +} + +/** + * Read-only supported subset: a proof-captured absolute standard npm cmd-shim + * override, its global-prefix package, prefix-local Node/npm, and the prefix-local + * optional backing. PATH-only Node selection remains refused until the snapshot + * proves the target cmd-shim's cwd and search policy. + */ +export async function inspectWindowsCodexCliPlanInstall( + deps: CodexCliInstallProvenanceDeps, fallback: CodexCliInstallReport, excluded: (path: string) => boolean, +): Promise { + const env = deps.env ?? {}; + // The bounded content read is opt-in: a bare ownership check holds the same + // reparse-refusing handles and identity set but streams no file content. + const observe = deps.observeInstallContent === true; + const candidate = env.CODEX_CLI_PATH?.trim(); + if (!deps.selectionAttested || !candidate || !/^[a-z]:[\\/]/i.test(candidate) + || win32.basename(candidate) !== "codex.cmd" + || (deps.selectedCommand != null && deps.selectedCommand !== candidate)) return fallback; + const prefix = win32.dirname(candidate); + const localNodePath = win32.join(prefix, "node.exe"); + const localNode = await inspectWindowsInstallationFiles([{ path: localNodePath, maxBytes: 0, metadataOnly: true }]); + if (localNode.kind === "refused" && localNode.reason !== "not-found") return fallback; + const selectedPath = env.PATH; + const selectedPathExt = env.PATHEXT; + const derivation = await deriveCodexCliInstallationInput({ + codexCliPath: candidate, path: selectedPath ?? null, pathExt: selectedPathExt ?? null, + }, { platform: "win32" }); + if (derivation.kind !== "derived" || derivation.input.candidate !== candidate + || derivation.input.npmPrefix !== prefix) return fallback; + const node = derivation.input.node; + const npmCli = derivation.input.npmCli; + const npmRoot = win32.dirname(win32.dirname(npmCli)); + const pathNode = !sameWindowsPath(node, localNodePath); + if (pathNode) return fallback; + const root = win32.join(prefix, "node_modules", "@openai", "codex"); + const manifest = win32.join(root, "package.json"); + const npmManifest = win32.join(npmRoot, "package.json"); + const bin = win32.join(root, "bin", "codex.js"); + const paths = [candidate, node, npmCli, root]; + if (prefix === win32.parse(prefix).root || paths.some(excluded)) return fallback; + // Only already handle-observed files can pass the real resolver's existence + // predicate. The explicit override wins before saved-state/shim/PATH candidates. + const selected = () => resolveCodexRuntime({ platform: "win32", env, configDir: deps.configDir, + probeVersion: false, discoverAlternatives: false, existsSync: path => path === candidate, + readFileSync: () => { throw new Error("state not required for explicit override"); }, + readdirSync: () => [], statSync: () => { throw new Error("discovery disabled"); }, + }).runtime.command === candidate && env.CODEX_CLI_PATH?.trim() === candidate; + try { + const first = await inspectWindowsInstallationFiles([ + { path: manifest, maxBytes: 256 * 1024 }, { path: npmManifest, maxBytes: 256 * 1024 }, + { path: node, maxBytes: 4096, prefixOnly: true }, + ]); + if (first.kind !== "observed") return fallback; + const initial = new Map(first.files.map(file => [file.path, file])); + const main = json(initial.get(manifest)!); const npm = json(initial.get(npmManifest)!); + const arch = peArch(initial.get(node)!.bytes); + if (!main || main.name !== "@openai/codex" || typeof main.version !== "string" || !parseStrictSemver(main.version) + || entry(main, "codex") !== "bin/codex.js" || !npm || npm.name !== "npm" + || typeof npm.version !== "string" || !parseStrictSemver(npm.version) || entry(npm, "npm") !== "bin/npm-cli.js" + || !arch || (deps.arch && deps.arch !== arch)) return fallback; + const pkg = `@openai/codex-win32-${arch}`; + const triple = arch === "x64" ? "x86_64-pc-windows-msvc" : "aarch64-pc-windows-msvc"; + // Node's nearest-first package lookup, bounded to this independent prefix. + const lanes = [win32.join(root, "bin", "node_modules"), win32.join(root, "node_modules"), + win32.join(prefix, "node_modules", "@openai", "node_modules"), win32.join(prefix, "node_modules")]; + const backingSelection = async (): Promise => { + for (const lane of lanes) { + const path = win32.join(lane, pkg, "package.json"); + const probe = await inspectWindowsInstallationFiles([{ path, maxBytes: 0, metadataOnly: true }]); + if (probe.kind === "observed") return path; + if (probe.reason !== "not-found") return null; + } + return null; // No global module lookup or vendor fallback adoption. + }; + const backingManifest = await backingSelection(); + if (!backingManifest) return fallback; + const native = win32.join(win32.dirname(backingManifest), "vendor", triple, "bin", "codex.exe"); + // Ownership requires the manifests, shim and backing manifest bytes plus the + // bounded Node header read above; entrypoint, npm CLI, Node and native + // contents only feed the opt-in digest, so they stay metadata-held without it. + const owned = (path: string) => ({ path, maxBytes: 256 * 1024 }); + const bound = (path: string) => observe ? { path, maxBytes: 256 * 1024 } : { path, maxBytes: 0, metadataOnly: true }; + const requests = [owned(manifest), owned(npmManifest), owned(candidate), bound(bin), bound(npmCli), owned(backingManifest)]; + const final = await inspectWindowsInstallationFiles([...requests, + observe + ? { path: node, maxBytes: 128 * 1024 * 1024, hashOnly: true } + : { path: node, maxBytes: 0, metadataOnly: true }, + observe + ? { path: native, maxBytes: MAX_NATIVE_BACKING_BYTES, hashOnly: true } + : { path: native, maxBytes: 0, metadataOnly: true }, + ], async () => selected() && env.PATH === selectedPath && env.PATHEXT === selectedPathExt + && await deriveCodexCliInstallationInput({ codexCliPath: candidate, + path: selectedPath ?? null, pathExt: selectedPathExt ?? null }, { platform: "win32" }) + .then(current => current.kind === "derived" && current.input.candidate === candidate + && current.input.npmPrefix === prefix && current.input.node === node && current.input.npmCli === npmCli) + && await backingSelection() === backingManifest); + if (final.kind !== "observed") return fallback; + const files = new Map(final.files.map(file => [file.path, file])); + if (!npmCodexCommandShim(files.get(candidate)!.bytes)) return fallback; + for (const path of [manifest, npmManifest]) if (id(initial.get(path)!) !== id(files.get(path)!)) return fallback; + if (JSON.stringify(initial.get(node)!.identity) !== JSON.stringify(files.get(node)!.identity)) return fallback; + const backing = json(files.get(backingManifest)!); + if (!backing || "exports" in backing || ![pkg, "@openai/codex"].includes(String(backing.name)) + || ![main.version, `${main.version}-win32-${arch}`].includes(String(backing.version))) return fallback; + const content = observe ? hash(JSON.stringify(["windows-npm-read-only-plan-v2", arch, prefix, candidate, node, npmCli, + ...final.files.map(file => [file.path, file.identity, file.digest])])) : null; + const report: CodexCliInstallReport = Object.freeze({ ...fallback, managed: true, selectionAttested: true, + provenance: "npm-global", reason: "managed_npm_global", packageVersion: main.version, + versionEvidence: Object.freeze({ kind: "package-manifest" }), installDigest: hash(prefix), + installContentDigest: content, shim: Object.freeze({ status: "not-tracked", backingKind: null }), + evidence: Object.freeze(["package_manifest", "package_manifest_digest", + ...(content ? ["install_content_digest" as const] : []), "global_npm_layout"] as const), + }); + evidence.set(report, Object.freeze({ node, npmCli, arch, nodeEvidence: id(files.get(node)!), npmEvidence: id(files.get(npmCli)!) })); + return report; + } catch { return fallback; } +} diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 68a6f27858d..91d7c7d3913 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -41,6 +41,7 @@ import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux"; import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; +import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; export type { DesktopAppExecOptions } from "./desktop-app/types"; @@ -90,6 +91,7 @@ export type DesktopAppRestartReason = | "restart_in_flight" | "handoff_started" | "relaunch_context_failed" + | "selection_mutation_busy" | "targets_survived" | "relaunch_failed"; @@ -248,11 +250,27 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp // first quits and relaunches, the second sees the freshly started shell as a target // and kills it. Own-pid reentrancy means the wp5 helper runs this same step and // finds the lock its caller made out to it. - const acquisition = acquireDesktopRestartLock(io.lock); - if (!acquisition.acquired) return skipped("restart_in_flight"); + // A cli-update worker holds this gate from its final process scan through + // its installer spawn, so no new shell may appear inside that window. The + // admission check runs BEFORE the restart lock and every mutation this + // function performs: a busy refusal must leave the app, the lock and any + // handoff all untouched. Held through relaunch or a failed attempt's + // cleanup; a handoff helper's own delayed descendants stay the documented + // pre-existing residual. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return skipped("selection_mutation_busy"); let handedOff = false; + // The lock attempt sits inside the protected scope too: a throw from the + // lock's own mkdir/write before the try would otherwise leak this + // process-wide gate for the rest of the process. lockHeld distinguishes + // "acquisition threw/refused" from "lock taken" so a failed attempt never + // releases a lock this call does not hold. + let lockHeld = false; try { + const acquisition = acquireDesktopRestartLock(io.lock); + if (!acquisition.acquired) return skipped("restart_in_flight"); + lockHeld = true; const install = adapter.discover(exec); if (!install) return skipped("package_discovery_failed"); @@ -366,6 +384,7 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp } finally { // On the handoff path ownership was transferred to the helper, so releasing here // would drop a lock that is still protecting a restart about to happen. - if (!handedOff) releaseDesktopRestartLock(io.lock); + selectionWrite.exit(); + if (lockHeld && !handedOff) releaseDesktopRestartLock(io.lock); } } diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 65f9e166384..e1a37f3b4d5 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -4,6 +4,10 @@ import { homedir, tmpdir } from "node:os"; import { delimiter, join, posix } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "./cli-update-selection-gate"; import { resolveCodexHomeDir } from "./home"; import { decodeOverlayState } from "./shim-state-file"; import { redactSecretString, redactUserPath } from "../lib/redact"; @@ -382,19 +386,30 @@ export function persistCodexRuntime( deps: ResolveCodexRuntimeDeps = {}, origin: CodexRuntimePinOrigin = "pinned", ): void { - const configDir = deps.configDir ?? getConfigDir(); - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const payload: PersistedCodexRuntimeState = { - version: 1, - command: runtime.command, - source: runtime.source, - selectedVersion: runtime.version, - updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), - origin, - }; - // Invalidate process authority before the persisted replacement is visible. - clearCodexRuntimeResolveCache(); - atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); + // A cli-update worker holds the shared selection gate from its final process + // scan through its installer spawn; this write must not slip into that + // window, so a held gate is an immediate busy rather than a wait. Checked + // BEFORE the mkdir and the resolve-cache invalidation so a busy refusal + // changes nothing at all. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); + try { + const configDir = deps.configDir ?? getConfigDir(); + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const payload: PersistedCodexRuntimeState = { + version: 1, + command: runtime.command, + source: runtime.source, + selectedVersion: runtime.version, + updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), + origin, + }; + // Invalidate process authority before the persisted replacement is visible. + clearCodexRuntimeResolveCache(); + atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); + } finally { + selectionWrite.exit(); + } } /** @@ -405,20 +420,29 @@ export function persistCodexRuntime( * folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold. */ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void { - const configDir = deps.configDir ?? getConfigDir(); - clearCodexRuntimeResolveCache(); + // Same gate as persistCodexRuntime, checked before the cache invalidation + // and outside the swallowing catch: a busy refusal must stay an error, not + // become a silent "already removed". + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { - unlinkSync(codexRuntimeStatePath(configDir)); - } catch (error) { - // An already-missing file is the success case: the pin is gone, which is the point. - // Anything else means the pin SURVIVES and stays authoritative, so every later - // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. - const code = (error as NodeJS.ErrnoException | null)?.code; - if (code === "ENOENT") return; - console.warn( - `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` - + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, - ); + const configDir = deps.configDir ?? getConfigDir(); + clearCodexRuntimeResolveCache(); + try { + unlinkSync(codexRuntimeStatePath(configDir)); + } catch (error) { + // An already-missing file is the success case: the pin is gone, which is the point. + // Anything else means the pin SURVIVES and stays authoritative, so every later + // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT") return; + console.warn( + `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` + + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, + ); + } + } finally { + selectionWrite.exit(); } } diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts index 12630347ba8..4eb5f4c3089 100644 --- a/src/codex/shim-overlay.ts +++ b/src/codex/shim-overlay.ts @@ -5,6 +5,10 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "./cli-update-selection-gate"; import { durableBunRuntime } from "../lib/bun-runtime"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { truncateRetainedUtf8 } from "../lib/admission"; @@ -442,6 +446,12 @@ function installLocked( return publishOverlay(launcher, overlay, check, pinnedState); } export function installUnixOverlay(discover: () => string | null, describeMiss?: () => string | null): InstallResult { + // The update worker's commit window re-observes selection evidence right up + // to its installer spawn; a shim install must not cross that window. Busy is + // surfaced before namespace creation, the restore lock and any journal + // mutation, so a held gate refuses without touching state. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return failure(new CodexCliSelectionMutationBusyError()); let lock: ReturnType = null; try { const check = namespace(true); @@ -450,7 +460,7 @@ export function installUnixOverlay(discover: () => string | null, describeMiss?: check(); return installLocked(discover, false, check, describeMiss); } catch (error) { return failure(error); } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRestoreResult { const result = readStateResult(); @@ -468,6 +478,11 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe } catch (error) { return { status: "ineligible", message: failure(error).message }; } if (!nativeProbe(state.launcherPath)) return { status: "ineligible", message: "Recorded native Codex launcher is unusable; repair it with its package manager" }; if (!options.enabled()) return { status: "disabled" }; + // Same selection gate as install: acquired before namespace creation, the + // restore lock and journal mutation; a held update commit window defers the + // restore rather than racing it. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return { status: "deferred", message: "a Codex CLI update is committing an install; shim restore deferred" }; let lock: ReturnType = null; try { const check = namespace(true); @@ -484,9 +499,13 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe const installed = installLocked(() => null, true, check); return installed.installed ? { status: "restored", message: installed.message } : { status: "healthy" }; } catch (error) { return { status: "deferred", message: failure(error).message }; } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } export function uninstallUnixCodexShim(): { removed: boolean; message: string } { + // Same selection gate: removal mutates the same state the update worker + // re-observes in its commit window, so a held gate reports busy unchanged. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return { removed: false, message: new CodexCliSelectionMutationBusyError().message }; let lock: ReturnType = null; try { const check = namespace(false); @@ -522,5 +541,5 @@ export function uninstallUnixCodexShim(): { removed: boolean; message: string } if (!removePinned(statePath(), stateFile)) throw new Error("Concurrent Codex state replacement preserved"); return { removed: true, message: "Codex PATH shim removed. Native Codex launcher was preserved. Remove the codex-shell-env source line from your shell startup file." }; } catch (error) { return { removed: false, message: failure(error).message }; } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } diff --git a/src/codex/windows-cli-child-native.ts b/src/codex/windows-cli-child-native.ts new file mode 100644 index 00000000000..1e53b77a0ff --- /dev/null +++ b/src/codex/windows-cli-child-native.ts @@ -0,0 +1,113 @@ +import { dlopen, ptr, read, type Library, type Symbols } from "bun:ffi"; + +// Windows x64 ABI only. No native binary, compiler, worker URL, or shell dependency. +export const WIN64_CHILD_ABI = Object.freeze({ securityAttributes: 24, securityDescriptor: 8, inheritHandle: 16, + startupInfo: 104, startupInfoEx: 112, startupFlags: 60, stdin: 80, stdout: 88, stderr: 96, attributes: 104, + processInformation: 24, process: 0, thread: 8, pid: 16, tid: 20, + overlapped: 32, event: 24, jobAccounting: 48, activeProcesses: 40 }); +const KERNEL = { + GetLastError: { args: [], returns: "u32" }, + GetCurrentProcess: { args: [], returns: "u64" }, + CompareStringOrdinal: { args: ["ptr", "i32", "ptr", "i32", "i32"], returns: "i32" }, + CreateJobObjectW: { args: ["ptr", "ptr"], returns: "u64" }, + AssignProcessToJobObject: { args: ["u64", "u64"], returns: "i32" }, + QueryInformationJobObject: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + TerminateJobObject: { args: ["u64", "u32"], returns: "i32" }, + TerminateProcess: { args: ["u64", "u32"], returns: "i32" }, + CreateProcessW: { args: ["ptr", "ptr", "ptr", "ptr", "i32", "u32", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + ResumeThread: { args: ["u64"], returns: "u32" }, + WaitForSingleObject: { args: ["u64", "u32"], returns: "u32" }, + GetExitCodeProcess: { args: ["u64", "ptr"], returns: "i32" }, + GetProcessTimes: { args: ["u64", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + InitializeProcThreadAttributeList: { args: ["ptr", "u32", "u32", "ptr"], returns: "i32" }, + UpdateProcThreadAttribute: { args: ["ptr", "u32", "u64", "ptr", "u64", "ptr", "ptr"], returns: "i32" }, + DeleteProcThreadAttributeList: { args: ["ptr"], returns: "void" }, + CreateNamedPipeW: { args: ["ptr", "u32", "u32", "u32", "u32", "u32", "u32", "ptr"], returns: "u64" }, + ConnectNamedPipe: { args: ["u64", "ptr"], returns: "i32" }, + CreateFileW: { args: ["ptr", "u32", "u32", "ptr", "u32", "u32", "u64"], returns: "u64" }, + CreateEventW: { args: ["ptr", "i32", "i32", "ptr"], returns: "u64" }, + ResetEvent: { args: ["u64"], returns: "i32" }, + ReadFile: { args: ["u64", "ptr", "u32", "ptr", "ptr"], returns: "i32" }, + GetOverlappedResult: { args: ["u64", "ptr", "ptr", "i32"], returns: "i32" }, + CancelIoEx: { args: ["u64", "ptr"], returns: "i32" }, + CloseHandle: { args: ["u64"], returns: "i32" }, + LocalFree: { args: ["ptr"], returns: "ptr" }, +} as const satisfies Symbols; +const ADVAPI = { + OpenProcessToken: { args: ["u64", "u32", "ptr"], returns: "i32" }, + GetTokenInformation: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + ConvertSidToStringSidW: { args: ["ptr", "ptr"], returns: "i32" }, + ConvertStringSecurityDescriptorToSecurityDescriptorW: { args: ["ptr", "u32", "ptr", "ptr"], returns: "i32" }, +} as const satisfies Symbols; + +export const wide = (value: string): Buffer => Buffer.from(`${value}\0`, "utf16le"); +export function securityAttributes(inherit: boolean, descriptor = 0n): Buffer { + const bytes = Buffer.alloc(WIN64_CHILD_ABI.securityAttributes); + bytes.writeUInt32LE(bytes.length, 0); bytes.writeBigUInt64LE(descriptor, 8); bytes.writeUInt32LE(+inherit, 16); + return bytes; +} + +/** Owns only handles returned by this invocation, never PID-reopened handles. */ +export class WindowsChildNative { + readonly k: Library["symbols"]; + readonly a: Library["symbols"]; + readonly handles = new Set(); + /** Explicit GC roots for pointers stored inside native attribute lists. */ + readonly borrowedMemory = new Set(); + readonly closeErrors: bigint[] = []; + private readonly kernel: Library; + private readonly advapi: Library; + private closed = false; + constructor() { + if (process.platform !== "win32" || process.arch !== "x64") throw new Error("windows_child_unsupported_platform"); + this.kernel = dlopen("kernel32.dll", KERNEL); + try { this.advapi = dlopen("advapi32.dll", ADVAPI); } + catch (error) { this.kernel.close(); throw error; } + // Own mutable dispatch tables permit per-instance fault injection in native fixtures. + this.k = { ...this.kernel.symbols }; this.a = { ...this.advapi.symbols }; + } + keep(value: number | bigint): bigint { + const handle = BigInt(value); + if (!handle || handle === 0xffffffffffffffffn) throw new Error(`windows_child_handle_${this.k.GetLastError!()}`); + this.handles.add(handle); return handle; + } + close(handle: bigint): void { + if (!this.handles.has(handle)) return; + if (!this.k.CloseHandle!(handle)) { this.closeErrors.push(handle); return; } + this.handles.delete(handle); + } + dispose(): boolean { + if (this.handles.size || this.borrowedMemory.size) return false; + if (!this.closed) { this.closed = true; this.advapi.close(); this.kernel.close(); } + return true; + } + /** Protected DACL grants only the current token user. No default/world pipe ACL. */ + privateDescriptor(): bigint { + const output = Buffer.alloc(8), size = Buffer.alloc(4); + if (!this.a.OpenProcessToken!(this.k.GetCurrentProcess!(), 8, ptr(output))) throw new Error("windows_child_token"); + const token = this.keep(output.readBigUInt64LE()); + let sidText = 0n; + try { + this.a.GetTokenInformation!(token, 1, null, 0, ptr(size)); + const length = size.readUInt32LE(); + if (length < 8 || length > 65536) throw new Error("windows_child_token_size"); + const user = Buffer.alloc(length); + if (!this.a.GetTokenInformation!(token, 1, ptr(user), length, ptr(size))) throw new Error("windows_child_token_user"); + const sid = user.readBigUInt64LE(); + if (!this.a.ConvertSidToStringSidW!(sid, ptr(output))) throw new Error("windows_child_token_sid"); + sidText = output.readBigUInt64LE(); + let sidString = ""; + for (let index = 0; index < 184; index++) { + const code = read.u16(sidText, index * 2); + if (!code) break; + sidString += String.fromCharCode(code); + } + if (!/^S-1-(?:\d+-)+\d+$/.test(sidString)) throw new Error("windows_child_sid_shape"); + const sddl = wide(`D:P(A;;GA;;;${sidString})`); + if (!this.a.ConvertStringSecurityDescriptorToSecurityDescriptorW!(ptr(sddl), 1, ptr(output), null)) { + throw new Error("windows_child_pipe_acl"); + } + return output.readBigUInt64LE(); + } finally { if (sidText) this.k.LocalFree!(sidText); this.close(token); } + } +} diff --git a/src/codex/windows-cli-child-pipe.ts b/src/codex/windows-cli-child-pipe.ts new file mode 100644 index 00000000000..6bcc6069fe6 --- /dev/null +++ b/src/codex/windows-cli-child-pipe.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import { ptr } from "bun:ffi"; +import { securityAttributes, wide, WIN64_CHILD_ABI, WindowsChildNative } from "./windows-cli-child-native"; + +/** One overlapped operation and one retained buffer per pipe. Never PeekNamedPipe. */ +export class WindowsChildPipe { + readonly overlapped = Buffer.alloc(WIN64_CHILD_ABI.overlapped); + readonly buffer = Buffer.alloc(64 * 1024); + readonly count = Buffer.alloc(4); + handle = 0n; + child = 0n; + event = 0n; + pending = false; + eof = false; + error = false; + private connecting = false; + private disposed = false; + constructor(readonly native: WindowsChildNative) {} + open(): void { + const n = this.native, k = n.k; + const descriptor = n.privateDescriptor(); + try { + const sa = securityAttributes(false, descriptor), childSa = securityAttributes(true, descriptor); + const name = wide(`\\\\.\\pipe\\ocx-child-${randomUUID()}`); + // Inbound, OVERLAPPED, FIRST_PIPE_INSTANCE; byte stream, REJECT_REMOTE_CLIENTS. + this.handle = n.keep(k.CreateNamedPipeW!(ptr(name), 0x40080001, 8, 1, 0, this.buffer.length, 0, ptr(sa))); + this.event = n.keep(k.CreateEventW!(null, 1, 0, null)); + this.overlapped.writeBigUInt64LE(this.event, 24); + // A known local client connects before ConnectNamedPipe: ERROR_PIPE_CONNECTED is success. + this.child = n.keep(k.CreateFileW!(ptr(name), 0x40000000, 0, ptr(childSa), 3, 0, 0n)); + if (!k.ConnectNamedPipe!(this.handle, ptr(this.overlapped))) { + const error = Number(k.GetLastError!()); + if (error === 997) { this.pending = true; this.connecting = true; } + else if (error !== 535) throw new Error(`windows_child_pipe_connect_${error}`); + } + } finally { k.LocalFree!(descriptor); } + } + closeChild(): void { if (this.child) { this.native.close(this.child); this.child = 0n; } } + /** Bounded synchronous work; pending overlapped operations always return immediately. */ + pump(consume: (bytes: Buffer) => void): void { + if (this.disposed) return; + if (this.eof) return; + const k = this.native.k; + for (let i = 0; i < 4 && !this.eof; i++) { + let done: number; + if (this.pending) { + if (!k.GetOverlappedResult!(this.handle, ptr(this.overlapped), ptr(this.count), 0)) { + const error = Number(k.GetLastError!()); + if (error === 996) return; // ERROR_IO_INCOMPLETE: retain buffers and operation. + if (this.overlapped.readBigUInt64LE(0) === 0x103n) { this.error = true; return; } + this.pending = false; this.finish(error); return; + } + this.pending = false; + if (this.connecting) { this.connecting = false; continue; } + done = this.count.readUInt32LE(); + } else { + this.overlapped.fill(0); this.overlapped.writeBigUInt64LE(this.event, 24); + if (!k.ResetEvent!(this.event)) { this.finish(-1); return; } + if (!k.ReadFile!(this.handle, ptr(this.buffer), this.buffer.length, ptr(this.count), ptr(this.overlapped))) { + const error = Number(k.GetLastError!()); + if (error === 997) { this.pending = true; return; } + this.finish(error); return; + } + done = this.count.readUInt32LE(); + } + if (!done) { this.eof = true; return; } + consume(this.buffer.subarray(0, done)); + } + } + private finish(error: number): void { this.eof = true; this.error = error !== 109; } + /** Cancel is only a request. Keep the OVERLAPPED, event and bytes until settlement. */ + async dispose(): Promise { + if (this.disposed) return; + this.closeChild(); + if (this.pending) { + this.native.k.CancelIoEx!(this.handle, ptr(this.overlapped)); + while (this.pending) { + if (this.native.k.GetOverlappedResult!(this.handle, ptr(this.overlapped), ptr(this.count), 0) + || (Number(this.native.k.GetLastError!()) !== 996 && this.overlapped.readBigUInt64LE(0) !== 0x103n)) this.pending = false; + else await Bun.sleep(5); + } + } + if (this.handle) this.native.close(this.handle); + if (this.event) this.native.close(this.event); + this.disposed = true; + } +} diff --git a/src/codex/windows-cli-child.ts b/src/codex/windows-cli-child.ts new file mode 100644 index 00000000000..d147b681bed --- /dev/null +++ b/src/codex/windows-cli-child.ts @@ -0,0 +1,228 @@ +import { win32 } from "node:path"; +import { ptr } from "bun:ffi"; +import { securityAttributes, wide, WIN64_CHILD_ABI, WindowsChildNative } from "./windows-cli-child-native"; +import { WindowsChildPipe } from "./windows-cli-child-pipe"; + +export type WindowsCliChildPolicy = "installer" | "pack" | "version"; +export function windowsCliChildPolicy(policy: WindowsCliChildPolicy) { + switch (policy) { + case "installer": return { captureBytes: 0, deadlineMs: null, reapMs: null, terminate: false } as const; + case "pack": return { captureBytes: 1024 * 1024, deadlineMs: null, reapMs: null, terminate: false } as const; + case "version": return { captureBytes: 16 * 1024, deadlineMs: 8000, reapMs: 1000, terminate: true } as const; + } +} +export interface WindowsCliChildInvocation { + readonly executable: string; + readonly args: readonly string[]; + readonly cwd: string; + readonly env: Readonly; + readonly policy: WindowsCliChildPolicy; +} +export interface WindowsCliChildResult { + readonly terminal: boolean; + readonly exitCode: number | null; + readonly stdout: string; + readonly overflow: boolean; + readonly failed: boolean; + readonly reason: string | null; +} +/** CRT argv quoting, with explicit lpApplicationName; never cmd.exe or PATH lookup. */ +export function quoteWindowsChildArg(arg: string): string { + return '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"'; +} +function validate(invocation: WindowsCliChildInvocation): void { + const localAbsolute = (value: string) => typeof value === "string" && /^[A-Za-z]:[\\/]/.test(value) + && win32.isAbsolute(value) && !value.includes("\0"); + if (!localAbsolute(invocation.executable) || !/\.exe$/i.test(invocation.executable) + || !localAbsolute(invocation.cwd) || !Array.isArray(invocation.args) + || invocation.args.some(arg => typeof arg !== "string" || arg.includes("\0")) + || !windowsCliChildPolicy(invocation.policy)) throw new Error("windows_child_invalid_invocation"); +} +function environmentBlock(env: Readonly, native: WindowsChildNative): Buffer { + const entries = Object.keys(env).filter(key => env[key] !== undefined).map(key => { + const value = env[key]!; + if (!key || /[=\0]/.test(key) || value.includes("\0")) throw new Error("windows_child_invalid_environment"); + return { key: wide(key), entry: `${key}=${value}` }; + }); + // Windows requires case-insensitive ordinal sorting, independent of JS/host locale. + const compare = (a: typeof entries[number], b: typeof entries[number]) => { + const result = Number(native.k.CompareStringOrdinal!(ptr(a.key), -1, ptr(b.key), -1, 1)); + if (!result) throw new Error("windows_child_environment_compare_failed"); + return result - 2; + }; + entries.sort(compare); + for (let i = 1; i < entries.length; i++) if (compare(entries[i - 1]!, entries[i]!) === 0) throw new Error("windows_child_invalid_environment"); + return Buffer.from(entries.map(item => item.entry).join("\0") + "\0\0", "utf16le"); +} + +/** + * Owns an unnamed non-inheritable Job and the original primary process handle. + * No KILL_ON_JOB_CLOSE, active-process limit, BREAKAWAY, PID reopen, or running abort. + * A caller must adopt lease/staging ownership BEFORE calling start, and retain this + * object and those resources whenever wait() returns terminal:false. + */ +export class WindowsCliChild { + readonly pipe: WindowsChildPipe; + readonly policy: ReturnType; + readonly startedAt = Date.now(); + private readonly clockStartedAt = performance.now(); + pid: number | null = null; + creationTime: string | null = null; + process = 0n; + job = 0n; + setupFailure: string | null = null; + private stopAt: number | null = null; + private terminationFailed = false; + private failed = false; + private reason: string | null = null; + private overflow = false; + private chunks: Buffer[] = []; + private captured = 0; + private waiting = false; + private finished: WindowsCliChildResult | undefined; + private observedTerminal: { terminal: true; exitCode: number; unknown: false } | undefined; + private prestartTerminationFailed = false; + constructor(readonly native: WindowsChildNative, policy: WindowsCliChildPolicy) { + this.policy = windowsCliChildPolicy(policy); this.pipe = new WindowsChildPipe(native); + } + private fail(reason: string): void { this.failed = true; this.reason ??= reason; } + private consume = (bytes: Buffer): void => { + const remaining = this.policy.captureBytes - this.captured; + if (bytes.length > remaining && this.policy.captureBytes) this.overflow = true; + if (remaining > 0) { const chunk = Buffer.from(bytes.subarray(0, remaining)); this.chunks.push(chunk); this.captured += chunk.length; } + // Overflow still drains/discards. Only the separate version policy may stop a Job. + if (this.overflow && this.policy.terminate) this.stop("output_limit"); + }; + private stop(reason: string): void { + this.fail(reason); + if (this.stopAt !== null || !this.policy.terminate) return; + this.stopAt = performance.now(); + if (!this.native.k.TerminateJobObject!(this.job, 1)) this.terminationFailed = true; + } + private result(terminal: boolean, exitCode: number | null): WindowsCliChildResult { + return { terminal, exitCode, stdout: Buffer.concat(this.chunks, this.captured).toString("utf8"), + overflow: this.overflow, failed: this.failed || !!this.setupFailure, reason: this.setupFailure ?? this.reason }; + } + /** A successful accounting query plus a signaled original process handle, never PID absence or STILL_ACTIVE. */ + observe(): { terminal: boolean; exitCode: number | null; unknown: boolean } { + if (this.observedTerminal) return this.observedTerminal; + const k = this.native.k, accounting = Buffer.alloc(WIN64_CHILD_ABI.jobAccounting), code = Buffer.alloc(4); + const wait = Number(k.WaitForSingleObject!(this.process, 0)); + if (wait !== 0 && wait !== 258) { this.fail("process_wait_failed"); return { terminal: false, exitCode: null, unknown: true }; } + if (!k.QueryInformationJobObject!(this.job, 1, ptr(accounting), accounting.length, null)) { + this.fail("job_query_failed"); return { terminal: false, exitCode: null, unknown: true }; + } + if (wait !== 0 || accounting.readUInt32LE(40) !== 0) return { terminal: false, exitCode: null, unknown: false }; + if (!k.GetExitCodeProcess!(this.process, ptr(code))) { + this.fail("process_exit_query_failed"); return { terminal: false, exitCode: null, unknown: true }; + } + return this.observedTerminal = { terminal: true, exitCode: code.readUInt32LE(), unknown: false }; + } + async wait(): Promise { + if (this.finished) return this.finished; + if (!this.process) throw new Error("windows_child_not_created_use_cleanup"); + if (this.waiting) throw new Error("windows_child_wait_already_owned"); + this.waiting = true; + try { + for (;;) { + this.pipe.pump(this.consume); + if (this.pipe.error) { this.fail("pipe_read_failed"); if (this.policy.terminate) this.stop("pipe_read_failed"); } + const observed = this.observe(); + if (observed.terminal && this.pipe.eof) { + await this.pipe.dispose(); + this.native.close(this.process); this.native.close(this.job); + const released = this.native.dispose(); + if (!released) this.fail("handle_close_failed"); + return this.finished = this.result(true, observed.exitCode); + } + if (observed.unknown || this.terminationFailed || this.prestartTerminationFailed + || (this.pipe.error && this.pipe.pending)) return this.result(false, null); + if (this.policy.deadlineMs !== null && performance.now() - this.clockStartedAt >= this.policy.deadlineMs) this.stop("deadline"); + if (this.stopAt !== null && performance.now() - this.stopAt >= this.policy.reapMs!) return this.result(false, null); + await Bun.sleep(5); + } + } finally { this.waiting = false; } + } + /** Not a running-child disposal API: only pre-user-code setup failures may call it. */ + reapUnstarted(): boolean { + if (!this.setupFailure || !this.process || this.finished || !this.native.handles.has(this.process)) return false; + this.prestartTerminationFailed = !this.native.k.TerminateProcess!(this.process, 1); + return !this.prestartTerminationFailed; + } +} + +export function startWindowsCliChild(invocation: WindowsCliChildInvocation): WindowsCliChild { + validate(invocation); + return startWindowsCliChildWithNative(invocation, new WindowsChildNative()); +} + +/** Internal native dispatch seam; fixtures use the real APIs with single-call faults. */ +export function startWindowsCliChildWithNative(invocation: WindowsCliChildInvocation, native: WindowsChildNative): WindowsCliChild { + validate(invocation); + const child = new WindowsCliChild(native, invocation.policy), k = native.k; + let stdin = 0n, stderr = 0n, thread = 0n, attributes: Buffer | undefined, initialized = false; + let inheritedHandles: Buffer | undefined; + try { + child.job = native.keep(k.CreateJobObjectW!(null, null)); + child.pipe.open(); + const inheritable = securityAttributes(true), nul = wide("NUL"); + stdin = native.keep(k.CreateFileW!(ptr(nul), 0x80000000, 3, ptr(inheritable), 3, 0, 0n)); + stderr = native.keep(k.CreateFileW!(ptr(nul), 0x40000000, 3, ptr(inheritable), 3, 0, 0n)); + const handles = inheritedHandles = Buffer.alloc(24); + [stdin, child.pipe.child, stderr].forEach((handle, index) => handles.writeBigUInt64LE(handle, index * 8)); + const size = Buffer.alloc(8); + k.InitializeProcThreadAttributeList!(null, 1, 0, ptr(size)); + const length = Number(size.readBigUInt64LE()); + if (length < 24 || length > 1024 * 1024) throw new Error("attribute_size"); + attributes = Buffer.alloc(length); + native.borrowedMemory.add(attributes); native.borrowedMemory.add(handles); + if (!k.InitializeProcThreadAttributeList!(ptr(attributes), 1, 0, ptr(size))) throw new Error("attribute_init"); + initialized = true; + if (!k.UpdateProcThreadAttribute!(ptr(attributes), 0, 0x20002n, ptr(handles), 24n, null, null)) throw new Error("attribute_handles"); + const startup = Buffer.alloc(WIN64_CHILD_ABI.startupInfoEx), info = Buffer.alloc(WIN64_CHILD_ABI.processInformation); + startup.writeUInt32LE(startup.length); startup.writeUInt32LE(0x100, 60); + [stdin, child.pipe.child, stderr].forEach((handle, index) => startup.writeBigUInt64LE(handle, 80 + index * 8)); + startup.writeBigUInt64LE(BigInt(ptr(attributes)), 104); + const executable = wide(invocation.executable), command = wide([invocation.executable, ...invocation.args].map(quoteWindowsChildArg).join(" ")); + const cwd = wide(invocation.cwd), env = environmentBlock(invocation.env, native); + if (command.length > 65534) throw new Error("command_length"); + // CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW. + // All buffers above remain strongly reachable through CreateProcessW and attribute deletion. + if (!k.CreateProcessW!(ptr(executable), ptr(command), null, null, 1, 0x08080404, ptr(env), ptr(cwd), ptr(startup), ptr(info))) { + throw new Error(`create_process_${k.GetLastError!()}`); + } + child.process = native.keep(info.readBigUInt64LE(0)); thread = native.keep(info.readBigUInt64LE(8)); + child.pid = info.readUInt32LE(16); + // Separate FILETIME buffers: creating Buffer subarrays while evaluating FFI + // arguments can materialize a new backing store in bundled Bun on Windows. + const creation = Buffer.alloc(8), exit = Buffer.alloc(8), kernel = Buffer.alloc(8), user = Buffer.alloc(8); + if (!k.GetProcessTimes!(child.process, ptr(creation), ptr(exit), ptr(kernel), ptr(user))) throw new Error("process_times"); + child.creationTime = creation.readBigUInt64LE().toString(); + if (child.creationTime === "0") throw new Error("process_creation_time_unknown"); + if (!k.AssignProcessToJobObject!(child.job, child.process)) throw new Error("job_assignment"); + if (Number(k.ResumeThread!(thread)) !== 1) throw new Error("resume_thread"); + } catch (error) { + child.setupFailure = error instanceof Error ? error.message : "setup_failed"; + if (child.process) child.reapUnstarted(); + } finally { + if (initialized) k.DeleteProcThreadAttributeList!(ptr(attributes!)); + if (attributes) native.borrowedMemory.delete(attributes); + if (inheritedHandles) native.borrowedMemory.delete(inheritedHandles); + child.pipe.closeChild(); + if (stdin) native.close(stdin); if (stderr) native.close(stderr); if (thread) native.close(thread); + } + // No await has occurred while any parent-owned inheritable stdio handle was open. + if (!child.process) { + // No I/O read is issued until wait(). The connect call is the only possible pending operation. + // Caller receives the owner for async cancellation/settlement via cleanupUnstartedWindowsChild. + return child; + } + return child; +} + +/** Setup never launched user code. Preserve owner until pending connect I/O is settled. */ +export async function cleanupUnstartedWindowsChild(child: WindowsCliChild): Promise { + if (child.process || !child.setupFailure) return false; + await child.pipe.dispose(); if (child.job) child.native.close(child.job); + return child.native.dispose(); +} diff --git a/src/codex/windows-installation-files.ts b/src/codex/windows-installation-files.ts index e1f3cef5691..ff66cf5a6d1 100644 --- a/src/codex/windows-installation-files.ts +++ b/src/codex/windows-installation-files.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { Library, Symbols } from "bun:ffi"; +import { MAX_NATIVE_BACKING_BYTES } from "./cli-installation-limits"; const KERNEL_SYMBOLS = { GetDriveTypeW: { args: ["ptr"], returns: "u32" }, @@ -53,6 +54,9 @@ class InspectionRefusal extends Error { constructor(readonly reason: Refusal) { super(reason); } } const MIB = 1024 * 1024; +// One native backing, the unchanged production Node bound, and at most twelve +// bounded text requests. This is an I/O ceiling; hash-only buffers stay empty. +const MAX_REQUEST_BYTES = MAX_NATIVE_BACKING_BYTES + (128 + 12) * MIB; const REPARSE = 0x400; const DIRECTORY = 0x10; const INVALID_HANDLE = 0xffffffffffffffffn; @@ -61,6 +65,11 @@ let openedForTests: (() => void) | undefined; export function setWindowsInstallationFilesOpenedForTests(callback?: () => void): void { openedForTests = callback; } +let readForTests: ((path: string, bytesRead: number) => void) | undefined; +/** Runs once per request with its streamed content bytes (0 for metadata-only), for read accounting. */ +export function setWindowsInstallationFilesReadForTests(callback?: (path: string, bytesRead: number) => void): void { + readForTests = callback; +} function components(path: string): { drive: string; names: string[] } | null { if (typeof path !== "string" || path.length > 8192 || !/^[A-Za-z]:[\\/]/.test(path)) return null; @@ -81,6 +90,7 @@ function components(path: string): { drive: string; names: string[] } | null { */ export async function inspectWindowsInstallationFiles( requests: readonly WindowsInstallationFileRequest[], + finalize?: (files: Extract["files"]) => boolean | Promise, ): Promise { if (!Array.isArray(requests) || requests.length === 0 || requests.length > 12) { return { kind: "refused", reason: "invalid-request" }; @@ -92,11 +102,11 @@ export async function inspectWindowsInstallationFiles( || (request.hashOnly !== undefined && typeof request.hashOnly !== "boolean") || (request.metadataOnly !== undefined && typeof request.metadataOnly !== "boolean") || (request.prefixOnly !== undefined && typeof request.prefixOnly !== "boolean") - || request.maxBytes > (request.hashOnly ? 256 * MIB : MIB)) return null; + || request.maxBytes > (request.hashOnly ? MAX_NATIVE_BACKING_BYTES : MIB)) return null; ceiling += request.maxBytes; return parsedPath; }); - if (parsed.some(path => path === null) || ceiling > 300 * MIB) { + if (parsed.some(path => path === null) || ceiling > MAX_REQUEST_BYTES) { return { kind: "refused", reason: "invalid-request" }; } if (process.platform !== "win32" || process.arch !== "x64") { @@ -207,7 +217,10 @@ export async function inspectWindowsInstallationFiles( openedForTests?.(); const observed = files.map(({ request, handle, identity }) => { const hash = createHash("sha256"); - if (request.metadataOnly) return { path: request.path, identity, bytes: new Uint8Array(), digest: "" }; + if (request.metadataOnly) { + readForTests?.(request.path, 0); + return { path: request.path, identity, bytes: new Uint8Array(), digest: "" }; + } const truncated = Boolean(request.prefixOnly) && !request.hashOnly && identity.size > request.maxBytes; const readLimit = truncated ? request.maxBytes : identity.size; const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(readLimit); @@ -228,8 +241,10 @@ export async function inspectWindowsInstallationFiles( throw new InspectionRefusal("identity-changed"); } } + readForTests?.(request.path, offset); return { path: request.path, identity, bytes, digest: truncated ? "" : hash.digest("hex") }; }); + if (finalize && !await finalize(observed)) throw new InspectionRefusal("identity-changed"); for (const file of files) { if (JSON.stringify(inspect(file.handle, false)) !== JSON.stringify(file.identity)) { throw new InspectionRefusal("identity-changed"); diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts index 5e6c2472d6f..97cc2f45115 100644 --- a/src/remote-control/workspace-codex-runtime.ts +++ b/src/remote-control/workspace-codex-runtime.ts @@ -2,6 +2,10 @@ import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { resolveCodexRuntimeAsync } from "../codex/runtime"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "../codex/cli-update-selection-gate"; import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; @@ -366,6 +370,23 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim "app-server", "--listen", "stdio://", ], { env: childEnv }); let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + // A cli-update worker holds this gate from its final process scan through + // its installer spawn; a new session shell must not appear inside that + // window. Busy is the same explicit pre-spawn failure path as a spawn + // throw, so the bridge and isolation are reclaimed identically. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) { + // Both teardown steps are attempted before the refusal leaves: the + // ordered runner keeps a rejected bridge stop from skipping isolation + // removal or replacing the explicit busy signal the caller maps to a + // retryable refusal. The raw cleanup error stays internal; the caller + // gets the same fixed busy evidence as every other pre-spawn refusal. + await runRemoteWorkspaceCleanupSteps([ + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]).catch(() => {}); + throw new CodexCliSelectionMutationBusyError(); + } try { child = Bun.spawn([invocation.file, ...invocation.args], { cwd: isolation, @@ -376,10 +397,12 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim ...invocation.options, }); } catch (error) { + selectionWrite.exit(); await bridge.stop(); removeRemoteWorkspaceIsolation(isolation); throw error; } + selectionWrite.exit(); const peer = new JsonLineRpcProcess(child); let activeTurn: ActiveTurn | null = null; let stopped = false; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 177d66cc84b..c51b21b34e4 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -83,6 +83,7 @@ import { handleClaudeDesktopPickerRoutes } from "./management/claude-desktop-pic import { handleDesktopCompatibilityRoutes } from "./management/desktop-compatibility-routes"; import { handleDesktopCompatibilityRuntimeRoutes } from "./management/desktop-compatibility-runtime-routes"; import { handleDesktopCompatibilitySettingsRoutes } from "./management/desktop-compatibility-settings-routes"; +import { handleCodexCliUpdateRoutes } from "./management/codex-cli-update-routes"; import { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes"; import type { ManagementContext } from "./management/context"; import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth"; @@ -384,6 +385,7 @@ export async function handleManagementAPI( ?? (await handleDesktopCompatibilityRoutes(ctx)) ?? (await handleDesktopCompatibilityRuntimeRoutes(ctx)) ?? (await handleDesktopCompatibilitySettingsRoutes(ctx)) + ?? (await handleCodexCliUpdateRoutes(ctx)) ?? (await handleAgentSettingsRoutes(ctx)) ?? (await handleCodexPromptRoutes(ctx)) ?? (await handleOauthAccountRoutes(ctx)) diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts new file mode 100644 index 00000000000..3b780c5bcab --- /dev/null +++ b/src/server/management/codex-cli-update-routes.ts @@ -0,0 +1,54 @@ +import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../codex/cli-update-job"; +import { runCodexCliUpdateApplyInWorker, runCodexCliUpdatePlanInWorker } from "../../codex/cli-update-job-worker"; +import { jsonResponse } from "../auth-cors"; +import { trackBackgroundWork } from "../../lib/background-work"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +const STATUS = /^\/api\/codex\/cli-update\/jobs\/([0-9a-f-]{36})$/; + +export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promise { + if (ctx.url.pathname === "/api/codex/cli-update/plan") { + if (ctx.req.method !== "GET") return jsonResponse({ error: "method_not_allowed" }, 405, ctx.req, ctx.config); + try { + const plan = await runCodexCliUpdatePlanInWorker(); + return jsonResponse(plan, 200, ctx.req, ctx.config); + } catch { return jsonResponse({ error: "plan_unavailable" }, 503, ctx.req, ctx.config); } + } + const match = STATUS.exec(ctx.url.pathname); + if (match && ctx.req.method === "GET") { + try { + const job = readCodexCliUpdateJob(match[1]!); + return job ? jsonResponse(job, 200, ctx.req, ctx.config) : jsonResponse({ error: "job_not_found" }, 404, ctx.req, ctx.config); + } catch { return jsonResponse({ error: "job_state_unavailable" }, 503, ctx.req, ctx.config); } + } + if (ctx.url.pathname !== "/api/codex/cli-update/apply") return null; + if (ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405, ctx.req, ctx.config); + if (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress) { + return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403, ctx.req, ctx.config); + } + let body: unknown; + try { body = await readManagementJsonBody(ctx.req); } + catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400, ctx.req, ctx.config); } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); + const value = body as Record; + if (Object.keys(value).some(key => !["requestId", "planId", "confirmed"].includes(key)) + || value.confirmed !== true || typeof value.requestId !== "string" || typeof value.planId !== "string") { + return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); + } + try { + const admitted = admitCodexCliUpdateJob({ requestId: value.requestId, planId: value.planId, confirmed: true }); + if (admitted.kind === "accepted") { + const work = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => runCodexCliUpdateApplyInWorker(value.planId as string, { jobId: admitted.job.jobId })); + trackBackgroundWork(`codex-cli-update:${admitted.job.jobId}`, work).catch(() => undefined); + } + return jsonResponse({ job: admitted.job }, 202, ctx.req, ctx.config); + } catch (error) { + const code = error instanceof Error ? error.message : "admission_failed"; + const status = code === "idempotency_conflict" ? 409 : code === "invalid_request" ? 400 : 503; + const response = jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "job_store_busy", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); + if (code === "job_store_busy") response.headers.set("Retry-After", "1"); + return response; + } +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 4dcb19d7167..a7db3d46fb5 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -176,6 +176,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/codex/desktop-compatibility/runtime", module: "server/management/desktop-compatibility-runtime-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Native app launch and account-wide UI trial require explicit local dashboard confirmation." } }, { method: "GET", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "deferred-verb", owner: "codex-desktop-compatibility", ownerDoc: "structure/clients/codex-desktop.md", why: "The persisted startup preference is currently owned by the local desktop compatibility panel." } }, { method: "POST", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Saved native observation intent requires an explicit local GUI action and fresh field revision." } }, + // server/management/codex-cli-update-routes + { method: "GET", path: "/api/codex/cli-update/plan", module: "server/management/codex-cli-update-routes", mutates: false, exempt: { reason: "local-transport", why: "The CLI runs the same proof-bound plan builder locally; the authenticated route also supplies the dashboard plan." } }, + { method: "POST", path: "/api/codex/cli-update/apply", module: "server/management/codex-cli-update-routes", mutates: true, exempt: { reason: "session-only", why: "HTTP apply requires a confirmed trusted loopback GUI session; the CLI invokes the same durable job owner locally." } }, + { method: "GET", path: "/api/codex/cli-update/jobs/{jobId}", module: "server/management/codex-cli-update-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "The CLI status command reads the same protected job record directly and does not use job IDs as authorization." } }, { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true }, diff --git a/src/update/codex-cli-update-launch-policy.mjs b/src/update/codex-cli-update-launch-policy.mjs index 8a1759d66d4..bef64366e27 100644 --- a/src/update/codex-cli-update-launch-policy.mjs +++ b/src/update/codex-cli-update-launch-policy.mjs @@ -39,6 +39,15 @@ export function isCodexCliUpdateInspectionArgv(argv) { return args[0] === "system" && args[1] === "codex-cli-update"; } +/** Long-lived published proxy servers need the same proof-bound snapshot so management routes + * can inspect the Node-selected Codex CLI without accepting a client path or ambient dotenv value. + * This only controls snapshot inclusion; it does not extend the one-shot inspection restrictions. + */ +export function carriesCodexCliUpdateInspectionContext(argv) { + const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof=")); + return isCodexCliUpdateInspectionArgv(argv) || args[0] === "start"; +} + /** Serialized OCX_NODE_LAUNCH_CONTEXT ceiling; src/cli/launcher-context.ts refuses above it. */ export const NODE_LAUNCH_CONTEXT_MAX_CHARS = 64 * 1024; diff --git a/structure/INDEX.md b/structure/INDEX.md index f33039a1cfd..bf7ea8e30ae 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -28,7 +28,7 @@ Persisted config, the Codex home it writes into, and the model catalog it publis | [`codex-provider-routing.md`](codex-provider-routing.md) | Explicit provider-ID routing consent, URL-only publication and recovery. | | [`codex-home.md`](codex-home.md) | CODEX_HOME resolution, the files opencodex manages there, and Codex-home diagnostics. | | [`codex-prompts.md`](codex-prompts.md) | Prompt-layer repair, base import previews and bounded text-probe sources. | -| [`codex-cli-update.md`](codex-cli-update.md) | Read-only update planning, isolated npm environment, and complete process observation. | +| [`codex-cli-update.md`](codex-cli-update.md) | Content-bound planning and explicit POSIX apply with isolated npm, artifact validation, installation leases and readback. | | [`catalog.md`](catalog.md) | Shared Codex catalog assembly, account namespaces, pool rotation, and effort ladders. | | [`catalog-vendor-context.md`](catalog-vendor-context.md) | Bundled model-ID window fallback, ambiguity handling, and inferred-capacity provenance. | | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | Persisted effort-clamp observation identity and idempotent sync notices. | @@ -152,6 +152,7 @@ A source area can be described by more than one doc, because these docs are orga | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `app/` | [`overview.md`](overview.md)
[`companion.md`](companion.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `bin/ocx.mjs` | [`codex-cli-update.md`](codex-cli-update.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
[`companion.md`](companion.md) | | `desktop/src-tauri/src/cli_command.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | @@ -191,6 +192,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | +| `src/cli/codex-cli-update.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/cli/codex-provider.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/cli/link-apply-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | @@ -212,10 +214,23 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/routed-gather.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`providers/opengateway.md`](providers/opengateway.md) | | `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | +| `src/codex/cli-update-apply.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-artifacts.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-files.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-inspection-context.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job-execution.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-lease.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-plan.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-selection-gate.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | +| `src/codex/windows-cli-child-native.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/windows-cli-child-pipe.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/windows-cli-child.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | @@ -288,6 +303,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/inference/advisor-diagnostics.ts` | [`advisor.md`](advisor.md) | | `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | +| `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/cli-management.md b/structure/cli-management.md index ba33a69f227..d89f28eacec 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -155,3 +155,7 @@ rechecks it under the config lock before saving. Noninteractive allow requires ` no prompt is issued during background start/sync. The [configuration contract](codex-provider-routing.md) owns scope, eligibility, drift refusal and recovery. No endpoint or credential is printed by this command. + +`ocx system codex-cli-update apply` durably admits and records the apply result under the protected config root; `--request-id` provides a retry key, and reusing it with a different plan conflicts. `ocx system codex-cli-update status ` reads the same path-free terminal DTO without a GUI token. A record left queued/running after owner restart is reported ambiguous with unknown owner state. Apply remains limited to the supported POSIX npm-global installation. + +The capability declarations document `ocx system codex-cli-update plan`, `apply` and `status` as the local counterparts of `GET /api/codex/cli-update/plan`, `POST /api/codex/cli-update/apply` and `GET /api/codex/cli-update/jobs/{jobId}`. They share the plan builder and durable job owner with the dashboard rather than sending HTTP management requests, so their driven-route lists remain empty. CLI apply requires `--yes`; HTTP apply separately requires explicit confirmation from a trusted loopback GUI session. The GUI parity test resolves these registered capabilities, checks their mutation classifications and documented resource counterparts, and includes all three resources in endpoint coverage. diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 1471016161a..dd6d08ff768 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -24,6 +24,10 @@ Under the test preload's `OCX_TEST_HOME_GUARD=1`, `src/codex/desktop-app-restart.ts` skips real restart without an injected executor. `src/cli/restart-scope.ts` reports that skip. `NODE_ENV=test` alone does not control this boundary. `tests/clients/desktop-app-restart.test.ts` covers the contract. +The same function refuses with the `selection_mutation_busy` skipped reason while a cli-update +worker holds its commit window: the refusal precedes the restart lock, package discovery, any app +quit and the handoff, and a lock-acquisition throw still releases the shared gate. See the +[update commit window](../codex-cli-update.md#worker-realm-and-the-selection-commit-window). ## Windows launch context diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 310445ddb3e..73e8710954c 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -1,4 +1,4 @@ -# Codex CLI Update Plan +# Codex CLI Update `src/codex/cli-update-plan.ts` constructs the read-only update plan; `src/codex/app-server-processes.ts` supplies its process observation. @@ -39,19 +39,245 @@ retargeted selection link, or an unmodelable global lookup all fail closed. The links are checked after the final file-identity pass while native descriptors remain open; this observes linkage at that boundary and does not prevent later symlink mutation. A managed install with no bound content is refused install_content_unverified before any registry query. The digest is -observation only — it carries no execution guarantee — so a future apply phase must re-observe +observation only — it carries no execution guarantee — so the apply phase re-observes rather than rely on it. The observation is opt-in: the plan path requests it because the planId binds it, while a bare check leaves the digest null rather than paying the bounded read. -Selection observation uses the runtime resolver with provenance's bounded no-follow, nonblocking state reader and local executable-path policy. Windows returns before invoking the resolver; its deferred inspection performs no candidate or configuration I/O. +Selection observation uses the runtime resolver with provenance's bounded no-follow, nonblocking state reader and local executable-path policy. Windows supports the restricted handle-bound plan predicate below; other Windows layouts remain deferred. Its child environment drops npm configuration overrides, named Node startup/output controls, the `LD_*`/`DYLD_*` loader families and the OpenSSL startup configuration and provider/engine search variables; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. Ambient NODE_COMPILE_CACHE is replaced with an owned path inside the isolation directory because npm's own enableCompileCache() otherwise recreates a default cache outside that directory. Distinct npm config files, npm/Node caches and logs stay under the owned temporary root with best-effort -cleanup; a cleanup error does not replace the computed plan/refusal. The POSIX update -scan includes all readable users by default, retaining an explicitly injected UID scope. +cleanup; a cleanup error does not replace the computed plan/refusal. Registry metadata +and the request-path process-table scan wait off the server event loop: the bounded npm +children and the darwin/Windows enumerations resolve asynchronously while keeping the +per-query deadline, bounded output, termination and status-null refusal lanes of the +synchronous contract, so a stalled registry cannot park unrelated proxy requests or +streaming; the apply re-scan uses the same async scan. Linux /proc observation remains +synchronous underneath as bounded filesystem reads rather than a child-process wait. +The POSIX update scan includes all readable users by default, retaining an explicitly injected UID scope. Linux per-process `status`/`cmdline` read failures invalidate a complete observation except for `ENOENT` (a process that disappeared); incomplete observation refuses with `blocked_process_state_unknown`; the restart/kill path skips unreadable entries while retaining independently verified same-user app-server targets. This plan neither installs a runtime nor signals an existing Codex session. Windows enumeration/installation limits remain unchanged. + +## Windows read-only plan predicate + +`src/codex/cli-windows-plan-provenance.ts` admits only a proof-captured, drive-absolute +`CODEX_CLI_PATH` naming the exact standard npm `codex.cmd` grammar. The global npm +package prefix comes from the shim directory and its `node_modules` tree. This plan +currently requires prefix-local `node.exe` and npm. Derivation may propose a PATH Node only +when the local Node probe proves `not-found`; a refused/reparse probe never counts as absent. +The production plan still refuses that external candidate: a standard npm cmd-shim runs bare +`node` when its local Node is absent, and CMD can prefer the invocation working directory. +The proof-bound launcher snapshot has PATH/PATHEXT but no target-shim invocation +cwd/search-result evidence, so a separate Node cannot be selected safely yet. The production runtime resolver confirms the explicit Codex override's +priority without saved-state reads or probes. A PE header binds Node's architecture; +the package manifest/bin, npm manifest/bin, Node and the first in-prefix platform +package plus native backing are observed together. Backing lookup follows the +nearest-first Node lanes and is repeated while the files and ancestors remain held. +Native handles refuse reparse points, writers and rename/delete sharing; initial +manifest and Node identities must match the final observation. Text stays bounded +to 256 KiB and Node to 128 MiB. The native backing uses the same finite 1 GiB +`MAX_NATIVE_BACKING_BYTES` as POSIX, defined in `src/codex/cli-installation-limits.ts`. +The native reader bounds aggregate requests to that backing plus 128 MiB Node +and twelve 1 MiB text requests; hash-only bytes are never buffered whole. +Only the opt-in content observation streams the entrypoint, npm CLI, Node and native bytes. The +plan path sets it, so the plan-bound `installContentDigest` is preserved; a bare check holds the +same files — Node and native included — open and identity-checked metadata-only, with zero content +bytes read and a null `installContentDigest`. + +PATH-only or separate-prefix Node selection, autostart wrappers, global/vendor fallback, +app/Store/MSIX bundles and version-manager ownership remain unverified and unmanaged. +The explicit `attest` report stays observation-only. Only the production plan predicate +creates private plan evidence; copying a public report cannot enable Windows planning. +This observes linkage and content, not publisher authenticity or a whole-tree guarantee. +The fresh-source Worker case in `tests/codex-integration/codex-cli-windows-plan.test.ts` +rebuilds private evidence through the native inspector inside that realm before resolving +owned fixture metadata. It does not establish compiled-worker or Windows ARM64 support. +Metadata queries use the observed Node/npm paths under freshly matching held handles, +the existing isolated npm configuration/cache, and the pinned registry. No installer runs. +A completed Windows read-only plan has a deterministic ID and `applicable: false`, +`refusal: windows_apply_disabled`; active/unknown sessions still refuse earlier. +The independent Windows apply entrypoint remains refused before inspection or lease creation. + +## Independent Windows child lifetime primitive + +`src/codex/windows-cli-child.ts` supplies an opt-in Windows x64 child owner, with native ABI/handle +ownership in `src/codex/windows-cli-child-native.ts` and overlapped output in +`src/codex/windows-cli-child-pipe.ts`. No plan, installer, pack or version consumer imports it yet; +Windows apply remains disabled. ARM64 is unsupported. + +It starts only an explicit drive-absolute `.exe` plus argv: `CreateProcessW(CREATE_SUSPENDED)`, +assignment to an unnamed non-inheritable Job, then `ResumeThread`. There is no PID reopen, +shell, breakaway fallback, process-count limit or kill-on-close limit. Assignment/resume failures +reap the original suspended primary before user code; failed termination retains an unknown owner. +`STARTUPINFOEXW` and an explicitly rooted attribute buffer whitelist exactly the three owned, +inheritable stdio handles with `bInheritHandles=TRUE`. Parent read/event/Job/process/thread handles +are not inheritable, and parent copies of the stdio ends close before any await. This is forward +isolation from unrelated inheritable handles, not reverse protection from a foreign unrestricted +`CreateProcess(TRUE)` while those stdio handles are temporarily inheritable. + +The output pipe uses an unpredictable name, a protected current-token-user DACL, remote-client +rejection, and a real overlapped read buffer/event. Cancellation retains that memory until native +completion settles; neither `PeekNamedPipe` nor a Worker script URL is used. Captured bytes are +decoded together as UTF-8. Installer output is discarded; pack captures 1 MiB and continues draining +excess without termination. Neither policy adds an overall deadline or running-abort kill. Version +alone has the existing 16 KiB, 8 second deadline and 1 second reap policy and may terminate its Job. +A termination acknowledgment, timeout, I/O error, failed wait/query or PID absence is not terminal. +The original primary handle must signal and a successful Job accounting query must report zero +active processes; exit code 259 is valid once that handle signals. Output still drains after Job +emptiness. Normal `CreateProcess` descendants are covered; WMI-created processes are not claimed. +Unknown results retain the owner and all lease/staging obligations. Setup failures with no process +use `cleanupUnstartedWindowsChild` to settle any connect operation and release only owned handles. + +The current `packArtifact` reads `packed.exitCode` without adopting `packed.terminal`, and apply's +`started` flag is set only at the installer. Consumer integration therefore requires adopting lifetime +and lease/staging retention before **every** spawn, including pack, before enabling Windows apply. +The npm `--fetch-timeout=120000` remains a separate fetch policy, not a process lifetime deadline. +`tests/codex-integration/codex-cli-windows-child.test.ts` exercises actual owned native fixtures, +including overlapping whitelist isolation, stdout-free descendants, incompatible nested Jobs, +pre-user-code failures, byte caps, cancellation settlement, wait/query failures and version reaping. + +## Explicit POSIX apply + +`src/cli/codex-cli-update.ts` accepts `apply --plan-id <32 lowercase hex> --yes` with optional JSON output. +`src/codex/cli-update-apply.ts` applies only a selected, managed, non-shim POSIX npm-global installation. +The inspector provides process-local canonical prefix/launcher/package evidence through an internal WeakMap; +public report locations are display-only and are never reconstructed into write targets. +`src/codex/cli-update-lease.ts` acquires one installation-prefix mutex without reentrancy, delegation, +waiting, or automatic stale/empty/suspicious-owner recovery. Only a matching owned directory and owner +file can be released. This apply lease is separate from service ownership leases. + +Under that lease, a fresh applicable plan must match the approved ID. Main and platform metadata include +exact versions, canonical SHA-512 integrity and official registry tarball URLs; all artifact fields, +platform and inspection architecture contribute to plan identity. Platform aliases whose published +manifest name is also `@openai/codex` are modeled explicitly. The existing isolated npm environment, +cwd, cache and distinct npmrc boundary is reused for metadata, exact-version pack and install calls. +The version verification child inherits the owned Node compile-cache path, which is removed with +terminal apply staging; an uncertain live child retains that staging and its lease. A verifier setup +failure before any child exists is terminal evidence instead, so the lease is released and later applies are not blocked. +The owned staging directory is canonicalized immediately after creation, so symlinked temporary roots +and macOS's /var alias are supported without accepting a linked archive file. +`src/codex/cli-update-artifacts.ts` streams compressed integrity and tar validation with independent +256 MiB compressed, 512 MiB total unpacked, 384 MiB member and 4,096 member bounds. Only small +manifest/entrypoint bytes are retained; native and auxiliary members retain size/mode/hash metadata. +Links, traversal, unsupported tar records, dependency-bearing manifests and missing backing files refuse. +After ReadStream construction it exclusively owns the archive descriptor. The held-fd identity check +precedes explicit stream destruction, and verification waits for the close event on success and failure; +pipeline settlement alone is not close completion and the caller never closes that raw fd again. + +After artifact preparation, selection/content, the approved metadata and active/unknown process blockers +are re-observed. Inside the worker realm the awaited process scan is the last yield before install; in +the same worker event-loop turn as the spawn a synchronous guard re-checks the cancellation state, +prefix identity, the byte-level preimage and the launcher's live canonical selection, and the per-job +commit fence's PREPARING-to-COMMITTED compare-exchange is the final guard before the installer spawn — +a parent cancel that already landed wins, while a cancel landing after COMMITTED defers to the live +child's close and readback. npm receives only two verified local tarball specs and an explicit canonical prefix, +with offline mode, optional dependencies omitted, lifecycle scripts disabled and no audit/fund/lockfile +side effects. Main manifests are not rewritten to make installation succeed. Cancelled preparation +refuses; cancellation during an admitted npm child waits for its close rather than killing it. The lease +and staging remain held while the installer is live or uncertain. There is no automatic retry or rollback. +`src/codex/cli-update-files.ts` compares the complete bounded regular-file sets after installation, +including auxiliary members and executable status; original-state readback also compares original modes. +The preimage covers selected roots and every planned main/backing mutation root, recording both full +existing file sets and absent roots. A newly created sibling backing cannot be called unchanged merely +because the previously selected nested backing and main package still match. +Required launcher/native paths must also pass the current user's X_OK check; another user's executable bit alone is insufficient. +After file/selection readback, the selected launcher runs with `--version` in an owned home/temp sandbox, +with 16 KiB output and an eight-second deadline. Normal exit and the exact target version are required. +Only this verification's new POSIX process group may be terminated on a deadline; an unconfirmed close/group +exit retains lease/staging and reports its PID with an ambiguous result. Existing sessions are never signalled. +Verified target readback and execution are `applied` even after nonzero installer exit; unchanged full original evidence +is `not_applied` even after exit zero. Partial, unreadable or uncertain results are `ambiguous`. +Cleanup failures are path-free `cleanupErrors` alongside the computed outcome. A failed or thrown +lease release marks retention unverified without preventing staging cleanup; cleanup does not replace +the readback outcome. Uncertain live installer/version children still retain both lease and staging. +Windows apply remains refused. Shims and non-npm managers remain unsupported. + +## Worker realm and the selection commit window + +The plan and apply bodies execute inside a dedicated Worker realm so their synchronous file hashing and +directory walks never occupy the parent's HTTP/event loop. `src/codex/cli-update-worker.ts` is the +per-job worker entrypoint, registered as `cli-update-worker` in the standalone build's +`WORKER_ENTRIES` (`scripts/build-standalone.ts`); `src/codex/cli-update-job-worker.ts` is the +parent-side dispatch the CLI and the management route call. The run message carries the allowlisted +frozen launcher snapshot — never the parent's trustedContext and never supplemented from ambient +environment — plus two shared-memory cells from `src/codex/cli-update-selection-gate.ts` and at most +the two home overrides as transport. `src/codex/cli-update-inspection-context.ts` exposes the shared +factory that rebuilds production inspection deps from that snapshot inside whichever realm calls it, so +the worker derives its own fresh selected-command evidence; the installer inspector's mutation-evidence +WeakMap is per-realm, which is exactly why the whole job — not just its hashing — must run there. + +A job is one fresh Worker, one unique requestId and the parent ledger generation; no wire generation +exists. The worker runs one message, replies and closes itself; a second message is ignored, so a stale +or duplicate delivery cannot attach new work to a dying realm. Settlement requires BOTH a valid reply +and the worker's own close — a done payload alone is never terminal evidence because a legitimate +result may still report installer_state_unknown or version_process_state_unknown with a retained lease +and a live verification child. The host never calls terminate(): an unresponsive worker may still own a +running npm child, so every post-delivery loss — worker death, message error, an implausible or missing +reply, or done without close inside the bounded grace — settles `ambiguous`/`job_result_unknown` with +the lease reported retained and the still-live worker tracked rather than silently leaked. A spawn +throw before the run message exists is the only `refused`/`preparation_failed` path, because no job +code provably ran. Ledger completion writes keep their existing bounded retry. + +Entry installation is limited to `Bun.isMainThread === false`; importing either entry or its +test fixture in the main realm must not replace that realm's message handler. Bun 1.4.4 does not +provide `self.close()`: completion removes the worker's message handler and lets referenced work +drain naturally, without `process.exit()` or parent termination. A real Bun-subprocess regression +keeps the worker alive after handler removal until that exact child exits. This establishes the +referenced Bun-child path only, not arbitrary native handles or all OS descendants. Bun's close +event marks worker termination and can precede full thread exit; it is not independent proof that +every installer descendant or retained native owner has finished. + +Cancellation crosses realms through shared memory, not a message: the parent CASes the job fence +PREPARING-to-CANCELLED, the worker's `isCancelled` checkpoints read that word during its synchronous +sections, and the worker's own PREPARING-to-COMMITTED CAS immediately before the installer spawn is the +commit decision. COMMITTED is deliberately not "cancelled" — once it lands the installer is a live +child whose close must be observed. Because a plain AbortSignal message could not interrupt a +synchronous worker section, signal-based cancellation alone is not equivalent and is not used as the +worker path's only channel. + +The worker's final window is also serialized against the parent's own selection mutations through one +process-wide gate cell. The gate covers exactly seven parent operations: `persistCodexRuntime` and +`clearPersistedCodexRuntime` in `src/codex/runtime.ts` (busy refusal — the +`codex_cli_selection_busy` error — before mkdir, cache invalidation or the swallowing unlink catch), +the three shim overlay boundaries in `src/codex/shim-overlay.ts` (install, auto-restore, uninstall — +busy in their existing failure/deferred/removed:false shapes before namespace creation, the restore +lock and journal mutation), the remote-workspace app-server `Bun.spawn` in +`src/remote-control/workspace-codex-runtime.ts` (busy refuses after running the same bridge/isolation +cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal), and +`src/codex/desktop-app-restart.ts` (busy returns the new `selection_mutation_busy` skipped reason +before the restart lock, package discovery, any app quit and the handoff, so a queued relaunch — and +its own lock-acquisition failure — cannot slip inside the window). Writers never wait: the commit +window spans only the worker's final process scan through its installer-spawn call, and a synchronous +parent writer must not park the event loop behind it, so a held gate is an immediate busy. The gate is +one cell for the whole process rather than per-configDir because alias, junction and case spellings of +the same target must not split the exclusion; a writer on a different configDir can see a brief busy. +Ordinary config writes, account operations, model requests, existing session interrupt/close and +arbitrary subprocesses are not gated. The gate claims nothing about processes the parent does not own: +an external installer, a handoff helper's delayed descendants, or an OS-level snapshot race stays the +documented residual it always was. + +`bin/ocx.mjs` includes the proof-bound Codex candidate snapshot for the published `start` command as well as the updater commands; this makes the production management plan work without accepting request-supplied candidate evidence. `src/codex/cli-update-inspection-context.ts` is the shared bridge from that Node snapshot to the installer inspector. Direct Bun/source launches have no trusted candidate and fail closed; neither path reconstructs write targets from display data. `src/codex/cli-update-job.ts` owns the bounded durable admission ledger shared by CLI and management. Request IDs are scoped to the protected config root and payload digest; exact retries return the same opaque job, conflicts refuse, and a full ledger refuses new admission. Every store operation requires a canonical same-owner config parent with safe write permissions and canonical ancestry to filesystem root; each ancestor must be owned by root or the current user, and shared-writable ancestors are refused unless root-owned and sticky. Only confirmed apply admission may create one absent config-root level, which is revalidated as owner-private. Plan and status reads never create it, and an existing root must pass the same canonical/no-link checks. This path-based ancestry check prevents untrusted other-user writable ancestors from redirecting store operations; it does not defend against concurrent tampering by the same user or root. Atomic updates use a private same-owner POSIX directory, no-follow reads and a short exclusive lock. Unresolved records are not expired. An in-process generation and revision own completion writes; after restart a queued/running record projects as `ambiguous` with `owner_state_unknown`, without claiming the old owner stopped. A `retained` lease state requires an execution reason that proves acquisition and intentional preservation; synthetic job errors, unverified release and release exceptions project as `unknown`. The path-free DTO separates owner state, installer child state, lease retention and overall lifetime uncertainty with fixed outcome/reason/cleanup codes. + +`src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. + +The ledger retains at most 64 jobs, including terminal jobs, and never prunes request IDs automatically. +At capacity it refuses new request IDs with `store_full`; existing matching retries still return the same job. +Capacity expansion and operator recovery require a separate design that preserves deduplication and job-status evidence. +`src/codex/cli-update-job-execution.ts` allows at most eight worker start/completion lock attempts, +with asynchronous 25 ms waits and no stale-lock reclamation. HTTP admission reports `job_store_busy` +with status 503 and `Retry-After: 1`; permanent admission failures do not advertise a retry delay. +Only engine exceptions synthesize `job_result_unknown`; persistence retries keep the same actual result. +Transient completion-write failures have the same bounded retry budget. Permanent failure reports +`job_result_persistence_failed` while retaining the computed result in the error; it does not establish durable completion. +CLI apply prints that failure with the admitted job ID, normalized outcome/reason, lease retention and fixed cleanup codes +in both human and JSON output, and exits 1. It never publishes raw result/error fields or reruns the installer to recover the receipt. +Unknown result/cleanup codes are normalized to fixed-code uncertainty before writing, so they cannot poison later reads. +Unsupported prefixes and unavailable lease storage are distinct from a held installation lease. +Missing/unreadable CLI job status has a structured `job_unavailable` JSON error and a nonzero exit. +CLI apply exits 0 only for an `applied` job; other outcomes and pending duplicates exit 1, including JSON output. +Failed lock initialization rolls back only the fresh directory and partial owner file whose identities this call proves; +replacement identities or unprovable ownership remain fail-closed. Fully initialized locks retain token-fenced cleanup. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 44729c1de4a..f9992a481d1 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -597,4 +597,4 @@ keep their existing no-catalog-refresh behavior. The regression suite is Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. The PINNED badge confirms before sending a null selection, which clears the pin and returns routing to automatic while bound threads keep their captured account. The dashboard also sends `expectedPinnedAccountId`; the persisted mutation rejects a changed pin with `account_pin_changed` before writing. Refused or uncertain releases reload `/active` and close the old confirmation without claiming that the prior selection survived. -`src/server/management/subagent-model-routes.ts` owns roster/picker and optional Claude force updates. The route registry declares its GET/PUT ownership; `agent-settings-routes.ts` only lazy-loads and delegates the matching path. GET includes `force`, exposed-only `forceAvailable`, and bounded `forceStatus`; PUT accepts force alone, null clears, and omission preserves it. Force-only updates do not regenerate the roster or converge Codex catalogs. The Subagents page keeps save failures visible and disables overlapping force writes. +`src/server/management/subagent-model-routes.ts` owns roster/picker and optional Claude force updates. The route registry declares its GET/PUT ownership; `agent-settings-routes.ts` only lazy-loads and delegates the matching path. GET includes `force`, exposed-only `forceAvailable`, and bounded `forceStatus`; PUT accepts force alone, null clears, and omission preserves it. Force-only updates do not regenerate the roster or converge Codex catalogs. The Subagents page keeps save failures visible and disables overlapping force writes. `src/server/management/codex-cli-update-routes.ts` serves the same proof-bound Codex CLI plan to the dashboard, admits apply only from a confirmed trusted loopback GUI session, and exposes authenticated job status. The job ID is a lookup key, never authorization; CLI status reads the protected common job record directly. The route registry declares the three paths and the CLI/local-transport parity. diff --git a/structure/manifest.json b/structure/manifest.json index 7b0d19d4260..f05908e25c7 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -172,11 +172,27 @@ { "path": "codex-cli-update.md", "tier": 2, - "title": "Codex CLI Update Plan", - "scope": "Read-only update planning, isolated npm environment, and complete process observation.", + "title": "Codex CLI Update", + "scope": "Content-bound planning and explicit POSIX apply with isolated npm, artifact validation, installation leases and readback.", "documents": [ "src/codex/cli-update-plan.ts", - "src/codex/app-server-processes.ts" + "src/codex/windows-cli-child.ts", + "src/codex/windows-cli-child-native.ts", + "src/codex/windows-cli-child-pipe.ts", + "src/codex/cli-update-apply.ts", + "src/codex/cli-update-job.ts", + "src/codex/cli-update-job-execution.ts", + "src/codex/cli-update-job-worker.ts", + "src/codex/cli-update-selection-gate.ts", + "src/codex/cli-update-worker.ts", + "src/server/management/codex-cli-update-routes.ts", + "src/codex/cli-update-inspection-context.ts", + "bin/ocx.mjs", + "src/codex/cli-update-artifacts.ts", + "src/codex/cli-update-files.ts", + "src/codex/cli-update-lease.ts", + "src/codex/app-server-processes.ts", + "src/cli/codex-cli-update.ts" ] }, { diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 93e43c41221..fbe0889439d 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -57,6 +57,7 @@ A track that holds a nested split reserves that subtree's minimum width (`minLay side-by-side splits never scroll inside a pane column; an oversized tree scrolls the pane surface once. Hub runtime admission counts pending create/resume starts as well as live handles against global and per-device limits; every outcome releases its reservation. Stop and shutdown reclaim late resumed handles before clearing ownership. Coordinator result size limits normalize both response text and success, so bridge and MCP callers receive consistent errors. +`src/remote-control/workspace-codex-runtime.ts` takes the shared Codex-CLI-update selection gate immediately before its app-server `Bun.spawn`; while a cli-update worker holds its short commit window the launch refuses with `codex_cli_selection_busy` after running the same bridge/isolation cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). Prompt HTTP admission returns 202 with the existing session/event cursor; tracked operations publish terminal status for polling and attach a rejection observer immediately. Reconnect/resume cannot publish ready while a turn is active. The dashboard prefers newer local event cursors over stale polling, favors authoritative polling on ties, retains unconfirmed draft text with an uncertainty notice, and never retries prompt POSTs automatically. diff --git a/structure/runtime.md b/structure/runtime.md index 65a345ef8dd..fb9b63a3e60 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ The minute sweep checks persisted activation deadlines locally; only missing dea ## Bun runtime and test runner -`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.4 and the test runner (`testRunnerBun`) to 1.4.3; `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. +`package.json` pins both the shipped npm runtime (`dependencies.bun`) and the test runner (`testRunnerBun`) to 1.4.4; the roles remain independently validated and `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. It left 1.4.0 for 1.4.3 because of oven-sh/bun#44581 (#4956). Bun 1.4.0's `Bun.spawnSync` pointed the VM at its private wait loop, so a finalizer that ran during one call (an earlier file's stdout/stderr sink, a stream reader) released its poll on that loop; the cached loop's poll count drifted to zero and a later call stopped polling for its child, spinning past its own `timeout` until the 120-second batch deadline. In a multi-file `--isolate` process that depends on what earlier files left to finalize, so single-file attribution stayed clean. `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` reproduces the mechanism in a child under a bounded parent and fails on 1.4.0. #6713 run 37631558263 reproduces that 1.4.2 preload segfault on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works. @@ -12,7 +12,7 @@ The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload se Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin. Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin. -`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split. +`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading. The caller need not already run the test pin; only a version match permits reusing its executable. `tests/test-layout-tooling.test.ts` verifies matching/different caller fixtures, the selected isolate command, and refusal before spawning tests for a wrong-version override. The 1.4.4 security transition aligns the default versions while retaining the role fields, resolver and CI role checks. Promotion requires successful full cross-platform `lane=all` proof on the exact candidate head; focused local tests do not establish that proof. ## Resolved static model policy @@ -95,7 +95,7 @@ The prefilter is only an optimization, not final process-membership authority. ## Explicit Codex CLI installation observation -`src/cli/codex-cli-update.ts` dispatches the opt-in Windows x64 `attest` operation to `src/codex/cli-installation-identity.ts`. With no options, `src/codex/cli-installation-targets.ts` derives the four inputs from the proof-bound launcher snapshot: the configured candidate or the first codex on the captured PATH, an OpenCodex wrapper resolving to its renamed npm backing, the npm prefix layout, and the Node/npm toolchain beside the resolved node.exe. Configured values containing a path separator must be drive-absolute; otherwise derivation refuses with `candidate_unavailable` instead of substituting a different PATH candidate. Bare command names and the unset default continue to resolve only through the captured PATH. Discovery probes candidates through the same local-volume, reparse-refusing held-handle reader as final observation, so captured PATH entries cannot trigger network filesystem I/O. A refused or unavailable prefix-local node.exe probe is not absence evidence, so derivation refuses with `toolchain_unresolved` instead of attesting a PATH-resolved Node. It never reads ambient state. Four explicit absolute paths remain accepted as an all-or-none override. Only the standard npm command shim or direct Codex package entry is accepted. The native reader in `src/codex/windows-installation-files.ts` holds ancestor/file handles for bounded reads and rejects reparse points, conflicting writers and unsupported paths/platforms. Its path-free report binds file identities and bytes to this observation, not a durable update permission. `installationIdentityObserved` can be true; `selectionAttested`, `managed` and `applyAllowed` remain false. Supplied Node identity does not prove launcher selection, effective npm config, past installer identity or tool authenticity. No package-registry request, installation, config write or process control occurs. Existing Windows `check` retains zero candidate/config filesystem I/O. A reported refusal can exit 0; consumers inspect `status`. +`src/cli/codex-cli-update.ts` dispatches the opt-in Windows x64 `attest` operation to `src/codex/cli-installation-identity.ts`. With no options, `src/codex/cli-installation-targets.ts` derives the four inputs from the proof-bound launcher snapshot: the configured candidate or the first codex on the captured PATH, an OpenCodex wrapper resolving to its renamed npm backing, the npm prefix layout, and the Node/npm toolchain beside the resolved node.exe. Configured values containing a path separator must be drive-absolute; otherwise derivation refuses with `candidate_unavailable` instead of substituting a different PATH candidate. Bare command names and the unset default continue to resolve only through the captured PATH. Discovery probes candidates through the same local-volume, reparse-refusing held-handle reader as final observation, so captured PATH entries cannot trigger network filesystem I/O. A refused or unavailable prefix-local node.exe probe is not absence evidence, so derivation refuses with `toolchain_unresolved` instead of attesting a PATH-resolved Node. It never reads ambient state. Four explicit absolute paths remain accepted as an all-or-none override. Only the standard npm command shim or direct Codex package entry is accepted. The native reader in `src/codex/windows-installation-files.ts` holds ancestor/file handles for bounded reads and rejects reparse points, conflicting writers and unsupported paths/platforms. Its path-free report binds file identities and bytes to this observation, not a durable update permission. `installationIdentityObserved` can be true; `selectionAttested`, `managed` and `applyAllowed` remain false. Supplied Node identity does not prove launcher selection, effective npm config, past installer identity or tool authenticity. No package-registry request, installation, config write or process control occurs. The separate production Windows check/plan predicate is described in [Windows read-only planning](codex-cli-update.md#windows-read-only-plan-predicate); unsupported layouts retain deferred inspection. A reported refusal can exit 0; consumers inspect `status`. The local account CLI and pool credential resolver share the [Orca source-owned import contract](codex-home.md#orca-source-owned-account-import): importing does not perform OAuth, and runtime credential resolution rereads the owned source. @@ -308,17 +308,17 @@ The probe uses a child-specific PATH and startup bypass; read-only config diagno Overlay diagnosis keeps `runnable` (owned executable wrapper/current binding and usable native launcher) separate from `active` (the selected PATH command is this wrapper); `healthy` requires both. Failed PATH inspection is unverified, not active. Explicit `codex-shim install` (`src/cli/dispatch.ts`) refuses with nonzero exit; a runnable overlay succeeds even while inactive, prints activation guidance, and receives no green readiness claim until active and free of routing/proxy warnings. Healthy existing Windows shims still succeed. -Codex CLI update inspection is split from mutation. `system codex-cli-update check` makes no +Codex CLI update inspection is split from the [explicit POSIX apply contract](codex-cli-update.md#explicit-posix-apply). `system codex-cli-update check` makes no package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; POSIX selection is attested when the runtime resolver selects the same canonical environment candidate without executing it. A linked npm-global manifest can then establish managed ownership; persisted candidates stay unattested. A manifest-owned POSIX npm-global install can also report an opt-in bounded content digest over the manifest, declared entrypoint, and the platform backing files at the layout's modeled locations; it is an observation claim, not execution proof. -On Windows this first slice performs no candidate/configuration filesystem I/O: it preserves only proof-captured -absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. That -fail-closed result records which observation was missing: a run with no proof-captured environment candidate reports -`windows_inspection_deferred`, because persisted selection is never consulted there and the command cannot claim that -no Codex CLI exists; a captured candidate whose path is not lexically eligible reports `candidate_path_unavailable`. -POSIX keeps `candidate_unavailable` for an unobserved candidate. +On Windows x64, a proof-captured absolute standard npm `codex.cmd` with prefix-local Node/npm and verified in-prefix +platform backing is inspected under reparse-refusing held handles; the opt-in content digest stays null here. +Other layouts keep the earlier fail-closed shape: only proof-captured absolute environment candidates receive +lexical app-bundle/version-manager reporting. A run with no captured candidate reports `windows_inspection_deferred` +(persisted selection is never consulted, so it cannot claim no Codex CLI exists); an ineligible captured path reports +`candidate_path_unavailable`. POSIX keeps `candidate_unavailable` for an unobserved candidate. This check does not admit a runtime or authorize an install. The command exposes no private mutation authority and does not query a registry, execute Codex/npm, install, repair, stop, restart, or change configuration/cache state. diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index f780d71aa90..0729ab6945d 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -65,7 +65,7 @@ describe("an edit to the setup action alone", () => { }); }); -// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need the testRunnerBun pin (1.4.3, #4956). +// Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 6d21c8726e6..b28dbab5c81 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -94,10 +94,10 @@ describe("install scripts", () => { // and spun past its own timeout. Scratch run 38213764818 stalled 8 of 252 Linux shard 1/4 // batch-5 runs that way (CLI child a zombie, its pidfd armed but never polled). // tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts keeps the runner above it. - // Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch - // proves the unified version; then delete testRunnerBun and the setup action's role. + // The security transition now defaults both roles to 1.4.4. Keep their independent + // validation; promotion still requires a green full cross-platform lane=all candidate run. expect(pkg.dependencies?.bun).toBe("1.4.4"); - expect(pkg.testRunnerBun).toBe("1.4.3"); + expect(pkg.testRunnerBun).toBe("1.4.4"); expect(pkg.dependencies?.zod).toBe("4.6.6"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..d0bb998b873 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -3,14 +3,39 @@ import { Agent, createServer, request } from "node:https"; import { connect, constants, type ClientHttp2Session, type ClientHttp2Stream } from "node:http2"; import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; +import { createServer as createTlsServer } from "node:tls"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; import { startPickerListener, type PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; -async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => void, +async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => void) | "early-fin", options: Partial = {}) { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); - const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); + const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean }> = []; + // A wire-level origin sends the reply and FIN while continuing to drain input. Bun's HTTP + // server also tears down unfinished requests; overriding destroySoon alone does not prevent + // that reset racing ahead of the reply on Windows with a buffered upload. + const upstream = handler === "early-fin" ? createTlsServer({ cert: leaf.certPem, key: leaf.keyPem, allowHalfOpen: true }, socket => { + socket.on("error", () => socket.destroy()); + let headers = ""; + let replied = false; + socket.on("data", chunk => { + if (replied) return; + headers += chunk.toString("latin1"); + const end = headers.indexOf("\r\n\r\n"); + if (end < 0) return; + replied = true; + const early = headers.startsWith("POST /early "); + const body = early ? "early" : "ordinary"; + const length = /\r\ncontent-length: (\d+)\r\n/i.exec(headers.slice(0, end + 2)); + const requestBody = headers.slice(end + 4); + const observation = { requestComplete: length ? requestBody.length >= Number(length[1]) + : requestBody.endsWith("0\r\n\r\n"), flushed: false }; + if (early) earlyReplies.push(observation); + socket.once("finish", () => { observation.flushed = true; }); + socket.end(`HTTP/1.1 ${early ? "413 Payload Too Large" : "200 OK"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`); + }); + }) : createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); const sockets = new Set(); for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); @@ -34,7 +59,7 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v rejectUnauthorized: true, agent: h1Agent, path, method, headers: { Host: "claude.ai", ...headers } }); clients.push(req); return req; }; - return { logs, h2, h1, relay, + return { logs, h2, h1, relay, earlyReplies, async close() { const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client)); await relay.close(); @@ -353,7 +378,7 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture((req, res) => { + const f = await fixture(input === "buffered" ? "early-fin" : (req, res) => { // Answer like a real origin that replies early: keep reading the body and close with a FIN. // Bun's own server would close this unfinished request with destroySoon(), a reset that // Windows delivers before the reply bytes, so the relay could never read what it should relay. @@ -373,6 +398,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); + if (input === "buffered") expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index c0c82b7bb5f..09a45813f67 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -81,14 +81,17 @@ describe("Codex CLI selection inspection boundary", () => { for (const key of Object.keys(env)) { if (["home", "node_path"].includes(key.toLowerCase())) delete env[key]; } + const startedAt = performance.now(); const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { cwd: repoRoot(), encoding: "utf8", timeout: 15_000, windowsHide: true, env: { ...env, Home: "C:\\" + "h".repeat(8 * 1024), Node_Path: "C:\\" + "n".repeat(8 * 1024), OPENCODEX_HOME: root, OPENCODEX_BUN_PATH: process.execPath, CODEX_CLI_PATH: "C:\\fixture\\codex.exe", OCX_NO_DESKTOP_HANDOFF: "1" }, }); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); + const diagnostic = JSON.stringify({ elapsedMs: performance.now() - startedAt, status: result.status, + signal: result.signal, error: result.error?.message, stdout: result.stdout?.slice(-2_048), stderr: result.stderr?.slice(-2_048) }); + expect(result.error, diagnostic).toBeUndefined(); + expect(result.status, diagnostic).toBe(0); const report = JSON.parse(result.stdout); expect(report.candidateSource).toBe("environment"); expect(report.reason).toBe("windows_inspection_deferred"); @@ -247,9 +250,19 @@ describe("Codex CLI selection inspection boundary", () => { // and node directories. Everything the spawned npm view needs is answered // by the script; nothing else on the PATH can shadow it. const integrity = "sha512-" + Buffer.alloc(64, 7).toString("base64"); + const backingVersion = "9.9.9-" + selected.pkg.slice("@openai/codex-".length); + const mainMetadata = JSON.stringify({ name: "@openai/codex", version: "9.9.9", + optionalDependencies: { [selected.pkg]: "npm:@openai/codex@" + backingVersion }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-9.9.9.tgz" } }); + const backingMetadata = JSON.stringify({ name: "@openai/codex", version: backingVersion, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-" + backingVersion + ".tgz" } }); writeFileSync(join(fakeBin, "npm"), [ "#!/bin/sh", 'case "$*" in', + ` *codex@${backingVersion}*--json*) printf '%s\\n' '${backingMetadata}'; exit 0 ;;`, + ` *--json*) printf '%s\\n' '${mainMetadata}'; exit 0 ;;`, + "esac", + 'case "$*" in', ' *dist.integrity*) printf "%s\n" ' + JSON.stringify(integrity) + " ;;", ' *dist.tarball*) printf "%s\n" "https://registry.npmjs.org/@openai/codex/-/codex-9.9.9.tgz" ;;', ' *) printf "%s\n" "9.9.9" ;;', @@ -469,9 +482,15 @@ const result = await handleCodexCliUpdateCommand(process.argv.slice(4), { requireEvidence(equal(captureCodexCliTransportEnv(options.env)), "npm transport mismatch"); requireEvidence(options.env.PATH === ${JSON.stringify(tools)}, "npm PATH mismatch"); queries++; - return { status: 0, stdout: args.includes("version") ? "1.1.0" - : args.includes("dist.integrity") ? "sha512-" + Buffer.alloc(64, 7).toString("base64") - : "https://registry.npmjs.org/@openai/codex/-/codex-1.1.0.tgz", stderr: "" }; + requireEvidence(args[0] === "view" && args.includes("--json"), "exact artifact metadata query missing"); + const requested = args[1].slice(args[1].lastIndexOf("@") + 1); + const version = requested === "latest" ? "1.1.0" : requested; + return { status: 0, stdout: JSON.stringify({ name: "@openai/codex", version, + optionalDependencies: version === "1.1.0" ? Object.fromEntries( + ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"] + .map(suffix => ["@openai/codex-" + suffix, "npm:@openai/codex@1.1.0-" + suffix])) : undefined, + dist: { integrity: "sha512-" + Buffer.alloc(64, 7).toString("base64"), + tarball: "https://registry.npmjs.org/@openai/codex/-/codex-" + version + ".tgz" } }), stderr: "" }; }, }), }); diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index df948dd06c6..6268f23f1d6 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../../src/cli/codex-cli-update"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, projectCodexCliUpdateJobLifecycle, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; +import * as updateJobs from "../../src/codex/cli-update-job"; +import { CodexCliJobStoreBusyError, CodexCliJobResultPersistenceError, normalizeCodexCliJobResult, + retryCodexCliJobStoreOperation, runCodexCliUpdateJobWork } from "../../src/codex/cli-update-job-execution"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; +import type { CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, @@ -39,7 +51,622 @@ const identityReport: CodexCliInstallationIdentityReport = { proof: "windows-handle-bound", toolchain: "observed-only", }; +function privateStoreParent(prefix: string): { outer: string; parent: string } { + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + try { + chmodSync(outer, 0o700); + const parentPath = join(outer, "private-parent"); + mkdirSync(parentPath, { mode: 0o700 }); + chmodSync(parentPath, 0o700); + const parent = realpathSync.native(parentPath); + const stat = lstatSync(parent); + const diagnostic = { + canonicalEqual: realpathSync.native(parent) === parent, + modeSafe: (stat.mode & 0o022) === 0, + isDirectory: stat.isDirectory(), + notSymlink: !stat.isSymbolicLink(), + ownerMatches: process.geteuid?.() !== undefined && stat.uid === process.geteuid(), + }; + if (!Object.values(diagnostic).every(Boolean)) { + throw new Error(`private job-store fixture parent failed safe checks: ${JSON.stringify(diagnostic)}`); + } + return { outer, parent }; + } catch (error) { + rmSync(outer, { recursive: true }); + throw error; + } +} + +function privateStoreRoot(prefix: string): { outer: string; parent: string; configDir: string } { + const fixture = privateStoreParent(prefix); + try { + const configPath = join(fixture.parent, "ocx"); + mkdirSync(configPath, { mode: 0o700 }); + chmodSync(configPath, 0o700); + const configDir = realpathSync.native(configPath); + const stat = lstatSync(configDir); + const diagnostic = { + canonicalEqual: realpathSync.native(configDir) === configDir, + modePrivate: (stat.mode & 0o077) === 0, + isDirectory: stat.isDirectory(), + notSymlink: !stat.isSymbolicLink(), + ownerMatches: process.geteuid?.() !== undefined && stat.uid === process.geteuid(), + }; + if (!Object.values(diagnostic).every(Boolean)) { + throw new Error(`private job-store config root failed safe checks: ${JSON.stringify(diagnostic)}`); + } + return { ...fixture, configDir }; + } catch (error) { + rmSync(fixture.outer, { recursive: true }); + throw error; + } +} + +function hasTrustedStickyTmp(): boolean { + if (process.platform === "win32" || process.geteuid?.() === undefined) return false; + try { + const canonical = realpathSync.native("/tmp"); + const stat = lstatSync(canonical); + return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === 0 && (stat.mode & 0o1000) !== 0; + } catch { return false; } +} + +describe("Codex CLI job persistence failures", () => { + const result: CodexCliApplyResult = { outcome: "applied", reason: "verified_target", planId: "a".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }; + const terminal = { jobId: randomUUID(), status: "applied", outcome: "applied", reason: "verified_target" } as CodexCliUpdateJobDto; + + test.each(["applied", "refused", "ambiguous", "not_applied", "queued", "running"] as const)("apply exit code reflects %s, including JSON and pending duplicate jobs", async status => { + const job = { ...terminal, status, outcome: status === "queued" || status === "running" ? null : status, + ownerState: status === "queued" || status === "running" ? "active" : "finished" } as CodexCliUpdateJobDto; + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob"); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockImplementation(async (_id, _generation, run) => { await run(); return job; }); + const log = spyOn(console, "log").mockImplementation(() => {}); + let runs = 0; + try { + const kinds = status === "queued" || status === "running" ? ["duplicate"] as const : ["accepted", "duplicate"] as const; + for (const kind of kinds) { + admission.mockReturnValue({ kind, job, generation: randomUUID() }); runs = 0; worker.mockClear(); + for (const json of [false, true]) { + log.mockClear(); + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", ...(json ? ["--json"] : [])], + { applyUpdate: async () => { runs++; return result; } })).toBe(status === "applied" ? 0 : 1); + expect(log).toHaveBeenCalled(); + if (json) expect(JSON.parse(String(log.mock.calls[0]![0]))).toMatchObject({ jobId: job.jobId, status, outcome: job.outcome }); + } + expect(runs).toBe(kind === "accepted" ? 2 : 0); + expect(worker).toHaveBeenCalledTimes(kind === "accepted" ? 2 : 0); + } + } finally { log.mockRestore(); admission.mockRestore(); worker.mockRestore(); } + }); + + test.each(["applied", "not_applied", "unknown-code"] as const)("CLI preserves the unpersisted %s result without rerunning work", async outcome => { + const unknown = outcome === "unknown-code"; + const actual = { ...result, outcome: outcome === "not_applied" ? outcome : "applied", + reason: unknown ? "private/unknown-reason" : outcome === "applied" ? "verified_target" : "original_install_unchanged", + leaseRetained: outcome !== "applied", cleanupErrors: unknown ? ["private/cleanup", "lease_release_exception"] + : outcome === "applied" ? ["staging_cleanup_failed"] : ["lease_release_exception"], + installedVersion: "private/installed", privateDetails: "private/internal-path" } as CodexCliApplyResult; + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockReturnValue({ + kind: "accepted", job: terminal, generation: randomUUID(), + }); + let runs = 0, finishes = 0, releases = 0; + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockImplementation(async (_id, _generation, run) => + runCodexCliUpdateJobWork(run, { begin: () => 1, read: () => null, release: () => { releases++; }, + finish: () => { finishes++; throw new Error("unsafe_store"); } })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const json of [false, true]) { + runs = finishes = releases = 0; worker.mockClear(); log.mockClear(); error.mockClear(); + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", ...(json ? ["--json"] : [])], + { applyUpdate: async () => { runs++; return actual; } })).toBe(1); + const safeOutcome = unknown ? "ambiguous" : outcome; + const safeReason = unknown ? "job_result_unknown" : actual.reason; + const cleanupErrors = unknown ? ["lease_release_exception"] : actual.cleanupErrors; + const output = log.mock.calls.map(call => String(call[0])).join("\n"); + if (json) expect(JSON.parse(output)).toEqual({ error: "job_result_persistence_failed", jobId: terminal.jobId, + outcome: safeOutcome, reason: safeReason, leaseRetained: actual.leaseRetained, cleanupErrors }); + else for (const line of [`job: ${terminal.jobId}`, "persistence: unverified (job_result_persistence_failed)", + `outcome: ${safeOutcome}`, `reason: ${safeReason}`, `lease-retained: ${actual.leaseRetained}`, + `cleanup-errors: ${cleanupErrors.join(", ")}`]) expect(output).toContain(line); + expect(output).not.toContain("private/"); + expect(error).not.toHaveBeenCalled(); expect(worker).toHaveBeenCalledTimes(1); + expect(runs).toBe(1); expect(finishes).toBe(1); expect(releases).toBe(1); + } + } finally { error.mockRestore(); log.mockRestore(); worker.mockRestore(); admission.mockRestore(); } + }); + + test("CLI leaves generic apply worker failures on the existing error path", async () => { + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockReturnValue({ + kind: "accepted", job: terminal, generation: randomUUID(), + }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockRejectedValue(new Error("job_store_busy")); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + let runs = 0; + try { + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", "--json"], + { applyUpdate: async () => { runs++; return result; } })).toBe(1); + expect(log).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith("Error: job_store_busy"); + expect(worker).toHaveBeenCalledTimes(1); expect(runs).toBe(0); + } finally { error.mockRestore(); log.mockRestore(); worker.mockRestore(); admission.mockRestore(); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["open", "partial-write"] as const)("owned lock initialization %s failure rolls back and permits later admission", failure => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-init-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); + const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; + let ownerFd: number | undefined; + const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { + if (String(args[0]) === owner && failure === "open") throw Object.assign(new Error("injected owner open"), { code: "ENOSPC" }); + const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; + }); + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === ownerFd && failure === "partial-write") { + nativeWrite(ownerFd!, "{"); throw Object.assign(new Error("injected partial owner write"), { code: "ENOSPC" }); + } + return nativeWrite(...args); + }); + try { + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-fault-01", planId: result.planId, confirmed: true, configDir })).toThrow(); + expect(existsSync(lock)).toBe(false); + open.mockRestore(); write.mockRestore(); + expect(admitCodexCliUpdateJob({ requestId: "initializer-fault-01", planId: result.planId, confirmed: true, configDir }).kind).toBe("accepted"); + expect(existsSync(lock)).toBe(false); + } finally { open.mockRestore(); write.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["directory", "owner-file"] as const)("initialization rollback refuses a replaced %s identity", replaced => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-replaced-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); + const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; + let ownerFd: number | undefined; + let originalLock: ReturnType | undefined, originalOwner: ReturnType | undefined; + const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { + const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; + }); + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === ownerFd) { + nativeWrite(ownerFd!, "{"); originalLock = lstatSync(lock); originalOwner = fs.fstatSync(ownerFd!); + if (replaced === "directory") { + fs.renameSync(lock, `${lock}-original`); mkdirSync(lock, { mode: 0o700 }); + // Keep the owner inode valid so only the changed directory identity refuses cleanup. + fs.renameSync(join(`${lock}-original`, "owner.json"), owner); + } else { fs.renameSync(owner, `${owner}-original`); } + nativeWrite(owner, "replacement", { mode: 0o600 }); + throw Object.assign(new Error("injected owner write after replacement"), { code: "ENOSPC" }); + } + return nativeWrite(...args); + }); + try { + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-01", planId: result.planId, confirmed: true, configDir })).toThrow(); + open.mockRestore(); write.mockRestore(); + const replacementLock = lstatSync(lock), replacementOwner = lstatSync(owner); + expect(replacementLock.mode & 0o777).toBe(0o700); + expect(replacementOwner.mode & 0o777).toBe(0o600); + expect(replacementOwner.uid).toBe(process.geteuid!()); expect(replacementOwner.nlink).toBe(1); + expect(replacementLock.dev).toBe(originalLock!.dev); expect(replacementOwner.dev).toBe(originalOwner!.dev); + if (replaced === "directory") { + expect(replacementLock.ino).not.toBe(originalLock!.ino); expect(replacementOwner.ino).toBe(originalOwner!.ino); + } else { + expect(replacementLock.ino).toBe(originalLock!.ino); expect(replacementOwner.ino).not.toBe(originalOwner!.ino); + } + expect(readFileSync(owner, "utf8")).toBe("replacement"); + expect(existsSync(replaced === "directory" ? `${lock}-original` : `${owner}-original`)).toBe(true); + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-02", planId: result.planId, confirmed: true, configDir })).toThrow("job_store_busy"); + } finally { open.mockRestore(); write.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test("a save failure retries the actual applied result without rerunning the engine", async () => { + const saved: CodexCliApplyResult[] = []; let runs = 0, released = 0; + const value = await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { + begin: () => 1, read: () => null, release: () => { released++; }, + finish: (_revision, value) => { saved.push(value); if (saved.length === 1) throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); return terminal; }, + }); + expect(value).toBe(terminal); expect(runs).toBe(1); expect(released).toBe(1); + expect(saved).toEqual([result, result]); expect(saved.every(value => value === result)).toBe(true); + }); + + test("persistent save failure carries the actual result and never invents a replacement", async () => { + const saved: CodexCliApplyResult[] = []; + let failure: unknown; + try { await runCodexCliUpdateJobWork(async () => result, { begin: () => 1, read: () => null, release: () => {}, + finish: (_revision, value) => { saved.push(value); throw new Error("unsafe_store"); } }); } + catch (error) { failure = error; } + expect(failure).toBeInstanceOf(CodexCliJobResultPersistenceError); + expect((failure as CodexCliJobResultPersistenceError).result).toBe(result); + expect(saved).toEqual([result]); + }); + + test("bounded lock contention yields asynchronously and does not reclaim the lock", async () => { + let attempts = 0; const waits: number[] = []; + expect(await retryCodexCliJobStoreOperation(() => { if (++attempts < 3) throw new CodexCliJobStoreBusyError(); return 7; }, + async ms => { waits.push(ms); })).toBe(7); + expect(attempts).toBe(3); expect(waits).toEqual([25, 25]); + attempts = 0; waits.length = 0; + await expect(retryCodexCliJobStoreOperation(() => { attempts++; throw new CodexCliJobStoreBusyError(); }, + async ms => { waits.push(ms); })).rejects.toThrow("job_store_busy"); + expect(attempts).toBe(8); expect(waits).toHaveLength(7); + }); + + test("unknown accepted result codes cannot poison a later store read", () => { + const safe = normalizeCodexCliJobResult({ ...result, reason: "private/unknown-reason", + cleanupErrors: ["private/cleanup"] as never }); + expect(safe).toMatchObject({ outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, cleanupErrors: [] }); + expect(JSON.stringify(safe)).not.toContain("private/"); + expect(normalizeCodexCliJobResult(result)).toEqual(result); + // The Windows refusal is a first-class job reason: it survives normalization + // instead of collapsing into job_result_unknown. + expect(normalizeCodexCliJobResult({ ...result, outcome: "refused", + reason: "windows_apply_disabled", leaseRetained: false })) + .toMatchObject({ outcome: "refused", reason: "windows_apply_disabled" }); + }); + + test("a missing generation returns null without running the engine", async () => { + let runs = 0, released = 0; + expect(await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { + begin: () => { throw new Error("job_generation_changed"); }, finish: () => terminal, + read: () => null, release: () => { released++; }, + })).toBeNull(); + expect(runs).toBe(0); expect(released).toBe(1); + }); + + test("missing status is structured JSON and a nonzero exit", async () => { + const messages: string[] = [], previous = console.log; + try { + console.log = value => { messages.push(String(value)); }; + expect(await handleCodexCliUpdateCommand(["status", randomUUID(), "--json"], { readJob: () => null })).toBe(1); + expect(JSON.parse(messages.join("\n"))).toEqual({ error: "job_unavailable" }); + } finally { console.log = previous; } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a contended real store preserves the engine result until the owned lock is released", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-retry-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"); + let timer: ReturnType | undefined, runs = 0; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "lock-retry-00001", planId: result.planId, confirmed: true, configDir }); + const job = await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => { + runs++; mkdirSync(lock, { mode: 0o700 }); + timer = setTimeout(() => fs.rmdirSync(lock), 50); + return result; + }, configDir); + expect(job).toMatchObject({ status: "applied", outcome: "applied", reason: "verified_target" }); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ outcome: "applied" }); + expect(runs).toBe(1); + } finally { clearTimeout(timer); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a rename followed by fsync failure retries the same completed revision", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-fsync-retry-"); + let sync: ReturnType | undefined; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "fsync-retry-0001", planId: result.planId, confirmed: true, configDir }); + const nativeSync = fs.fsyncSync; let calls = 0; + const job = await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => { + sync = spyOn(fs, "fsyncSync").mockImplementation(fd => { + if (++calls === 3) throw Object.assign(new Error("injected directory sync failure"), { code: "EIO" }); + return nativeSync(fd); + }); + return result; + }, configDir); + expect(job).toMatchObject({ status: "applied", reason: "verified_target" }); + expect(calls).toBeGreaterThan(2); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ outcome: "applied", reason: "verified_target" }); + expect(JSON.parse(readFileSync(join(configDir, "codex-cli-update-jobs", "jobs.json"), "utf8")).jobs[0].revision).toBe(2); + } finally { sync?.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an accepted unknown result is persisted as readable fixed-code uncertainty", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-unknown-result-"); + try { + const admitted = admitCodexCliUpdateJob({ requestId: "unknown-result-01", planId: result.planId, confirmed: true, configDir }); + await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + async () => ({ ...result, reason: "late_result", cleanupErrors: ["private/path"] as never }), configDir); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ status: "ambiguous", + reason: "job_result_unknown", leaseState: "unknown", cleanupErrors: [] }); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a duplicate worker cannot clear the active generation's completion ownership", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-worker-fence-"); + let release = () => {}, pending: Promise | undefined; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "worker-fence-001", planId: result.planId, confirmed: true, configDir }); + pending = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => new Promise(resolve => { release = () => resolve(result); }), configDir); + await Promise.resolve(); + expect(await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + async () => { throw new Error("a duplicate worker must not invoke the engine"); }, configDir)) + .toMatchObject({ status: "running", ownerState: "active" }); + release(); + expect(await pending).toMatchObject({ status: "applied", reason: "verified_target" }); + } finally { release(); await pending?.catch(() => undefined); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("64 retained terminal jobs refuse new admission while preserving the first request dedupe", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-full-store-"); + try { + let first = ""; + for (let index = 0; index < 64; index++) { + const admitted = admitCodexCliUpdateJob({ requestId: `retained-key-${index}`, planId: result.planId, confirmed: true, configDir }); + if (index === 0) first = admitted.job.jobId; + expect(await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => result, configDir)).toMatchObject({ outcome: "applied" }); + } + expect(() => admitCodexCliUpdateJob({ requestId: "retained-key-65", planId: result.planId, confirmed: true, configDir })).toThrow("store_full"); + expect(admitCodexCliUpdateJob({ requestId: "retained-key-0", planId: result.planId, confirmed: true, configDir })) + .toMatchObject({ kind: "duplicate", job: { jobId: first, outcome: "applied" } }); + } finally { rmSync(outer, { recursive: true }); } + }, 30_000); +}); + describe("Codex CLI update CLI", () => { + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed admission creates one missing config root without making reads write", () => { + const { outer, parent } = privateStoreParent("ocx-cli-update-missing-parent-"); + const configDir = join(parent, "ocx"); + try { + expect(readCodexCliUpdateJob("12345678-1234-1234-1234-123456789012", configDir)).toBeNull(); + expect(existsSync(configDir)).toBe(false); + const missingParent = join(parent, "not-created"); + expect(() => admitCodexCliUpdateJob({ requestId: "no-recursive-root-001", planId: "c".repeat(32), confirmed: true, + configDir: join(missingParent, "ocx") })).toThrow(); + expect(existsSync(missingParent)).toBe(false); + const accepted = admitCodexCliUpdateJob({ requestId: "missing-root-admission-01", planId: "a".repeat(32), confirmed: true, configDir }); + const stat = lstatSync(configDir); + expect({ + canonicalEqual: realpathSync.native(configDir) === configDir, + modePrivate: (stat.mode & 0o077) === 0, + isDirectory: stat.isDirectory(), + isSymlink: stat.isSymbolicLink(), + ownerMatches: process.geteuid?.() !== undefined && stat.uid === process.geteuid?.(), + accepted: accepted.kind === "accepted", + }).toEqual({ canonicalEqual: true, modePrivate: true, isDirectory: true, isSymlink: false, ownerMatches: true, accepted: true }); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed admission reuses an existing canonical config root", () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-existing-root-"); + try { + const before = lstatSync(configDir); + const accepted = admitCodexCliUpdateJob({ requestId: "existing-root-admission-01", planId: "b".repeat(32), confirmed: true, configDir }); + const after = lstatSync(configDir); + expect(accepted.kind).toBe("accepted"); + expect(after.dev).toBe(before.dev); + expect(after.ino).toBe(before.ino); + expect(realpathSync.native(configDir)).toBe(configDir); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(!hasTrustedStickyTmp())("missing config root admission allows a root-owned sticky temporary ancestor", () => { + const stickyTmp = realpathSync.native("/tmp"); + const directConfig = join(stickyTmp, `ocx-cli-update-direct-sticky-${randomUUID()}`); + expect(() => admitCodexCliUpdateJob({ requestId: "direct-sticky-parent-001", planId: "d".repeat(32), + confirmed: true, configDir: directConfig })).toThrow("unsafe_store"); + expect(existsSync(directConfig)).toBe(false); + const outer = realpathSync.native(mkdtempSync(join(stickyTmp, "ocx-cli-update-sticky-parent-"))); + try { + chmodSync(outer, 0o700); + const parent = join(outer, "private-parent"); + mkdirSync(parent, { mode: 0o700 }); + chmodSync(parent, 0o700); + const configDir = join(parent, "ocx"); + const accepted = admitCodexCliUpdateJob({ requestId: "sticky-parent-admission-001", planId: "d".repeat(32), confirmed: true, configDir }); + const stat = lstatSync(configDir); + expect(accepted.kind).toBe("accepted"); + expect((stat.mode & 0o077) === 0 && stat.isDirectory() && !stat.isSymbolicLink() + && process.geteuid?.() !== undefined && stat.uid === process.geteuid?.() + && realpathSync.native(configDir) === configDir).toBe(true); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("missing config root admission refuses a non-sticky shared ancestor before mkdir", () => { + const { outer, parent } = privateStoreParent("ocx-cli-update-shared-ancestor-"); + const configDir = join(parent, "ocx"); + try { + chmodSync(outer, 0o777); + expect(() => admitCodexCliUpdateJob({ requestId: "shared-ancestor-admission-01", planId: "e".repeat(32), + confirmed: true, configDir })).toThrow("unsafe_store"); + expect(existsSync(configDir)).toBe(false); + } finally { + chmodSync(outer, 0o700); + rmSync(outer, { recursive: true }); + } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("existing config root refuses unsafe ancestry before admission or completion writes", async () => { + const { outer, parent, configDir } = privateStoreRoot("ocx-cli-update-existing-shared-ancestor-"); + let release: (() => void) | undefined; + let pending: Promise | undefined; + try { + const accepted = admitCodexCliUpdateJob({ requestId: "existing-shared-admission-001", planId: "f".repeat(32), + confirmed: true, configDir }); + pending = runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, () => new Promise(resolve => { + release = () => resolve({ outcome: "applied", reason: "verified_target", planId: "f".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + await Promise.resolve(); // Worker admission now yields before invoking the engine. + if (!release) throw new Error("fixture job owner did not enter its awaited operation"); + const ledger = join(configDir, "codex-cli-update-jobs", "jobs.json"); + const before = readFileSync(ledger); + chmodSync(outer, 0o777); + expect(() => admitCodexCliUpdateJob({ requestId: "blocked-existing-admission-001", planId: "1".repeat(32), + confirmed: true, configDir })).toThrow("unsafe_store"); + expect(() => readCodexCliUpdateJob(accepted.job.jobId, configDir)).toThrow("unsafe_store"); + expect(() => finishCodexCliUpdateJob(accepted.job.jobId, accepted.generation, 1, { + outcome: "applied", reason: "verified_target", planId: "f".repeat(32), installedVersion: "1.0.0", + targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [], + }, configDir)).toThrow("unsafe_store"); + await expect(runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, + async () => { throw new Error("unsafe ancestry must reject before running"); }, configDir)).rejects.toThrow("unsafe_store"); + expect(readFileSync(ledger)).toEqual(before); + } finally { + chmodSync(outer, 0o700); + release?.(); + if (pending) await pending.catch(() => undefined); + rmSync(outer, { recursive: true }); + } + }); + + test("job lifecycle projection keeps lease and child uncertainty distinct from the outcome", () => { + const project = (input: Parameters[0]) => projectCodexCliUpdateJobLifecycle(input); + const base = { active: false, status: "ambiguous" as const, outcome: "ambiguous" as const, + reason: "job_result_unknown", leaseRetained: true, cleanupErrors: [] as readonly string[] }; + expect(project(base)).toMatchObject({ childState: "unknown", leaseState: "unknown", outcome: "ambiguous" }); + expect(project({ ...base, reason: "installer_or_readback_error" })).toMatchObject({ + childState: "unknown", leaseState: "retained", outcome: "ambiguous", + }); + expect(project({ ...base, status: "applied", outcome: "applied", reason: "verified_target", + cleanupErrors: ["lease_release_exception"] })).toMatchObject({ + childState: "terminal", leaseState: "unknown", status: "applied", outcome: "applied", + }); + expect(project({ ...base, status: "applied", outcome: "applied", reason: "verified_target", + cleanupErrors: ["lease_release_unverified"] })).toMatchObject({ leaseState: "unknown", outcome: "applied" }); + expect(project({ ...base, reason: "installation_readback_unverified" })).toMatchObject({ + leaseState: "unknown", + }); + expect(project({ ...base, reason: "version_process_state_unknown" })).toMatchObject({ + childState: "unknown", leaseState: "retained", + }); + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("apply retry keys deduplicate durable jobs and status exposes only the shared public DTO", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-job-test-"); + try { + const requestId = "retry-key-0000001"; + const planId = "a".repeat(32); + const accepted = admitCodexCliUpdateJob({ requestId, planId, confirmed: true, configDir }); + const replay = admitCodexCliUpdateJob({ requestId, planId, confirmed: true, configDir }); + expect(accepted.kind).toBe("accepted"); + expect(replay.kind).toBe("duplicate"); + expect(replay.job.jobId).toBe(accepted.job.jobId); + expect(() => admitCodexCliUpdateJob({ requestId, planId: "b".repeat(32), confirmed: true, configDir })).toThrow("idempotency_conflict"); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configDir; + const statusLogs: string[] = []; + const oldLog = console.log; + try { + console.log = (...values: unknown[]) => statusLogs.push(values.map(String).join(" ")); + expect(await handleCodexCliUpdateCommand(["status", accepted.job.jobId, "--json"])).toBe(0); + expect(JSON.parse(statusLogs[0]!)).toMatchObject({ jobId: accepted.job.jobId, ownerState: "active" }); + } finally { + console.log = oldLog; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + } + + let installerRuns = 0; + const terminal = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, async () => { + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "running", ownerState: "active" }); + installerRuns += 1; + return { outcome: "applied", reason: "verified_target", planId, installedVersion: "1.0.0", targetVersion: "1.1.0", + installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; + }, configDir); + expect(installerRuns).toBe(1); + expect(terminal).toMatchObject({ status: "applied", outcome: "applied", ownerState: "finished" }); + expect(JSON.stringify(terminal)).not.toMatch(/ownerPid|generation|payloadHash|requestKey|token|path/i); + const stale = finishCodexCliUpdateJob(accepted.job.jobId, accepted.generation, 1, + { outcome: "refused", reason: "late_result", planId, installedVersion: null, targetVersion: null, + installerExitCode: null, leaseRetained: false, verificationPid: null, cleanupErrors: [] }, configDir); + expect(stale?.status).toBe("applied"); + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)?.status).toBe("applied"); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a new owner reports a live owner's job unknown; the original generation can finish without stale overwrite", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-restart-test-"); + let release: () => void = () => {}; + let pending: Promise | undefined; + try { + const accepted = admitCodexCliUpdateJob({ requestId: "restart-key-00001", planId: "c".repeat(32), confirmed: true, configDir }); + pending = runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, () => new Promise(resolve => { + release = () => resolve({ outcome: "applied", reason: "verified_target", planId: "c".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + await Promise.resolve(); + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "running", ownerState: "active" }); + const modulePath = new URL("../../src/codex/cli-update-job.ts", import.meta.url).href; + const script = `import { readCodexCliUpdateJob } from ${JSON.stringify(modulePath)}; console.log(JSON.stringify(readCodexCliUpdateJob(${JSON.stringify(accepted.job.jobId)})));`; + const child = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 5_000, + env: { ...process.env, OPENCODEX_HOME: configDir } }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + const dto = JSON.parse(child.stdout.trim()); + expect(dto).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "unknown", lifetimeUncertain: true, + reason: "owner_state_unknown" }); + release(); + const completed = await pending; + expect(completed).toMatchObject({ status: "applied", ownerState: "finished" }); + expect(finishCodexCliUpdateJob(accepted.job.jobId, accepted.generation, 1, + { outcome: "refused", reason: "late_result", planId: "c".repeat(32), installedVersion: null, + targetVersion: null, installerExitCode: null, leaseRetained: false, verificationPid: null, + cleanupErrors: [] }, configDir)?.status).toBe("applied"); + } finally { + release(); + if (pending) await pending.catch(() => undefined); + rmSync(outer, { recursive: true }); + } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an exception without close evidence preserves child and lease uncertainty", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-unknown-child-test-"); + try { + const accepted = admitCodexCliUpdateJob({ requestId: "unknown-child-0001", planId: "f".repeat(32), confirmed: true, configDir }); + const result = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, + async () => { throw new Error("unobservable child exception"); }, configDir); + expect(result).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "finished", + childState: "unknown", leaseState: "unknown", lifetimeUncertain: true, reason: "job_result_unknown" }); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an engine exception while its fixture child is live reports unknown child state", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-live-child-test-"); + let child: ChildProcess | undefined; + try { + const accepted = admitCodexCliUpdateJob({ requestId: "live-child-exception-001", planId: "9".repeat(32), confirmed: true, configDir }); + const result = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, async () => { + child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", windowsHide: true }); + await new Promise((resolve, reject) => { + child!.once("spawn", resolve); + child!.once("error", reject); + child!.once("exit", () => reject(new Error("fixture child exited before the engine exception"))); + }); + if (!child?.pid) throw new Error("fixture child did not expose its process id"); + process.kill(child.pid, 0); + throw new Error("simulated engine error after fixture child started"); + }, configDir); + if (!child?.pid) throw new Error("fixture child process identity was lost"); + process.kill(child.pid, 0); + expect(result).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "finished", + childState: "unknown", leaseState: "unknown", lifetimeUncertain: true, reason: "job_result_unknown" }); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = new Promise(resolve => child!.once("exit", () => resolve())); + child.kill("SIGTERM"); + let timer: ReturnType | undefined; + await Promise.race([closed, new Promise(resolve => { timer = setTimeout(resolve, 2_000); })]); + if (timer) clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await closed; + } + } + rmSync(outer, { recursive: true }); + } + }); + + test("parses job status and stable apply request IDs", () => { + const planId = "d".repeat(32); + const jobId = "12345678-1234-1234-1234-123456789012"; + expect(parseCodexCliUpdateArgs(["apply", "--plan-id", planId, "--yes", "--request-id", "retry-key-123456"])).toMatchObject({ + action: "apply", planId, requestId: "retry-key-123456", + }); + expect(parseCodexCliUpdateArgs(["status", jobId])).toEqual({ action: "status", json: false, jobId }); + expect(() => parseCodexCliUpdateArgs(["apply", "--plan-id", planId, "--yes", "--request-id", "short"])).toThrow(); + }); + test("bare attest selects the proof-bound candidate and forwards the derived input", async () => { expect(parseCodexCliUpdateArgs(["attest"])).toEqual({ json: false, attest: "selected" }); expect(parseCodexCliUpdateArgs(["attest", "--json"])).toEqual({ json: true, attest: "selected" }); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index eea0c242764..e3bc522ac38 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -13,6 +13,7 @@ import { handleProviderRuntimeCommand } from "../../src/cli/provider-runtime"; import { providerQuotaLine } from "../../src/cli/account-extended"; import { formatAccountTable } from "../../src/cli/account"; import { handleConnectCommand } from "../../src/cli/connect"; +import { CAPABILITIES, capabilityInvocation } from "../../src/cli/capabilities"; import { codexRestartLines, handleSystemCommand } from "../../src/cli/system-command"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -638,6 +639,20 @@ describe("headless GUI parity CLI", () => { ["/api/v2", "ocx v2/agent"], ["/api/windows-tray", "ocx tray"], ]; + // These commands share local owners with the GUI routes; declaring HTTP routes + // on the capabilities would falsely claim the CLI sends management requests. + // Resolve real capability declarations and require their documented counterpart. + for (const [resource, verb, mutates] of [ + ["/api/codex/cli-update/plan", "plan", false], + ["/api/codex/cli-update/apply", "apply", true], + ["/api/codex/cli-update/jobs", "status", false], + ] as const) { + const capability = CAPABILITIES.find(cap => cap.command.join(" ") === `system codex-cli-update ${verb}`); + expect(capability, resource).toBeDefined(); + expect(capability!.mutates).toBe(mutates); + expect(capability!.details?.some(detail => detail.includes(resource)), resource).toBe(true); + coverage.push([resource, capabilityInvocation(capability!)]); + } const uncovered = [...endpoints].filter(endpoint => !coverage.some(([prefix]) => endpoint === prefix || endpoint.startsWith(prefix))); expect(uncovered).toEqual([]); }); diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 87dd958b75e..187ecef6b1c 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { listCodexDesktopShellPids, restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; import { classifyDesktopPicker, readCodexCatalogBinding, readCodexDesktopPickerState } from "../../src/codex/desktop-app/picker-freshness"; import type { DesktopAppAdapter, DesktopProcess } from "../../src/codex/desktop-app/types"; import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; @@ -624,3 +629,79 @@ describe("desktop picker freshness inputs (#4761)", () => { } }); }); + +describe("the cli-update selection gate covers the whole restart ladder", () => { + test("CLI commit window refuses the real desktop restart caller before quit, then releases it", () => { + const calls: Call[] = []; + const io = scriptedIo({ + discovery: DISCOVERY, + processes: "1000 900 2026-08-22T10:00:00.000Z", + calls, + }); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + try { + held = window.tryEnter(); + expect(held).toBe(true); + const denied = withTrustedExes(() => restartCodexDesktopApp(io)); + expect(denied).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "selection_mutation_busy", + }); + expect(existsSync(io.lock!.lockPath!)).toBe(false); + expect(calls.some(call => call.file === TASKKILL || + /CloseMainWindow|Start-Process|OpenCodexPackageActivation/.test(call.args.join(" ")))).toBe(false); + + window.exit(); + held = false; + calls.length = 0; + // Same caller/lock path, plus explicit absence checks: same-PID reentry alone + // would NOT prove lock release. + const allowed = withTrustedExes(() => restartCodexDesktopApp(io)); + expect(allowed).toEqual({ + attempted: true, stopped: [1000], surviving: [], relaunch: "started", + }); + expect(calls.filter(call => call.args.join(" ").includes("CloseMainWindow"))).toHaveLength(1); + expect(calls.some(call => call.args.join(" ").includes("Start-Process"))).toBe(true); + expect(calls.some(call => call.file === TASKKILL)).toBe(false); + + expect(existsSync(io.lock!.lockPath!)).toBe(false); + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + removeTreeWithRetry(dirname(io.lock!.lockPath!)); + } + }); + + test("a lock-acquisition throw still releases the process-wide selection gate", () => { + // Regression for the gate-then-lock ordering: the lock's own mkdir/write + // can throw, and before the fix that throw sat outside the try/finally and + // left the shared gate held for the rest of the process. A lock path whose + // parent is a regular file makes that acquisition throw deterministically. + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-restart-gate-")); + try { + const blocker = join(dir, "not-a-directory"); + writeFileSync(blocker, "fixture"); + const calls: Call[] = []; + const io = scriptedIo({ + discovery: DISCOVERY, + processes: "1000 900 2026-08-22T10:00:00.000Z", + calls, + }); + io.lock = { lockPath: join(blocker, "lock") }; + expect(() => withTrustedExes(() => restartCodexDesktopApp(io))).toThrow(); + // No discovery/quit/relaunch ran, and the gate is free again. + expect(calls).toHaveLength(0); + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + removeTreeWithRetry(dir); + } + }); +}); diff --git a/tests/clients/desktop-restart-handoff.test.ts b/tests/clients/desktop-restart-handoff.test.ts index f299bbba0a5..4191d071e4c 100644 --- a/tests/clients/desktop-restart-handoff.test.ts +++ b/tests/clients/desktop-restart-handoff.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; import { handoffLogPath, resolveHelperCommand, @@ -9,7 +10,12 @@ import { startDesktopRestartHandoff, type HandoffIo, } from "../../src/codex/desktop-app/handoff"; -import { acquireDesktopRestartLock } from "../../src/codex/desktop-app/lock"; +import { acquireDesktopRestartLock, releaseDesktopRestartLock } from "../../src/codex/desktop-app/lock"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; /** * The handoff exists because the self-ancestry refusal fires in the NORMAL case on a @@ -92,6 +98,104 @@ describe("starting a handoff", () => { expect(planArg).toBeDefined(); expect(existsSync(planArg as string)).toBe(false); }); + + test("worker commit gate refuses self-ancestry handoff before restart lock acquisition", () => { + const dir = home(); + const lockPath = join(dir, "lock"); + const spawned: { command?: string; args?: string[] } = {}; + const handoffIo = startIo(dir, spawned, 9001); + const calls = { handoff: 0, helper: 0, quit: 0, force: 0, relaunch: 0, exec: 0 }; + let lockPathReads = 0; + const lock = { + ...handoffIo.lock!, + get lockPath() { lockPathReads++; return lockPath; }, + }; + handoffIo.lock = lock; + const spawnHelper = handoffIo.spawnHelper!; + handoffIo.spawnHelper = (command, args) => { + calls.helper++; + return spawnHelper(command, args); + }; + const io: DesktopAppRestartIo = { + platform: "win32", + lock, + allowHandoff: true, + ancestryPids: () => [4242, 1000], + execFile: () => { calls.exec++; throw new Error("unexpected fixture exec"); }, + isAlive: () => false, + sleep: () => {}, + now: () => 1_000, + adapter: { + discover: () => ({ id: "fixture", root: dir, relaunch: "fixture" }), + listProcesses: () => [{ + pid: 1000, parentPid: 1, createdAt: "fixture-start", + executable: join(dir, "fixture-shell"), + }], + isShell: () => true, + ancestryPids: () => [4242, 1000], + captureRelaunchContext: () => ({}), + requestQuit: () => { calls.quit++; }, + forceStop: () => { calls.force++; }, + relaunch: () => { calls.relaunch++; }, + }, + startHandoff: () => { + calls.handoff++; + const result = startDesktopRestartHandoff(handoffIo); + return result.kind === "started" + ? { helperPid: result.helperPid, logPath: result.logPath } + : null; + }, + }; + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + try { + expect(existsSync(lockPath)).toBe(false); + held = window.tryEnter(); + expect(held).toBe(true); + // Calls the production restart ladder, not a wrapper that pre-checks the gate. + expect(restartCodexDesktopApp(io)).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "selection_mutation_busy", + }); + expect(calls).toEqual({ handoff: 0, helper: 0, quit: 0, force: 0, relaunch: 0, exec: 0 }); + // Final absence alone would miss create-then-unlink. The existing lock + // implementation reads io.lock.lockPath before any acquisition side effect. + expect(lockPathReads).toBe(0); + expect(existsSync(lockPath)).toBe(false); + expect(spawned.command).toBeUndefined(); + + window.exit(); + held = false; + expect(restartCodexDesktopApp(io)).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "handoff_started", + handoff: { helperPid: 9001, logPath: handoffLogPath({ homeDir: dir }) }, + }); + expect(calls).toEqual({ handoff: 1, helper: 1, quit: 0, force: 0, relaunch: 0, exec: 0 }); + expect(lockPathReads).toBeGreaterThan(0); + expect(spawned.command).toBe("/usr/local/bin/ocx"); + expect(spawned.args?.slice(0, 3)).toEqual(["internal", "desktop-restart-handoff", "--plan"]); + expect(JSON.parse(readFileSync(lockPath, "utf8")).ownerPid).toBe(9001); + // Handoff deliberately keeps the filesystem lock, but must release the + // process-wide selection gate in the restart function's finally. + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + // These are synthetic fixture owners; no helper process was created. + releaseDesktopRestartLock({ lockPath, pid: 9001 }); + releaseDesktopRestartLock({ lockPath, pid: 4242 }); + const planPath = spawned.args?.[3]; + if (planPath && existsSync(planPath)) unlinkSync(planPath); + const logPath = handoffLogPath({ homeDir: dir }); + if (existsSync(logPath)) unlinkSync(logPath); + rmdirSync(dir); + } + }); }); describe("running the handoff", () => { diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts index b9d259c2ebb..9525b1357ea 100644 --- a/tests/clients/remote-workspace-codex-runtime.test.ts +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -1,5 +1,10 @@ import { repoPath } from "../helpers/repo-root"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; import { resolve } from "node:path"; import { CodexRemoteWorkspaceRuntimeFactory, @@ -118,3 +123,105 @@ test("Codex Remote Workspace resumes the persisted App Server thread ID", async expect(handle.threadId).toBe("thread-persisted"); await handle.stop(); }); + +function launchGateStartOptions(emit: (type: RemoteWorkspaceSessionEvent["type"], text: string) => void = () => {}): Parameters[0] { + return { + sessionId: "launch-gate-session", + deviceId: "launch-gate-device", + deviceName: "Fixture", + rootId: "launch-gate-root", + rootLabel: "Fixture", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + coordinator: new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }), + emit, + }; +} + +// Uses the REAL factory and its existing explicit command seam. A broken gate +// reaches the spawn spy, which throws; it can never launch a real Codex app. +test("CLI commit window blocks Remote Workspace native spawn and release restores the caller", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{}] }) }, + }); + const options = launchGateStartOptions(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("unexpected native spawn while worker holds the gate"); + }); + try { + held = window.tryEnter(); + expect(held).toBe(true); + await expect(factory.start(options)).rejects.toThrow("codex_cli_selection_busy"); + expect(spawn).toHaveBeenCalledTimes(0); + } finally { + if (held) window.exit(); + spawn.mockRestore(); + } + // No native Codex fallback: command remains the repository's fake JSON-RPC server. + const handle = await factory.start(options); + try { + await handle.prompt("Complete this fixture turn"); + } finally { + await handle.stop(); + } + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } +}); + +// Unlike a gate-wrapper unit test, this reaches the existing prompt/interrupt/ +// close implementation. The emitted marker replaces a scheduling sleep. +test("an existing Remote Workspace turn can interrupt and close while the worker gate is held", async () => { + let ready!: () => void; + const sawHeldTurn = new Promise(resolveReady => { ready = resolveReady; }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + heldUntilInterrupt: true, + notifications: [{ + method: "item/completed", + params: { item: { id: "gate-ready", type: "agentMessage", text: "gate-ready" } }, + }], + }], + }), + }, + }); + const handle = await factory.start(launchGateStartOptions((type, text) => { + if (type === "assistant" && text === "gate-ready") ready(); + })); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + let stopped = false; + let turn: Promise | undefined; + try { + turn = handle.prompt("Hold until interrupted").then(() => "resolved", () => "rejected"); + await sawHeldTurn; + held = window.tryEnter(); + expect(held).toBe(true); + await handle.stop(); + stopped = true; + expect(await turn).toBe("rejected"); + // The stop path must NOT steal/release the worker's ownership. + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(false); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + if (!stopped) await handle.stop(); + if (turn) await turn; + } +}); diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index e60b9f9ecbf..69cd2be4808 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -26,6 +26,7 @@ import { resetCodexAppServerCatalogStateCache, restartCodexAppServers, scanCodexSessionProcesses, + scanCodexSessionProcessesAsync, STALE_CODEX_APP_SERVER_HINT, warnIfStaleCodexAppServersAfterStartupWrite, WINDOWS_CODEX_BASENAME_CANDIDATE_RE, @@ -842,6 +843,41 @@ describe("Codex session process matching (#2811)", () => { expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); } }); + + test("scanCodexSessionProcessesAsync prefers the async seam and keeps the fail-closed contract", async () => { + // The request-path scan must wait on an injected async source rather than a + // synchronous enumeration, so a stalled process-table read cannot park the loop. + const unavailable = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 12, commandLine: "codex" }], + listSnapshotsAsync: async () => { throw new Error("procfs unreadable"); }, + }); + expect(unavailable.kind).toBe("unavailable"); + const scan = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 99, commandLine: "codex" }], + listSnapshotsAsync: async () => [ + { pid: 11, commandLine: "vim notes.txt" }, + { pid: 12, commandLine: "codex" }, + { pid: 12, commandLine: "codex" }, + { pid: 13, commandLine: "codex exec --json" }, + ], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") { + // The async source answered (pid 99 was the sync-only decoy) and dedupe holds. + expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); + } + }); + + test("scanCodexSessionProcessesAsync falls back to the injected sync seam", async () => { + const scan = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 21, commandLine: "codex app-server" }], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") expect(scan.processes.map(process => process.pid)).toEqual([21]); + }); }); describe("CLI /api sync wiring for stale app-servers (#476)", () => { diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts new file mode 100644 index 00000000000..c396e17f0aa --- /dev/null +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -0,0 +1,839 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { createServer, connect } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { applyCodexCliUpdate, verifyInstalledCodexVersion, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, runCodexCliUpdateJob, type CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; +import { createCodexCliApplyFixture, waitForApplyAdmission, writeFixtureNpmWrapper } from "../helpers/codex-cli-apply-fixture"; +import { fixtureArtifactSet } from "../helpers/codex-cli-apply-artifact"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; +import { observeCodexCliFileSet, sameCodexCliFiles } from "../../src/codex/cli-update-files"; +import { + createCodexCliUpdateFence, cancelCodexCliUpdateFence, codexCliUpdateIsCancelled, + codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +const posix = process.platform !== "win32"; +function fixture(targetNativeSource?: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-private-")); roots.push(root); + return createCodexCliApplyFixture(root, process.platform, process.arch, targetNativeSource); +} +function depsFor(f: ReturnType, overrides: Partial = {}): CodexCliApplyDeps { + return { platform: process.platform, temporaryRoot: f.temporaryRoot, + inspectionDeps: { env: f.env, configDir: join(f.home, "ocx"), selectionAttested: true, selectedCommand: f.launcher }, + scanProcesses: () => ({ kind: "observed", processes: [] }), + resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: f.target.artifacts.main.integrity, artifacts: f.target.artifacts }), + fetchArtifact: async artifact => artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes, + ...overrides }; +} +async function approved(f: ReturnType, deps = depsFor(f)) { + const plan = await createCodexCliUpdatePlan(deps); + expect(plan.applicable, JSON.stringify(plan)).toBe(true); expect(plan.planId).toMatch(/^[0-9a-f]{32}$/); + return plan.planId!; +} +function cli(f: ReturnType, args: string[]) { + const result = spawnSync(Bun.which("node")!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", ...args, "--json"], { + cwd: repoRoot(), encoding: "utf8", env: f.env, timeout: 60_000, + }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(0); + return JSON.parse(result.stdout); +} +function fixtureInWorkspaceScratch() { + const scratch = join(repoRoot(), ".tmp"); + mkdirSync(scratch, { recursive: true }); + const stat = lstatSync(scratch); + if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync.native(scratch) !== scratch) { + throw new Error("workspace .tmp is not a private local directory"); + } + const root = mkdtempSync(join(scratch, "codex-cli-management-e2e-")); roots.push(root); + return createCodexCliApplyFixture(root, process.platform, process.arch); +} + +function managementTestEnv(f: ReturnType) { + const node = Bun.which("node"); + if (!node) throw new Error("fixture node executable unavailable"); + const home = f.home; + const configDir = join(home, "ocx"); + const env: NodeJS.ProcessEnv = { + PATH: [join(f.root, "bin"), join(f.prefix, "bin"), dirname(node), "/usr/bin", "/bin"].join(":"), + HOME: home, CODEX_HOME: join(home, ".codex"), TMPDIR: f.temporaryRoot, TEMP: f.temporaryRoot, TMP: f.temporaryRoot, + OPENCODEX_HOME: configDir, CODEX_CLI_PATH: f.launcher, OPENCODEX_BUN_PATH: process.execPath, + OCX_NO_DESKTOP_HANDOFF: "1", NODE_COMPILE_CACHE: join(f.root, "node-compile-cache"), + XDG_CONFIG_HOME: join(home, "xdg-config"), XDG_CACHE_HOME: join(home, "xdg-cache"), XDG_DATA_HOME: join(home, "xdg-data"), + }; + return { node, configDir, env }; +} + +async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("private listener port unavailable"); + const port = address.port; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + return port; +} + +function startFixtureManagementServer(f: ReturnType, port: number, gate: string) { + const { node, env } = managementTestEnv(f); + env.OCX_FIXTURE_NPM_GATE = gate; + const child = spawn(node, [repoPath("bin", "ocx.mjs"), "start", "--port", String(port)], { + cwd: f.root, env, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const append = (chunk: Buffer) => { output = (output + chunk.toString("utf8")).slice(-64 * 1024); }; + child.stdout?.on("data", append); + child.stderr?.on("data", append); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once("close", (code, signal) => resolve({ code, signal })); + }); + return { child, closed, output: () => output, env }; +} + +async function waitForManagementServer(base: string, child: ChildProcess, closed: Promise, output: () => string): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`fixture server exited early (${child.exitCode}); outputBytes=${Buffer.byteLength(output())}`); + try { + const response = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(1000) }); + if (response.ok) return; + } catch { /* the isolated listener is still starting */ } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`fixture server readiness timed out; outputBytes=${Buffer.byteLength(output())}`); +} + +function closeOrTimeout(closed: Promise, timeoutMs: number): Promise { + return new Promise(resolve => { + const timer = setTimeout(() => resolve(false), timeoutMs); + closed.then(() => { clearTimeout(timer); resolve(true); }); + }); +} + +async function stopFixtureManagementServer(server: ReturnType): Promise { + const pid = server.child.pid; + if (!pid) throw new Error("fixture server pid unavailable during owned cleanup"); + const groupAlive = () => { + try { process.kill(-pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } + }; + if (server.child.exitCode === null && server.child.signalCode === null) { + server.child.kill("SIGINT"); + await closeOrTimeout(server.closed, 10_000); + } + if (groupAlive()) { + try { process.kill(-pid, "SIGTERM"); } catch { /* owned process group already exited */ } + await closeOrTimeout(server.closed, 5_000); + } + if (groupAlive()) { + try { process.kill(-pid, "SIGKILL"); } catch { /* owned process group already exited */ } + await closeOrTimeout(server.closed, 2_000); + } +} + +async function issueFixtureGuiSession(base: string) { + const origin = new URL(base).origin; + const response = await fetch(`${base}/opencodex-session`, { headers: { origin } }); + if (!response.ok) throw new Error(`fixture GUI session refused (${response.status})`); + const html = await response.text(); + const read = (name: string) => new RegExp(`>((resolve, reject) => { + const socket = connect(Number(url.port), url.hostname); + let connected = false; + socket.once("error", error => { if (!connected) reject(error); }); + socket.once("connect", () => socket.write(Buffer.concat([Buffer.from(headers), payload]), error => { + if (error) { socket.destroy(); reject(error); return; } + connected = true; + socket.pause(); + resolve(socket); + })); + }); +} + +async function waitForJob(base: string, session: { token: string; csrf: string; origin: string }, jobId: string, + predicate: (value: Record) => boolean, timeoutMs = 30_000): Promise> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const response = await fetch(`${base}/api/codex/cli-update/jobs/${jobId}`, { headers: fixtureSessionHeaders(session) }); + if (response.ok) { + const value = await response.json() as Record; + if (predicate(value)) return value; + } + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error("fixture management job state timed out"); +} + +async function waitForDurableAdmission(configDir: string, requestId: string, timeoutMs = 10_000): Promise { + const file = join(configDir, "codex-cli-update-jobs", "jobs.json"); + const requestKey = createHash("sha256").update(requestId).digest("hex"); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(file)) { + const value = JSON.parse(readFileSync(file, "utf8")) as { jobs?: Array<{ id?: string; requestKey?: string }> }; + const job = value.jobs?.find(entry => entry.requestKey === requestKey); + if (job?.id) return job.id; + } + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("fixture durable job admission was not observed"); +} + +function gateFixtureNpmInstall(f: Pick, "root">) { + const path = join(f.root, "bin", "npm"); + const source = readFileSync(path, "utf8"); + const marker = "const installed = cp.spawnSync("; + if (source.split(marker).length !== 2) throw new Error("fixture npm wrapper shape changed"); + const viewMarker = 'if (args[0] === "view") { if (!metadata[args[1]]) process.exit(81); console.log(JSON.stringify(metadata[args[1]])); process.exit(0); }'; + if (source.split(viewMarker).length !== 2) throw new Error("fixture npm metadata wrapper shape changed"); + const metadataTrace = join(f.root, "metadata-view-trace.jsonl"); + const tracedView = [ + 'if (args[0] === "view") {', + ' const spec = args[1], value = metadata[spec];', + ' const stage = spec === "@openai/codex@latest" ? "latest" : value?.name === "@openai/codex" && value?.version?.includes("-") ? "backing" : "pinned";', + ` fs.appendFileSync(${JSON.stringify(metadataTrace)}, JSON.stringify({stage,spec,exitCode:value?0:81,signal:null,spawnError:null,stdoutJsonValid:!!value,reason:value?"metadata_returned":"fixture_metadata_missing",name:value?.name??null,version:value?.version??null,optionalDependencyKeys:value?.optionalDependencies?Object.keys(value.optionalDependencies):[],hasIntegrity:typeof value?.dist?.integrity==="string",hasTarball:typeof value?.dist?.tarball==="string"})+"\\n");`, + ' if (!value) process.exit(81); console.log(JSON.stringify(value)); process.exit(0);', + '}', + ].join("\n"); + const tracedSource = source.replace(viewMarker, tracedView); + const wait = [ + 'const fixtureGate = process.env.OCX_FIXTURE_NPM_GATE;', + 'if (fixtureGate) {', + ' const cell = new Int32Array(new SharedArrayBuffer(4));', + ' const deadline = Date.now() + 45000;', + ' while (!fs.existsSync(fixtureGate) && Date.now() < deadline) Atomics.wait(cell, 0, 0, 20);', + ' if (!fs.existsSync(fixtureGate)) process.exit(85);', + '}', + ].join("\n") + "\n"; + writeFileSync(path, tracedSource.replace(marker, wait + marker), { encoding: "utf8", mode: 0o755 }); + chmodSync(path, 0o755); +} + +async function postFixtureApply(base: string, session: { token: string; csrf: string; origin: string }, body: unknown) { + return fetch(`${base}/api/codex/cli-update/apply`, { method: "POST", headers: fixtureSessionHeaders(session, true), body: JSON.stringify(body) }); +} +test("HTTP fixture npm wrapper returns all metadata under an ESM ancestor", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-http-npm-module-"))); roots.push(root); + writeFileSync(join(root, "package.json"), JSON.stringify({ private: true, type: "module" })); + const fakeBin = join(root, "bin"); mkdirSync(fakeBin); + const node = Bun.which("node"); if (!node) throw new Error("fixture node unavailable"); + const f = fixtureArtifactSet("linux", "x64"); + const main = { ...f.mainManifest, dist: { integrity: f.artifacts.main.integrity, tarball: f.artifacts.main.tarball } }; + const backing = { name: f.artifacts.backing.name, version: f.artifacts.backing.version, + dist: { integrity: f.artifacts.backing.integrity, tarball: f.artifacts.backing.tarball } }; + const metadata = { "@openai/codex@latest": main, "@openai/codex@1.1.0": main, + ["@openai/codex@" + backing.version]: backing }; + const installTrace = join(root, "install-trace.jsonl"); + writeFixtureNpmWrapper(fakeBin, node, join(root, "unused-npm.cjs"), metadata, {}, installTrace); + gateFixtureNpmInstall({ root }); + const env: NodeJS.ProcessEnv = { ...process.env, NODE_COMPILE_CACHE: join(root, "node-cache") }; + for (const key of Object.keys(env)) if (["node_options", "node_path", "node_v8_coverage", "node_redirect_warnings"].includes(key.toLowerCase())) delete env[key]; + for (const [spec, expected] of Object.entries(metadata)) { + const result = spawnSync(node, [join(fakeBin, "npm"), "view", spec, "--json"], { + cwd: root, env, encoding: "utf8", timeout: 10_000, windowsHide: true, + }); + expect({ exitCode: result.status, spawnFailed: !!result.error, + requireUndefined: result.stderr?.includes("require is not defined") }).toEqual({ exitCode: 0, spawnFailed: false, requireUndefined: false }); + expect(JSON.parse(result.stdout)).toEqual(expected); + } + const views = readFileSync(join(root, "metadata-view-trace.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(views.map(value => value.stage)).toEqual(["latest", "pinned", "backing"]); + expect(views.every(value => value.reason === "metadata_returned")).toBe(true); + expect(existsSync(installTrace)).toBe(false); +}); +describe("default version verifier pre-child setup", () => { + test("a mkdir failure is terminal without creating a child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-version-setup-")); roots.push(root); + const home = join(root, "version-home"); + writeFileSync(home, "not a directory\n"); + expect(await verifyInstalledCodexVersion("", "1.1.0", root, {})).toEqual({ + terminal: true, ok: false, pid: null, + }); + expect(readFileSync(home, "utf8")).toBe("not a directory\n"); + }); + test("a synchronous spawn throw is terminal without creating a child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-version-setup-")); roots.push(root); + const command = "ocx-verifier\0must-not-spawn"; + expect(() => spawn(command, ["--version"], { cwd: root, env: {} })).toThrow(/null bytes/); + expect(await verifyInstalledCodexVersion(command, "1.1.0", root, {})).toEqual({ + terminal: true, ok: false, pid: null, + }); + expect(lstatSync(join(root, "version-home")).isDirectory()).toBe(true); + }); +}); + +describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { + test("a held lease and an unsupported prefix are distinct refusal inputs", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-policy-"))); roots.push(root); + chmodSync(root, 0o700); + const first = acquireCodexCliApplyLease(root); + expect(first.kind).toBe("acquired"); + if (first.kind !== "acquired") throw new Error("owned private prefix lease unavailable"); + try { expect(acquireCodexCliApplyLease(root)).toEqual({ kind: "busy" }); } + finally { expect(first.release()).toBe(true); } + chmodSync(root, 0o770); + expect(acquireCodexCliApplyLease(root)).toEqual({ kind: "prefix_unsupported" }); + expect(existsSync(join(root, ".ocx-codex-cli-apply.lock"))).toBe(false); + }); + + test("published Node launcher -> real resolver/provenance -> plan/apply -> verified new files", () => { + const f = fixture(); + const plan = cli(f, ["plan"]); + expect(plan.applicable).toBe(true); expect(plan.managed).toBe(true); + expect(plan.targetArtifacts.backing.name).toBe("@openai/codex"); + const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); + expect(result).toMatchObject({ status: "applied", outcome: "applied", childState: "terminal", + leaseState: "not_acquired_or_released", lifetimeUncertain: false }); + const after = cli(f, ["check"]); expect(after.managed).toBe(true); expect(after.packageVersion).toBe("1.1.0"); + const calls = readFileSync(f.trace, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("--offline"); expect(calls[0]).toContain("--ignore-scripts"); + expect(calls[0]![calls[0]!.indexOf("--prefix") + 1]).toBe(f.prefix); + expect(calls[0]!.filter(arg => arg.includes("@file:"))).toHaveLength(2); + expect(calls[0]!.join(" ")).not.toContain("@latest"); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000); + + test("published Node launcher -> isolated management plan, lost-response apply retry, and CLI status", async () => { + const f = fixtureInWorkspaceScratch(); + const gate = join(f.root, "release-npm-install"); + gateFixtureNpmInstall(f); + const port = await reserveLoopbackPort(); + const base = `http://127.0.0.1:${port}`; + let server = startFixtureManagementServer(f, port, gate); + let releaseGate = false; + let session: Awaited> | undefined; + let jobId: string | undefined; + let lostSocket: ReturnType | undefined; + let finishOldOwner: (() => void) | undefined; + let oldOwnerWork: Promise | undefined; + try { + await waitForManagementServer(base, server.child, server.closed, server.output); + session = await issueFixtureGuiSession(base); + const planResponse = await fetch(`${base}/api/codex/cli-update/plan`, { headers: fixtureSessionHeaders(session) }); + if (!planResponse.ok) throw new Error(`isolated production plan refused (${planResponse.status})`); + const plan = await planResponse.json() as { schemaVersion?: number; applicable: boolean; refusal?: string | null; + planId: string | null; provenance?: string; managed: boolean; installedVersion: string | null; + targetVersion: string | null; installContentDigest: string | null; + session?: { state?: string; matches?: number | null } }; + if (!plan.applicable || !plan.planId || !plan.managed || plan.installedVersion !== "1.0.0" + || plan.targetVersion !== "1.1.0" || !plan.installContentDigest) { + // Keep the diagnostic useful for separating missing launcher provenance from a + // fixture/target refusal, without printing paths, commands, environment or IDs. + const refusalCodes = ["windows_inspection_deferred", "windows_apply_disabled", "not_managed", + "installed_version_unverified", "install_content_unverified", "target_unresolved", "already_current", + "target_not_newer", "blocked_active_session", "blocked_process_state_unknown"]; + const provenanceKinds = ["npm-global", "app-bundle", "version-manager", "standalone-unverified", "unknown"]; + const sessionStates = ["none", "active", "unknown", "not-evaluated"]; + const diagnostic = { + schemaVersion: plan.schemaVersion === 1 ? 1 : null, applicable: plan.applicable === true, + refusal: typeof plan.refusal === "string" && refusalCodes.includes(plan.refusal) ? plan.refusal : null, + provenance: typeof plan.provenance === "string" && provenanceKinds.includes(plan.provenance) ? plan.provenance : null, + managed: plan.managed === true, planIdPresent: typeof plan.planId === "string", + installedVersionMatches: plan.installedVersion === "1.0.0", targetVersionMatches: plan.targetVersion === "1.1.0", + installContentDigestPresent: typeof plan.installContentDigest === "string", + sessionState: typeof plan.session?.state === "string" && sessionStates.includes(plan.session.state) ? plan.session.state : null, + sessionMatches: Number.isSafeInteger(plan.session?.matches) && (plan.session?.matches ?? -1) >= 0 ? plan.session!.matches : null, + metadataQueries: existsSync(join(f.root, "metadata-view-trace.jsonl")) + ? readFileSync(join(f.root, "metadata-view-trace.jsonl"), "utf8").split("\n").filter(Boolean) + .map(line => JSON.parse(line) as { stage: string; spec: string; exitCode: number; signal: null; + spawnError: null; stdoutJsonValid: boolean; reason: string; name: string | null; version: string | null; + optionalDependencyKeys: string[]; hasIntegrity: boolean; hasTarball: boolean }) + : [], + }; + throw new Error(`published launcher management plan was not applicable: ${JSON.stringify(diagnostic)}`); + } + + const requestId = "lost-response-management-e2e-01"; + const applyBody = { requestId, planId: plan.planId, confirmed: true }; + lostSocket = await sendLostApplyResponse(base, session, applyBody); + jobId = await waitForDurableAdmission(join(f.home, "ocx"), requestId); + lostSocket.destroy(); lostSocket = undefined; + const installTrace = join(f.root, "install-trace.jsonl"); + const installDeadline = Date.now() + 30_000; + while (Date.now() < installDeadline) { + const lines = existsSync(installTrace) ? readFileSync(installTrace, "utf8").split("\n").filter(Boolean) : []; + if (lines.length > 0) break; + await new Promise(resolve => setTimeout(resolve, 20)); + } + const beforeRetry = existsSync(installTrace) ? readFileSync(installTrace, "utf8").split("\n").filter(Boolean) : []; + if (beforeRetry.length !== 1) throw new Error("lost HTTP response did not reach one fixture installer admission"); + + const retry = await postFixtureApply(base, session, applyBody); + if (retry.status !== 202) throw new Error(`same-key management retry returned ${retry.status}`); + const retryBody = await retry.json() as { job?: { jobId?: string } }; + if (retryBody.job?.jobId !== jobId) throw new Error("same-key retry did not return the admitted job id"); + const conflict = await postFixtureApply(base, session, { ...applyBody, planId: "f".repeat(32) }); + if (conflict.status !== 409) throw new Error(`changed-payload key reuse returned ${conflict.status}`); + const running = await fetch(`${base}/api/codex/cli-update/jobs/${jobId}`, { headers: fixtureSessionHeaders(session) }); + const runningDto = await running.json() as Record; + if (!running.ok || runningDto.status !== "running" || runningDto.ownerState !== "active" + || runningDto.childState !== "active_or_not_observed" || runningDto.leaseState !== "unknown") { + throw new Error("active fixture job status lost its execution/lease uncertainty projection"); + } + const stillOneInstall = readFileSync(installTrace, "utf8").split("\n").filter(Boolean); + if (stillOneInstall.length !== 1) throw new Error("idempotent response replay scheduled another installer"); + + writeFileSync(gate, "release\n", { flag: "wx", mode: 0o600 }); + releaseGate = true; + const terminal = await waitForJob(base, session, jobId, + value => value.ownerState === "finished", 45_000); + if (terminal.outcome !== "applied" || terminal.status !== "applied" || terminal.leaseState !== "not_acquired_or_released") { + throw new Error("private fixture apply did not finish with verified installation and lease cleanup"); + } + const args = JSON.parse(readFileSync(installTrace, "utf8").trim()) as string[]; + if (args.length === 0 || !args.includes("--offline") || !args.includes("--ignore-scripts") + || args[args.indexOf("--prefix") + 1] !== f.prefix || args.filter(arg => arg.includes("@file:")).length !== 2) { + throw new Error("private npm invocation escaped the fixture prefix or install restrictions"); + } + + const { node, env } = managementTestEnv(f); + const cli = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "status", jobId, "--json"], { + cwd: f.root, encoding: "utf8", env, timeout: 30_000, + }); + if (cli.error || cli.status !== 0) throw new Error("published CLI status consumer failed"); + const cliDto = JSON.parse(cli.stdout.trim()) as Record; + if (JSON.stringify(cliDto) !== JSON.stringify(terminal)) throw new Error("CLI and management job DTOs diverged"); + const publicDto = JSON.stringify(terminal); + for (const privateValue of [f.root, f.home, f.prefix, session.token, session.csrf, gate]) { + if (publicDto.includes(privateValue)) throw new Error("job DTO exposed fixture-private state"); + } + for (const field of ["requestKey", "payloadHash", "generation", "ownerPid", "verificationPid", "environment", "token"]) { + if (Object.hasOwn(terminal, field)) throw new Error("job DTO included a private schema field"); + } + jobId = undefined; + + // Keep a separate old owner alive across the management server restart. The restarted + // server must project its persisted running record as unknown, then accept only that + // original generation's late completion. + const configDir = join(f.home, "ocx"); + const restartPlanId = "b".repeat(32); + const oldOwner = admitCodexCliUpdateJob({ requestId: "server-restart-owner-unknown-01", planId: restartPlanId, confirmed: true, configDir }); + oldOwnerWork = runCodexCliUpdateJob(oldOwner.job.jobId, oldOwner.generation, () => new Promise(resolve => { + finishOldOwner = () => resolve({ outcome: "applied", reason: "verified_target", planId: restartPlanId, + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + const oldOwnerStatus = await fetch(`${base}/api/codex/cli-update/jobs/${oldOwner.job.jobId}`, { headers: fixtureSessionHeaders(session) }); + const oldOwnerDto = await oldOwnerStatus.json() as Record; + if (!oldOwnerStatus.ok || oldOwnerDto.ownerState !== "unknown" || oldOwnerDto.status !== "ambiguous" + || oldOwnerDto.reason !== "owner_state_unknown") throw new Error("management API treated an unowned running record as stopped"); + + await stopFixtureManagementServer(server); + const restartedPort = await reserveLoopbackPort(); + const restartedBase = `http://127.0.0.1:${restartedPort}`; + server = startFixtureManagementServer(f, restartedPort, gate); + await waitForManagementServer(restartedBase, server.child, server.closed, server.output); + const restartedSession = await issueFixtureGuiSession(restartedBase); + const afterRestart = await fetch(`${restartedBase}/api/codex/cli-update/jobs/${oldOwner.job.jobId}`, + { headers: fixtureSessionHeaders(restartedSession) }); + const restartedDto = await afterRestart.json() as Record; + if (!afterRestart.ok || restartedDto.ownerState !== "unknown" || restartedDto.reason !== "owner_state_unknown") { + throw new Error("restarted management process inferred an old owner's termination"); + } + finishOldOwner?.(); + const lateResult = await oldOwnerWork; + const staleWrite = finishCodexCliUpdateJob(oldOwner.job.jobId, oldOwner.generation, 1, + { outcome: "refused", reason: "late_result", planId: restartPlanId, installedVersion: null, + targetVersion: null, installerExitCode: null, leaseRetained: false, verificationPid: null, + cleanupErrors: [] }, configDir); + if (lateResult.outcome !== "applied" || staleWrite?.outcome !== "applied") { + throw new Error("late old-generation state overwrote the owner's completed result"); + } + const afterLate = await fetch(`${restartedBase}/api/codex/cli-update/jobs/${oldOwner.job.jobId}`, + { headers: fixtureSessionHeaders(restartedSession) }); + const finalDto = await afterLate.json() as Record; + if (!afterLate.ok || finalDto.status !== "applied" || finalDto.ownerState !== "finished") { + throw new Error("restarted management process did not observe the fenced owner completion"); + } + } finally { + finishOldOwner?.(); + if (oldOwnerWork) await oldOwnerWork.catch(() => undefined); + lostSocket?.destroy(); + if (!releaseGate) { + try { writeFileSync(gate, "release\n", { flag: "wx", mode: 0o600 }); } catch { /* gate was not installed */ } + } + if (session && jobId) { + try { await waitForJob(base, session, jobId, value => value.ownerState === "finished", 45_000); } + catch { /* preserve the primary assertion; the owned server is still drained below */ } + } + await stopFixtureManagementServer(server); + } + }, 120_000); + + test("published CLI packs and applies through a symlinked TMPDIR using canonical staging", () => { + const f = fixture(); const alias = join(f.root, "tmp-alias"); + symlinkSync(f.temporaryRoot, alias, "dir"); + Object.assign(f.env, { TMPDIR: alias, NODE_COMPILE_CACHE: join(f.root, "node-compile-cache") }); + const plan = cli(f, ["plan"]); expect(plan.applicable).toBe(true); + const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); + expect(result).toMatchObject({ status: "applied", outcome: "applied", childState: "terminal", + leaseState: "not_acquired_or_released", lifetimeUncertain: false }); + const after = cli(f, ["check"]); + expect(after.managed).toBe(true); expect(after.packageVersion).toBe("1.1.0"); + const calls = readFileSync(f.trace, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); + expect(calls).toHaveLength(1); + const archives = calls[0]!.filter(arg => arg.includes("@file:")).map(arg => arg.split("@file:")[1]!); + expect(archives).toHaveLength(2); + for (const archive of archives) { expect(archive.startsWith(f.temporaryRoot + "/")).toBe(true); expect(archive.startsWith(alias + "/")).toBe(false); } + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + + test.each(["stale-content", "same-version-integrity", "active", "unknown", "archive-tamper", "missing-backing"] as const)( + "%s refuses before installer admission", async scenario => { + const f = fixture(); let installs = 0; + const deps = depsFor(f, { install: async () => { installs++; return { terminal: true, exitCode: 0 }; } }); + const id = await approved(f, deps); + if (scenario === "stale-content") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), "#!/usr/bin/env node\n// changed bytes\n"); + const altered = { ...deps } as { -readonly [K in keyof CodexCliApplyDeps]: CodexCliApplyDeps[K] }; + if (scenario === "same-version-integrity") altered.resolveTarget = () => ({ kind: "resolved", version: "1.1.0", integrity: f.target.artifacts.main.integrity, + artifacts: { ...f.target.artifacts, backing: { ...f.target.artifacts.backing, integrity: "sha512-" + Buffer.alloc(64, 8).toString("base64") } } }); + if (scenario === "active") altered.scanProcesses = () => ({ kind: "observed", processes: [{ pid: 1, command: "codex" }] } as never); + if (scenario === "unknown") altered.scanProcesses = () => ({ kind: "unavailable" } as never); + if (scenario === "archive-tamper") altered.fetchArtifact = async () => Buffer.from("invalid archive"); + if (scenario === "missing-backing") altered.fetchArtifact = async artifact => artifact.version === "1.1.0" ? f.target.main.bytes : f.target.main.bytes; + const result = await applyCodexCliUpdate(id, altered); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000, + ); + test("a plan from a different prefix refuses without installing", async () => { + const first = fixture(), second = fixture(); const id = await approved(first); + let installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(second, { install: async () => { installs++; return { terminal: true, exitCode: 0 }; } })); + expect(result.reason).toBe("plan_changed"); expect(installs).toBe(0); + }, 120_000); + test("content and selection are renewed after slow artifact preparation", async () => { + const f = fixture(); let installs = 0; + const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { + fetchArtifact: async artifact => { + if (artifact.version !== "1.1.0") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), "#!/usr/bin/env node\n// changed during prepare\n"); + return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; + }, install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + }, 120_000); + test.each(["selection", "active", "unknown"] as const)("late %s change refuses after artifact preparation", async kind => { + const f = fixture(); const id = await approved(f); let prepared = false, installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(f, { + fetchArtifact: async artifact => { + if (artifact.version !== "1.1.0") { + prepared = true; + if (kind === "selection") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } + } + return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; + }, + scanProcesses: () => prepared && kind !== "selection" + ? kind === "active" ? { kind: "observed", processes: [{ pid: 1, command: "codex" }] } as never : { kind: "unavailable" } as never + : { kind: "observed", processes: [] }, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000); + test("artifact fetch keeps the actual AbortSignal identity and event-listener contract", async () => { + const f = fixture(), id = await approved(f), controller = new AbortController(); + const fence = createCodexCliUpdateFence(); + const signals: AbortSignal[] = [], listeners: Array<() => void> = []; + let abortEvents = 0, installs = 0; + try { + const result = await applyCodexCliUpdate(id, depsFor(f, { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), + fetchArtifact: async (artifact, signal) => { + expect(signal).toBe(controller.signal); + expect(signal).toBeInstanceOf(AbortSignal); + const listener = () => { abortEvents++; }; + signal!.addEventListener("abort", listener, { once: true }); + signals.push(signal!); listeners.push(listener); + return artifact.installName === f.target.artifacts.main.installName ? f.target.main.bytes : f.target.backing.bytes; + }, + install: () => { installs++; return Promise.resolve({ terminal: true, exitCode: 0 }); }, + })); + expect(result).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false }); + expect(signals).toHaveLength(2); expect(installs).toBe(1); expect(abortEvents).toBe(0); + controller.abort(); expect(abortEvents).toBe(2); + expect(codexCliUpdateIsCancelled(fence)()).toBe(false); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + } finally { + for (let i = 0; i < signals.length; i++) signals[i]!.removeEventListener("abort", listeners[i]!); + } + }, 120_000); + test.each(["preimage-drift", "selection-change", "signal-abort", "sab-cancel"] as const)( + "a %s while the process scan is pending refuses before install", async lane => { + const f = fixture(); const id = await approved(f); let installs = 0; + const controller = new AbortController(), fence = createCodexCliUpdateFence(); + const commitWindow = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + let scans = 0, release!: () => void, entered!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const scanEntered = new Promise(resolve => { entered = resolve; }); + // Two plan scans precede the final scan. Only that final awaited scan is held. + const pending = applyCodexCliUpdate(id, depsFor(f, { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), commitWindow, + scanProcessesAsync: async () => { + if (++scans === 3) { entered(); await held; } + return { kind: "observed", processes: [] }; + }, + install: () => { installs++; return Promise.resolve({ terminal: true, exitCode: 0 }); }, + })); + try { + await waitForApplyAdmission(scanEntered, pending); expect(scans).toBe(3); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + if (lane === "preimage-drift") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), + "#!/usr/bin/env node\n// changed while the final scan was pending\n"); + if (lane === "selection-change") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } + if (lane === "signal-abort") controller.abort(); + if (lane === "sab-cancel") { + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(controller.signal.aborted).toBe(false); + } + release(); const result = await pending; + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + if (lane === "sab-cancel" || lane === "signal-abort") expect(result.reason).toBe("cancelled"); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + const next = beginCodexCliSelectionParentWrite(); + try { expect(next.entered).toBe(true); } finally { next.exit(); } + } finally { release(); await pending.catch(() => {}); } + }, 120_000); + test("actual apply commits while install is called synchronously, then frees gate before awaiting it", async () => { + const f = fixture(), id = await approved(f), fence = createCodexCliUpdateFence(); + const commitWindow = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + let entered!: () => void, finish!: () => void, installs = 0, settled = false; + const started = new Promise(resolve => { entered = resolve; }); + const terminal = new Promise<{ terminal: boolean; exitCode: number }>(resolve => { + finish = () => resolve({ terminal: true, exitCode: 0 }); + }); + const pending = applyCodexCliUpdate(id, depsFor(f, { + isCancelled: codexCliUpdateIsCancelled(fence), commitWindow, + // Deliberately NOT async: this is the documented same-turn child-start seam. + // It starts only this fake's admission marker, never the production installer. + install: () => { + installs++; + expect(cancelCodexCliUpdateFence(fence)).toBe(false); // commit won before invocation + expect(codexCliUpdateIsCancelled(fence)()).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + entered(); return terminal; + }, + })).then(result => { settled = true; return result; }); + try { + await waitForApplyAdmission(started, pending); + expect(installs).toBe(1); expect(settled).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(true); } finally { writer.exit(); } + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + } finally { finish(); await pending.catch(() => {}); } + expect(await pending).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false }); + expect(installs).toBe(1); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + test("exit zero with original full files is not_applied", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); + expect(result.outcome, JSON.stringify(result)).toBe("not_applied"); expect(result.installerExitCode).toBe(0); + }, 120_000); + test("a newly installed sibling backing with unchanged nested-old main is ambiguous", async () => { + const f = fixture(); + const mainRoot = join(f.prefix, "lib", "node_modules", "@openai", "codex"); + const sibling = join(f.prefix, "lib", "node_modules", ...f.old.artifacts.backing.installName.split("/")); + const nestedScope = join(mainRoot, "node_modules", "@openai"); mkdirSync(nestedScope, { recursive: true }); + renameSync(sibling, join(nestedScope, f.old.artifacts.backing.installName.split("/")[1]!)); + const before = readFileSync(join(mainRoot, "package.json"), "utf8"); + const originalFiles = observeCodexCliFileSet([mainRoot]); + expect(existsSync(sibling)).toBe(false); + const id = await approved(f); let installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + installs++; + f.privateNpm(invocation.args.filter(arg => !arg.startsWith(f.target.artifacts.main.installName + "@file:"))); + return { terminal: true, exitCode: 17 }; + } })); + expect(installs).toBe(1); expect(result.outcome).toBe("ambiguous"); + expect(result.reason).toBe("installation_readback_unverified"); expect(result.leaseRetained).toBe(false); + expect(result.installedVersion).toBe("1.0.0"); + expect(sameCodexCliFiles(originalFiles, observeCodexCliFileSet([mainRoot]))).toBe(true); + expect(readFileSync(join(mainRoot, "package.json"), "utf8")).toBe(before); + expect(JSON.parse(readFileSync(join(sibling, "package.json"), "utf8")).version).toBe(f.target.artifacts.backing.version); + }, 120_000); + test.each(["complete-nonzero", "missing-auxiliary", "changed-auxiliary", "main-only", "native-nonexecutable"] as const)( + "%s is classified by complete readback, not installer exit", async scenario => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + const args = scenario === "main-only" ? invocation.args.filter(arg => !arg.startsWith(f.target.artifacts.backing.installName + "@file:")) : [...invocation.args]; + f.privateNpm([...args]); + const auxiliary = join(f.prefix, "lib", "node_modules", ...f.target.artifacts.backing.installName.split("/"), "vendor/auxiliary.txt"); + if (scenario === "missing-auxiliary") rmSync(auxiliary); + if (scenario === "changed-auxiliary") writeFileSync(auxiliary, "synthetic auxiliary CHANGED\n"); + if (scenario === "native-nonexecutable") chmodSync(join(f.prefix, "lib", "node_modules", ...f.target.artifacts.backing.installName.split("/"), + "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"), 0o644); + return { terminal: true, exitCode: scenario === "complete-nonzero" ? 17 : 0 }; + } })); + expect(result.outcome, JSON.stringify(result)).toBe(scenario === "complete-nonzero" ? "applied" : "ambiguous"); + expect(result.leaseRetained).toBe(false); + }, 120_000, + ); + test.skipIf(process.geteuid?.() === 0)("mode 0641 is not executable by the nonroot file owner", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); + chmodSync(join(f.prefix, "lib", "node_modules", ...f.target.artifacts.backing.installName.split("/"), + "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"), 0o641); + return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome).toBe("ambiguous"); + }, 120_000); + test.each(["#!/bin/sh\nexit 7\n", "#!/bin/sh\nprintf 'codex-cli 9.9.9\\n'\n"])("verified bytes with unsuccessful/wrong version execution are ambiguous", async source => { + const f = fixture(source); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome, JSON.stringify(result)).toBe("ambiguous"); expect(result.reason, JSON.stringify(result)).toBe("version_readback_unverified"); + expect(result.leaseRetained).toBe(false); expect(result.verificationPid).toBeNull(); + }, 120_000); + test("uncertain owned version child retains lease/staging and exposes its PID", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); return { terminal: true, exitCode: 0 }; + }, verifyVersion: async () => ({ terminal: false, ok: false, pid: 12345 }) })); + expect(result.outcome).toBe("ambiguous"); expect(result.reason).toBe("version_process_state_unknown"); + expect(result.leaseRetained).toBe(true); expect(result.verificationPid).toBe(12345); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + // Only an injected lifetime observation: there is no actual PID 12345 owned here. + }, 120_000); + test("empty and damaged apply locks remain busy without reclaim", async () => { + const f = fixture(); const id = await approved(f); let installs = 0; + const path = join(f.prefix, ".ocx-codex-cli-apply.lock"); mkdirSync(path, { mode: 0o700 }); + for (const owner of [null, "", "{broken"]) { + if (owner !== null) writeFileSync(join(path, "owner.json"), owner); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => { installs++; return { terminal: true, exitCode: 0 }; } })); + expect(result.reason).toBe("installation_busy"); expect(existsSync(path)).toBe(true); + } + expect(installs).toBe(0); + }, 120_000); + test("one prefix lease excludes a concurrent apply and remains held for an uncertain installer", async () => { + const f = fixture(); const id = await approved(f); + let resume!: () => void; let entered!: () => void; + const pending = new Promise(resolve => { resume = resolve; }); + const admitted = new Promise(resolve => { entered = resolve; }); + const first = applyCodexCliUpdate(id, depsFor(f, { fetchArtifact: async artifact => { + entered(); await pending; return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; + }, install: async () => ({ terminal: false, exitCode: null }) })); + try { + await waitForApplyAdmission(admitted, first); + const other = await applyCodexCliUpdate(id, depsFor(f)); + expect(other.reason, JSON.stringify(other)).toBe("installation_busy"); + } finally { resume(); await first.catch(() => {}); } + const result = await first; + expect(result.outcome).toBe("ambiguous"); expect(result.leaseRetained).toBe(true); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(acquireCodexCliApplyLease(f.prefix)).toEqual({ kind: "busy" }); + // The uncertain child is a test double; no real installer survives teardown. + }, 120_000); + test("original file mode damage is ambiguous, not not_applied", async () => { + const f = fixture(); const id = await approved(f); + const native = join(f.prefix, "lib", "node_modules", ...f.old.artifacts.backing.installName.split("/"), + "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => { + chmodSync(native, 0o644); return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome).toBe("ambiguous"); + }, 120_000); + test("cancellation during npm waits for terminal state, keeping lease and staging until readback", async () => { + const f = fixture(); const id = await approved(f); const controller = new AbortController(); + let close!: () => void, admitted!: () => void; + const started = new Promise(resolve => { admitted = resolve; }); + const terminal = new Promise(resolve => { close = resolve; }); + let done = false; + const running = applyCodexCliUpdate(id, depsFor(f, { signal: controller.signal, install: async () => { + admitted(); await terminal; return { terminal: true, exitCode: 0 }; + } })).then(result => { done = true; return result; }); + try { + await waitForApplyAdmission(started, running); controller.abort(); await Promise.resolve(); + expect(done).toBe(false); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + } finally { close(); await running.catch(() => {}); } + const result = await running; + expect(result.outcome, JSON.stringify(result)).toBe("not_applied"); expect(result.leaseRetained).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + test.each(["cancel", "error"] as const)("%s during preparation releases only its own lease", async kind => { + const f = fixture(); const id = await approved(f); const controller = new AbortController(); + let installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(f, { signal: controller.signal, + fetchArtifact: async () => { if (kind === "error") throw new Error("synthetic failure"); controller.abort(); return f.target.main.bytes; }, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + test("lease cleanup failure preserves the original outcome and still removes staging", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { + acquireLease: prefix => { + const owned = acquireCodexCliApplyLease(prefix); if (owned.kind !== "acquired") return owned; + return { kind: "acquired", release: () => { owned.release(); throw new Error("injected cleanup failure"); } }; + }, + install: async () => ({ terminal: true, exitCode: 0 }), + })); + expect(result.outcome, JSON.stringify(result)).toBe("not_applied"); + expect(result.cleanupErrors).toEqual(["lease_release_exception"]); + expect(result.leaseRetained).toBe(true); expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + test("a default verifier setup failure before any child releases the lease", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); + const staging = readdirSync(f.temporaryRoot).find(name => name.startsWith("ocx-codex-cli-apply-")); + expect(staging).toBeDefined(); + writeFileSync(join(f.temporaryRoot, staging!, "version-home"), "not a directory\n"); // mkdirSync fails EEXIST + return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome, JSON.stringify(result)).toBe("ambiguous"); + expect(result.reason).toBe("version_readback_unverified"); + expect(result.leaseRetained).toBe(false); expect(result.verificationPid).toBeNull(); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + const again = await applyCodexCliUpdate(id, depsFor(f)); + expect(again.reason, JSON.stringify(again)).not.toBe("installation_busy"); + }, 120_000); +}); diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts new file mode 100644 index 00000000000..3afc95cd9d6 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { codexCliArtifactSet, codexCliArtifactSetDigest, MAX_CODEX_ARCHIVE_BYTES, MAX_CODEX_MEMBER_BYTES, verifyCodexCliArchive, verifiedCodexCliContent } from "../../src/codex/cli-update-artifacts"; +import { codexCliUpdatePlanId } from "../../src/codex/cli-update-plan"; +import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/codex-cli-apply-artifact"; +import { applyCodexCliUpdate, cleanupCodexCliApply, verifyInstalledCodexVersion } from "../../src/codex/cli-update-apply"; +import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; +import { parseCodexCliUpdateArgs, handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; +import { chmodSync, closeSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { gzipSync, gunzipSync } from "node:zlib"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCodexCliApplyFixture, waitForApplyAdmission } from "../helpers/codex-cli-apply-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { observeCodexCliPreimage, sameCodexCliPreimage, sameCodexCliFiles, type CodexCliFileSet } from "../../src/codex/cli-update-files"; + +describe("Codex CLI apply artifacts", () => { + test("real npm installs main and platform aliases from local tarballs into a private prefix offline", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-alias-")); + try { + const f = createCodexCliApplyFixture(root, "linux", "x64"); + const modules = join(f.prefix, ...(process.platform === "win32" ? [] : ["lib"]), "node_modules"); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex", "package.json"), "utf8")).version).toBe("1.0.0"); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex-linux-x64", "package.json"), "utf8")).version).toBe("1.0.0-linux-x64"); + expect(Object.keys(f.target.mainManifest.optionalDependencies)).toHaveLength(6); + f.privateNpm(["install", "-g", "--prefix", f.prefix, "--package-lock=false", + `${f.target.artifacts.main.installName}@file:${f.target.main.path}`, + `${f.target.artifacts.backing.installName}@file:${f.target.backing.path}`]); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex", "package.json"), "utf8")).version).toBe("1.1.0"); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex-linux-x64", "package.json"), "utf8")).version).toBe("1.1.0-linux-x64"); + expect(readFileSync(join(modules, "@openai", "codex", "package.json"), "utf8")).toBe(JSON.stringify(f.target.mainManifest) + "\n"); + } finally { removeTreeWithRetry(root); } + }, 120_000); + test("verifies the exact main and aliased platform manifest/entrypoint/native bytes", async () => { + const f = fixtureArtifactSet(); + expect(verifiedCodexCliContent(f.artifacts, await verifyCodexCliArchive(f.main, f.artifacts.main), + await verifyCodexCliArchive(f.backing, f.artifacts.backing))).toMatch(/^[0-9a-f]{64}$/); + const metadata = (artifact: typeof f.artifacts.main) => ({ name: artifact.name, version: artifact.version, + dist: { integrity: artifact.integrity, tarball: artifact.tarball } }); + const set = codexCliArtifactSet({ ...f.mainManifest, ...metadata(f.artifacts.main) }, + spec => { expect(spec).toBe("@openai/codex@1.1.0-linux-x64"); return metadata(f.artifacts.backing); }, "linux", "x64"); + expect(set).toEqual(f.artifacts); + const observed = await verifyCodexCliArchive(f.backing, f.artifacts.backing); + expect([...observed].find(([name]) => name.endsWith("/bin/codex"))![1].bytes).toBeNull(); + }); + test("explicit inspection architecture selects the backing independently of the Bun host architecture", () => { + const f = fixtureArtifactSet("linux", "arm64"); + const set = codexCliArtifactSet({ ...f.mainManifest, dist: { integrity: f.artifacts.main.integrity, tarball: f.artifacts.main.tarball } }, + spec => { expect(spec).toBe("@openai/codex@1.1.0-linux-arm64"); return { name: "@openai/codex", version: f.artifacts.backing.version, + dist: { integrity: f.artifacts.backing.integrity, tarball: f.artifacts.backing.tarball } }; }, "linux", "arm64"); + expect(set.backing.installName).toBe("@openai/codex-linux-arm64"); + }); + test("readback catches executable-bit loss and preserves exact original modes", () => { + const target: CodexCliFileSet = new Map([["native", { size: 1, digest: "same", executable: true }]]); + const observed: CodexCliFileSet = new Map([["native", { size: 1, digest: "same", executable: false, mode: 0o644 }]]); + expect(sameCodexCliFiles(target, observed)).toBe(false); + const original: CodexCliFileSet = new Map([["data", { size: 1, digest: "same", executable: false, mode: 0o644 }]]); + expect(sameCodexCliFiles(original, new Map([["data", { size: 1, digest: "same", executable: false, mode: 0o600 }]]))).toBe(false); + }); + test("preimage tracks absent target roots in addition to the unchanged selected main/nested backing", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-preimage-"))); + try { + const main = join(root, "main"), nested = join(main, "node_modules", "backing"), target = join(root, "new-backing"); + mkdirSync(nested, { recursive: true }); writeFileSync(join(main, "package.json"), "old main\n"); + writeFileSync(join(nested, "native"), "old native\n"); + const roots = [main, nested, main, target]; + const before = observeCodexCliPreimage(roots); + expect(before.absentRoots).toEqual([target]); expect(before.presentRoots).toEqual([main, nested]); + expect(sameCodexCliPreimage(before, observeCodexCliPreimage(roots))).toBe(true); + mkdirSync(target); // Even an empty newly created root is a partial mutation. + expect(sameCodexCliPreimage(before, observeCodexCliPreimage(roots))).toBe(false); + writeFileSync(join(target, "native"), "new native\n"); + expect(sameCodexCliPreimage(before, observeCodexCliPreimage(roots))).toBe(false); + } finally { removeTreeWithRetry(root); } + }); + test("same-version backing integrity drift changes the approved plan identity", () => { + const f = fixtureArtifactSet(); + const bound = { platform: "linux" as const, provenance: "npm-global" as const, installedVersion: "1.0.0", + installDigest: "a", installContentDigest: "b", channel: "latest" as const, targetVersion: "1.1.0", targetIntegrity: f.artifacts.main.integrity }; + const digest = (set: typeof f.artifacts) => codexCliUpdatePlanId({ ...bound, targetArtifactsDigest: codexCliArtifactSetDigest(set) }); + expect(digest(f.artifacts)).not.toBe(digest({ ...f.artifacts, backing: { ...f.artifacts.backing, integrity: "changed" } })); + expect(codexCliArtifactSetDigest(f.artifacts)).toBe(codexCliArtifactSetDigest({ arch: f.artifacts.arch, platform: f.artifacts.platform, + backing: { ...f.artifacts.backing }, main: { tarball: f.artifacts.main.tarball, integrity: f.artifacts.main.integrity, + name: f.artifacts.main.name, installName: f.artifacts.main.installName, version: f.artifacts.main.version } })); + }); + test("archive byte tampering is refused", async () => { + const f = fixtureArtifactSet(); const changed = Buffer.from(f.main); changed[changed.length - 1] ^= 1; + await expect(verifyCodexCliArchive(changed, f.artifacts.main)).rejects.toThrow(); + }); + test.each(["valid", "corrupt"] as const)("real-file archive %s closes its stream before return and preserves a reused sentinel fd", async kind => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-archive-lifetime-"))); + const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"), sentinelPath = join(root, "sentinel"); + writeFileSync(path, kind === "valid" ? f.main : Buffer.from("not gzip")); writeFileSync(sentinelPath, "sentinel"); + const create = fs.createReadStream; + let captured: { fd: number; closed: boolean; heldAtEnd: boolean; close: Promise } | undefined; + const spy = spyOn(fs, "createReadStream").mockImplementation((...args) => { + const stream = create(...args); + const observed = { fd: Number((args[1] as { fd: number }).fd), closed: false, heldAtEnd: false, close: Promise.resolve() }; + observed.close = new Promise(resolve => stream.once("close", () => { observed.closed = true; resolve(); })); + stream.once("end", () => { try { observed.heldAtEnd = fs.fstatSync(observed.fd).isFile(); } catch { /* assertion below */ } }); + captured = observed; + return stream; + }); + let sentinel: number | undefined; + let sentinelIdentity: { dev: number; ino: number } | undefined; + try { + const verified = await verifyCodexCliArchive(path, f.artifacts.main).then(() => true, () => false); + const observed = captured!; + expect(observed).toBeDefined(); + const closedAtReturn = observed.closed; + // A stale asynchronous archive close used to target this immediately reused fd. + sentinel = openSync(sentinelPath, "r"); + sentinelIdentity = fs.fstatSync(sentinel); + await observed.close; + await new Promise(resolve => setImmediate(resolve)); + let sentinelAlive = false; + try { + const after = fs.fstatSync(sentinel), bytes = Buffer.alloc(8); + sentinelAlive = after.dev === sentinelIdentity.dev && after.ino === sentinelIdentity.ino + && fs.readSync(sentinel, bytes, 0, 8, 0) === 8 && bytes.toString() === "sentinel"; + } catch { /* assertion below */ } + expect({ kind, verified, closedAtReturn, sentinelAlive, sentinelReused: sentinel === observed.fd }) + .toEqual({ kind, verified: kind === "valid", closedAtReturn: true, sentinelAlive: true, sentinelReused: true }); + if (kind === "valid") expect(observed.heldAtEnd).toBe(true); + } finally { + spy.mockRestore(); + if (captured) await captured.close; + if (sentinel !== undefined) { + try { + const owned = fs.fstatSync(sentinel); + if (owned.dev === sentinelIdentity?.dev && owned.ino === sentinelIdentity?.ino) closeSync(sentinel); + } catch { /* old implementation may have closed the sentinel; no unknown fd is closed */ } + } + removeTreeWithRetry(root); + } + }); + test.skipIf(process.platform === "win32")("real-file archive closure precedes repeated lease acquire/release without touching a sentinel fd", async () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-archive-lease-"))); + const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"); writeFileSync(path, f.main); + const sentinel = openSync(join(root, "sentinel"), "wx+"); + try { + fs.writeSync(sentinel, "sentinel"); + for (let index = 0; index < 12; index++) { + await verifyCodexCliArchive(path, f.artifacts.main); + const lease = acquireCodexCliApplyLease(root); expect(lease.kind).toBe("acquired"); + if (lease.kind !== "acquired") throw new Error("fixture lease unavailable"); + expect(lease.release()).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(fs.fstatSync(sentinel).size).toBe(8); + } + } finally { closeSync(sentinel); removeTreeWithRetry(root); } + }); + test.skipIf(process.platform === "win32")("lease descriptor-close failure is structured and still permits staging cleanup", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-close-failure-"))); + const stage = join(root, "stage"); mkdirSync(stage); + const lease = acquireCodexCliApplyLease(root); expect(lease.kind).toBe("acquired"); + if (lease.kind !== "acquired") throw new Error("fixture lease unavailable"); + const close = fs.closeSync; + const spy = spyOn(fs, "closeSync").mockImplementation(fd => { + close(fd); throw Object.assign(new Error("injected post-close failure"), { code: "EBADF" }); + }); + try { + expect(cleanupCodexCliApply(lease, stage)).toEqual({ leaseRetained: true, cleanupErrors: ["lease_release_unverified"] }); + expect(fs.existsSync(stage)).toBe(false); + } finally { spy.mockRestore(); removeTreeWithRetry(root); } + }); + test("an oversized tar member refuses from its header before reading or allocating its body", async () => { + const tar = gunzipSync(fixtureTar({ "package.json": "{}" })); + tar.write((MAX_CODEX_MEMBER_BYTES + 1).toString(8).padStart(11, "0") + "\0", 124, 12); + tar.fill(32, 148, 156); + tar.write(tar.subarray(0, 512).reduce((sum, byte) => sum + byte, 0).toString(8).padStart(7, "0") + "\0", 148, 8); + const bytes = gzipSync(tar); + await expect(verifyCodexCliArchive(bytes, fixtureArtifact(bytes))).rejects.toThrow("artifact_tar_invalid"); + }); + test("an oversized compressed file refuses from fstat without reading its sparse body", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-archive-bound-")); + try { + const path = join(root, "oversize.tgz"), fd = openSync(path, "wx"); + try { ftruncateSync(fd, MAX_CODEX_ARCHIVE_BYTES + 1); } finally { closeSync(fd); } + await expect(verifyCodexCliArchive(path, fixtureArtifact(Buffer.alloc(0)))).rejects.toThrow("artifact_file_unverified"); + } finally { removeTreeWithRetry(root); } + }); + test.each(["../escape", "/absolute", "dir/../escape", "bad\\path"])("unsafe archive entry %s is refused", async name => { + const bytes = fixtureTar({ "package.json": JSON.stringify({ name: "@openai/codex", version: "1.1.0" }), [name]: "x" }); + await expect(verifyCodexCliArchive(bytes, fixtureArtifact(bytes))).rejects.toThrow(); + }); + test("a missing backing binary refuses a verified manifest-only archive", async () => { + const f = fixtureArtifactSet(); + const bytes = fixtureTar({ "package.json": f.backingFiles["package.json"] }); + const main = await verifyCodexCliArchive(f.main, f.artifacts.main); + const backing = await verifyCodexCliArchive(bytes, fixtureArtifact(bytes, f.artifacts.backing.version, f.artifacts.backing.installName)); + expect(() => verifiedCodexCliContent(f.artifacts, main, backing)).toThrow(); + }); + test("dependency-bearing archives cannot trigger an installer resolution", async () => { + const bytes = fixtureTar({ "package.json": JSON.stringify({ name: "@openai/codex", version: "1.1.0", dependencies: { unexpected: "latest" } }) }); + await expect(verifyCodexCliArchive(bytes, fixtureArtifact(bytes))).rejects.toThrow("artifact_dependency_unverified"); + }); +}); +describe("Codex CLI apply consumer", () => { + test("admission observes early outcomes and rejections without waiting for an unentered installer", async () => { + const never = new Promise(() => {}); + const result = { outcome: "refused" as const, reason: "installation_busy", planId: "a".repeat(32), installedVersion: null, + targetVersion: null, installerExitCode: 17, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; + await expect(waitForApplyAdmission(never, Promise.resolve(result))).rejects.toThrow("outcome=refused reason=installation_busy installerExitCode=17"); + await expect(waitForApplyAdmission(never, Promise.reject(new TypeError("fixture rejection")))).rejects.toThrow("apply rejected before admission: TypeError"); + }); + test("a lease cleanup exception is structured and cannot prevent staging cleanup", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-cleanup-"))); + const stage = join(root, "stage"); mkdirSync(stage); writeFileSync(join(stage, "artifact"), "owned fixture"); + try { + const cleanup = cleanupCodexCliApply({ release: () => { throw new Error("private native error"); } }, stage); + expect(cleanup).toEqual({ leaseRetained: true, cleanupErrors: ["lease_release_exception"] }); + expect(fs.existsSync(stage)).toBe(false); + expect(Object.isFrozen(cleanup.cleanupErrors)).toBe(true); + } finally { removeTreeWithRetry(root); } + }); + test("lease and staging cleanup failures are both retained without native error text", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-cleanup-"))); + const stage = join(root, "stage"); mkdirSync(stage); + const remove = fs.rmSync; + const spy = spyOn(fs, "rmSync").mockImplementation((path, options) => { + if (path === stage) throw new Error("private stage path"); return remove(path, options); + }); + try { + expect(cleanupCodexCliApply({ release: () => false }, stage)).toEqual({ + leaseRetained: true, cleanupErrors: ["lease_release_unverified", "staging_cleanup_failed"], + }); + expect(fs.existsSync(stage)).toBe(true); + } finally { spy.mockRestore(); removeTreeWithRetry(root); } + }); + test("the owned version probe waits for terminal exit and rejects unrelated version output", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-version-")); + try { + const result = await verifyInstalledCodexVersion(process.execPath, "9.9.9", root, process.env); + expect(result).toEqual({ terminal: true, ok: false, pid: null }); + } finally { removeTreeWithRetry(root); } + }); + test("requires explicit exact plan and confirmation, and accepts shared JSON positions", () => { + const planId = "a".repeat(32); + const requestId = "apply-parser-test-0001"; + expect(parseCodexCliUpdateArgs(["--json", "apply", `--plan-id=${planId}`, "--yes", "--request-id", requestId])) + .toEqual({ action: "apply", planId, requestId, json: true }); + for (const args of [["apply"], ["apply", "--yes"], ["apply", "--plan-id", planId], + ["apply", "--yes", "--yes", "--plan-id", planId], ["apply", "--yes", "--plan-id", "latest"]]) { + expect(() => parseCodexCliUpdateArgs(args)).toThrow(); + } + }); + test("malformed apply cannot reach its handler", async () => { + let calls = 0; + expect(await handleCodexCliUpdateCommand(["apply", "--yes"], { applyUpdate: async () => { calls++; throw new Error("not reached"); } })).toBe(2); + expect(calls).toBe(0); + }); + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed apply reaches its consumer with the exact plan ID and prints its public result", async () => { + const planId = "a".repeat(32); let calls = 0; const logs: string[] = [], log = console.log; + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-cli-job-"))); + const previousHome = process.env.OPENCODEX_HOME; + const report = { outcome: "not_applied" as const, reason: "original_install_unchanged", planId, + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; + try { + chmodSync(outer, 0o700); + const parent = join(outer, "private-parent"); + mkdirSync(parent, { mode: 0o700 }); + chmodSync(parent, 0o700); + const configPath = join(parent, "ocx"); + mkdirSync(configPath, { mode: 0o700 }); + chmodSync(configPath, 0o700); + const configDir = realpathSync.native(configPath); + chmodSync(configDir, 0o700); + process.env.OPENCODEX_HOME = configDir; + console.log = value => { logs.push(String(value)); }; + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", planId, "--yes", "--json"], { + applyUpdate: async (id, deps) => { calls++; expect(id).toBe(planId); expect(deps.inspectionDeps).toBeDefined(); return report; }, + })).toBe(1); + expect(calls).toBe(1); expect(logs).toHaveLength(1); + expect(fs.existsSync(join(configDir, "codex-cli-update-jobs", "jobs.json"))).toBe(true); + const dto = JSON.parse(logs[0]!) as Record; + const matcherDto = structuredClone(dto); + expect(typeof dto.jobId).toBe("string"); + expect(dto.jobId).toMatch(/^[0-9a-f-]{36}$/); + expect(matcherDto).toMatchObject({ jobId: expect.stringMatching(/^[0-9a-f-]{36}$/), status: "not_applied", + ownerState: "finished", lifetimeUncertain: false, childState: "terminal", + leaseState: "not_acquired_or_released", planId, outcome: "not_applied", + reason: "original_install_unchanged", cleanupErrors: [], + createdAt: expect.any(String), updatedAt: expect.any(String) }); + const isIsoUtcTimestamp = (value: unknown) => typeof value === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) + && Number.isFinite(Date.parse(value)); + expect(isIsoUtcTimestamp(dto.createdAt)).toBe(true); + expect(isIsoUtcTimestamp(dto.updatedAt)).toBe(true); + expect(Object.keys(dto).sort()).toEqual([ + "childState", "cleanupErrors", "createdAt", "jobId", "leaseState", "lifetimeUncertain", + "outcome", "ownerState", "planId", "reason", "status", "updatedAt", + ]); + for (const field of ["requestKey", "payloadHash", "generation", "ownerPid", "verificationPid", "environment", "token", "leaseRetained"]) { + expect(Object.hasOwn(dto, field)).toBe(false); + } + } finally { + console.log = log; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(outer); + } + }); + test("Windows apply refuses before inspecting or installing", async () => { + let calls = 0; + const result = await applyCodexCliUpdate("a".repeat(32), { platform: "win32", inspect: async () => { calls++; throw new Error("not reached"); } }); + expect(result.reason).toBe("windows_apply_disabled"); expect(calls).toBe(0); + }); +}); diff --git a/tests/codex-integration/codex-cli-update-integrity.test.ts b/tests/codex-integration/codex-cli-update-integrity.test.ts index fbfe3e3160e..0f5dd29ca58 100644 --- a/tests/codex-integration/codex-cli-update-integrity.test.ts +++ b/tests/codex-integration/codex-cli-update-integrity.test.ts @@ -1,21 +1,28 @@ import { describe, expect, test } from "bun:test"; import type { spawnSync } from "node:child_process"; -import { resolveCodexCliUpdateTarget } from "../../src/codex/cli-update-plan"; +import { resolveCodexCliUpdateArtifactTarget } from "../../src/codex/cli-update-plan"; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; -function resolveWith(token: string) { +async function resolveWith(token: string) { let queries = 0; - const outputs = ["1.4.2", token, "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz"]; - const spawn = (() => ({ status: 0, stdout: outputs[queries++], stderr: "" })) as typeof spawnSync; - return { target: resolveCodexCliUpdateTarget("latest", spawn), queries }; + const spawn = ((_bin: string, args: string[]) => { + queries++; + return { status: 0, stderr: "", stdout: JSON.stringify({ + name: args.join(" ").includes("@openai/codex-linux-x64@") ? "@openai/codex-linux-x64" : "@openai/codex", + version: "1.4.2", optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity: token, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, + }) }; + }) as typeof spawnSync; + return { target: await resolveCodexCliUpdateArtifactTarget("latest", spawn, undefined, "linux", "x64"), queries }; } describe("Codex CLI registry SHA-512 evidence", () => { - test("accepts a complete canonical SHA-512 digest in a multi-hash response", () => { - expect(resolveWith(`'sha1-old ${integrity}'\n`)).toEqual({ - target: { kind: "resolved", version: "1.4.2", integrity }, queries: 3, - }); + test("accepts a complete canonical SHA-512 digest for both production artifacts", async () => { + const value = await resolveWith(integrity); + expect(value.target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity, + artifacts: { backing: { name: "@openai/codex-linux-x64", integrity } } }); + expect(value.queries).toBe(3); }); test.each([ @@ -26,8 +33,9 @@ describe("Codex CLI registry SHA-512 evidence", () => { // Node's permissive base64 decoder accepts nonzero unused padding bits. `sha512-${"A".repeat(85)}B==`, integrity.replace("==", ""), - ])("refuses malformed integrity %s before querying the tarball", token => { - const result = resolveWith(token); + `sha1-old ${integrity}`, + ])("refuses malformed integrity %s before querying the tarball", async token => { + const result = await resolveWith(token); expect(result.target.kind).toBe("unresolved"); expect(result.queries).toBe(2); }); diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index aa833c55625..4cc117a15b5 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { + carriesCodexCliUpdateInspectionContext, codexCliInspectionLookupSeeds, isCodexCliUpdateInspectionArgv, NODE_LAUNCH_CONTEXT_MAX_CHARS, @@ -21,6 +22,13 @@ describe("Codex CLI updater launcher policy", () => { expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "update"])).toBe(false); }); + test("published start carries only the proof-bound updater snapshot, without becoming a one-shot inspection", () => { + expect(carriesCodexCliUpdateInspectionContext(["node", "ocx", "start", "--port", "12345"])).toBe(true); + expect(carriesCodexCliUpdateInspectionContext(["node", "ocx", "--ocx-internal-launch-proof=bad", "start"])).toBe(true); + expect(carriesCodexCliUpdateInspectionContext(["node", "ocx", "status"])).toBe(false); + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "start", "--port", "12345"])).toBe(false); + }); + test("launcher skips boot repair and lazy Bun installation for this namespace", () => { const source = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); // Read the guard that actually wraps the probe rather than a fixed pair of adjacent @@ -71,5 +79,7 @@ describe("Codex CLI updater launcher policy", () => { expect(capture).toContain('["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,'); expect(capture).toContain("...CODEX_CLI_TRANSPORT_ENV_KEYS].map(name => name.toLowerCase())"); expect(capture).toContain("if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];"); + expect(source).toContain("|| (process.platform !== \"win32\" && carriesCodexCliUpdateInspectionContext(process.argv))"); + expect(capture).toContain("codexCliInspectionEnv: codexCliUpdateInspectionContext ? {"); }); }); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index b77188877ea..982a59ca822 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; @@ -17,9 +17,11 @@ import { CODEX_CLI_REGISTRY, codexCliUpdatePlanId, createCodexCliUpdatePlan, - resolveCodexCliUpdateTarget, + resolveCodexCliUpdateArtifactTarget, + spawnCodexCliUpdateMetadata, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, + type CodexCliUpdateSpawn, type CodexCliUpdateTarget, } from "../../src/codex/cli-update-plan"; import { @@ -33,6 +35,9 @@ import { } from "../helpers/codex-cli-content-fs"; const REGISTRY_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; +function resolveArtifact(channel: "latest", spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, env?: NodeJS.ProcessEnv) { + return resolveCodexCliUpdateArtifactTarget(channel, spawn, env, "linux", "x64"); +} /** * Phase 2 of the Codex CLI update manager. @@ -332,43 +337,47 @@ describe("strict Codex app-server process scan", () => { describe("registry target resolution", () => { function spawnStub(outputs: { status: number | null; stdout: string }[]) { let call = 0; - return (() => { - const next = outputs[call++] ?? { status: 1, stdout: "" }; - return { status: next.status, stdout: next.stdout, stderr: "" }; + return ((_bin: string, args: string[]) => { + const index = call++, version = outputs[0]?.stdout.trim(); + const name = index === 2 ? "@openai/codex-linux-x64" : "@openai/codex"; + return { status: outputs[index === 0 ? 0 : 1]?.status ?? 1, + stdout: JSON.stringify({ name, version, optionalDependencies: { "@openai/codex-linux-x64": version }, + dist: { integrity: outputs[1]?.stdout.trim(), tarball: outputs[2]?.stdout.trim() } }), stderr: "" }; }) as never; } - test("an exact version with a sha512 token resolves", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("an exact version with a sha512 token resolves", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, - { status: 0, stdout: `'${REGISTRY_INTEGRITY}' sha1-old\n` }, + { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n" }, ])); - expect(target).toEqual({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY }); + expect(target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY, + artifacts: { platform: "linux", arch: "x64", backing: { name: "@openai/codex-linux-x64" } } }); }); - test("a missing integrity token refuses instead of proceeding best-effort", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a missing integrity token refuses instead of proceeding best-effort", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: "sha1-onlythis\n" }, ])); expect(target.kind).toBe("unresolved"); }); - test("a registry timeout refuses", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([{ status: null, stdout: "" }])); + test("a registry timeout refuses", async () => { + const target = await resolveArtifact("latest", spawnStub([{ status: null, stdout: "" }])); expect(target.kind).toBe("unresolved"); }); - test("a non-version answer is not treated as a version", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a non-version answer is not treated as a version", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "latest\n" }, ])); expect(target.kind).toBe("unresolved"); }); - test("a tarball outside the pinned registry origin is refused even with a valid digest", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a tarball outside the pinned registry origin is refused even with a valid digest", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://evil.invalid/@openai/codex/-/codex-1.4.2.tgz\n" }, @@ -376,14 +385,120 @@ describe("registry target resolution", () => { expect(target.kind).toBe("unresolved"); }); - test("a tarball answer that is not a URL at all is refused", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a tarball answer that is not a URL at all is refused", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "not-a-url\n" }, ])); expect(target.kind).toBe("unresolved"); }); + + const GATED_MAIN_JSON = JSON.stringify({ + name: "@openai/codex", version: "1.4.2", + optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity: REGISTRY_INTEGRITY, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, + }); + const GATED_BACKING_JSON = JSON.stringify({ + name: "@openai/codex-linux-x64", version: "1.4.2", + dist: { integrity: REGISTRY_INTEGRITY, tarball: "https://registry.npmjs.org/@openai/codex-linux-x64/-/codex-linux-x64-1.4.2.tgz" }, + }); + + /** + * A REAL child that answers like npm view --json but only after the gate file + * appears — or after its own cap, so a failed assertion can never hang the suite. + * Runs through the production async spawn runner, not a mocked event loop. + */ + const gatedNpmSpawn = (gateFile: string, capMs: number): CodexCliUpdateSpawn => + (_bin, args, options) => spawnCodexCliUpdateMetadata(process.execPath, [ + "-e", + 'const fs=require("node:fs"),g=process.env.OCX_GATE_FILE,a=process.env.OCX_GATE_ANSWER,d=Date.now()+Number(process.env.OCX_GATE_CAP_MS);' + + 'const t=setInterval(()=>{if(!fs.existsSync(g)&&Date.now()<=d)return;clearInterval(t);process.stdout.write(a+"\\n",()=>process.exit(0))},15)', + ], { + ...options, + // The resolver's Windows verbatim flag must not leak onto our own child argv. + windowsVerbatimArguments: false, + env: { ...options.env, OCX_GATE_FILE: gateFile, OCX_GATE_CAP_MS: String(capMs), + OCX_GATE_ANSWER: args.join(" ").includes("@openai/codex-linux-x64@") ? GATED_BACKING_JSON : GATED_MAIN_JSON }, + }); + + test("a gated registry child keeps the event loop responsive while planning waits", async () => { + const dir = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-gated-npm-"))); + const gateFile = join(dir, "release"); + try { + // The real resolver wiring: registry queries spawn the gated child through the + // production async runner. Under the old spawnSync this parked the event loop + // for the whole per-query deadline. + const pending = createCodexCliUpdatePlan(planDeps({ + resolveTarget: undefined, + inspectionDeps: { arch: "x64" }, + spawnProcess: gatedNpmSpawn(gateFile, 4_000), + })); + const firedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise(resolve => setTimeout(resolve, 40)); + // A parked event loop defers the probe until the gated child self-caps, so the + // wall clock from the plan call is the discriminator — not probe latency alone. + expect(Date.now() - firedAt).toBeLessThan(2_000); + writeFileSync(gateFile, "release"); + const plan = await pending; + expect(plan).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("registry metadata runner terminal lanes", () => { + const OPTS = { encoding: "utf8" as const, timeout: 15_000, maxBuffer: 1024 * 1024, windowsHide: true as const }; + test("a non-zero exit keeps its code", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, ["-e", "process.exit(3)"], OPTS); + expect(result.status).toBe(3); + }); + test("a child past the per-query deadline settles null on its terminal event", async () => { + const startedAt = Date.now(); + const result = await spawnCodexCliUpdateMetadata(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], + { ...OPTS, timeout: 250 }); + expect(result.status).toBeNull(); + // The deadline kill is followed by the child's real close, not a reap guess + // long enough to matter; a 30 s sleeper proves settlement came from the kill. + expect(Date.now() - startedAt).toBeLessThan(5_000); + }); + test("stdout past maxBuffer refuses instead of truncating", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", 'process.stdout.write("x".repeat(2 * 1024 * 1024))'], + { ...OPTS, maxBuffer: 64 * 1024 }); + expect(result.status).toBeNull(); + }); + test("multibyte output split across OS pipe chunks decodes intact", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", 'process.stdout.write("\uac00".repeat(200_000))'], { ...OPTS, maxBuffer: 4 * 1024 * 1024 }); + expect(result.status).toBe(0); + expect(result.stdout).toBe("가".repeat(200_000)); + expect(result.stdout).not.toContain("�"); + }); + test("a binary that cannot be spawned settles null like spawnSync", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-meta-spawn-")); + try { + const result = await spawnCodexCliUpdateMetadata(join(dir, "no-such-npm"), ["view"], OPTS); + expect(result.status).toBeNull(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + test("the result waits for the child's real terminal event before settling", async () => { + const dir = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-meta-terminal-"))); + const marker = join(dir, "terminal"); + try { + const startedAt = Date.now(); + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", "setTimeout(() => require('node:fs').writeFileSync(" + JSON.stringify(marker) + ", 'x'), 300)"], OPTS); + expect(result.status).toBe(0); + // The child's final write is on disk when the runner settles: the resolve + // followed the terminal event, so the caller's isolation cleanup in its + // finally can never run underneath a still-live child. + expect(existsSync(marker)).toBe(true); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(250); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); }); describe("registry configuration isolation", () => { @@ -446,6 +561,14 @@ describe("registry configuration isolation", () => { }; calls.push(call); const field = queriedField(call); + if (argvLine(call).includes("--json")) { + const version = (outputs.version ?? "").trim(); + const backing = argvLine(call).match(/@openai\/codex-(?:linux|darwin)-(?:x64|arm64)(?=@)/)?.[0]; + return { status: 0, stdout: JSON.stringify({ name: backing ?? "@openai/codex", version, + optionalDependencies: backing ? undefined : Object.fromEntries(["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"] + .map(suffix => [`@openai/codex-${suffix}`, version])), + dist: { integrity: (outputs["dist.integrity"] ?? "").trim(), tarball: (outputs["dist.tarball"] ?? "").trim() } }), stderr: "" }; + } return { status: 0, stdout: outputs[field] ?? "", stderr: "" }; }) as never; return { calls, spawn }; @@ -457,7 +580,7 @@ describe("registry configuration isolation", () => { "dist.tarball": "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n", }; - test.each(["resolved", "unresolved"] as const)("cleanup failure preserves the %s resolver outcome", kind => { + test.each(["resolved", "unresolved"] as const)("cleanup failure preserves the %s resolver outcome", async kind => { let ownedRoot = ""; let cleanupAttempts = 0; const originalRemove = fs.rmSync; @@ -474,7 +597,7 @@ describe("registry configuration isolation", () => { ownedRoot = options.cwd as string; return (capture.spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe(kind); + expect((await resolveArtifact("latest", spawn)).kind).toBe(kind); expect(cleanupAttempts).toBe(1); expect(existsSync(ownedRoot)).toBe(true); } finally { @@ -483,13 +606,14 @@ describe("registry configuration isolation", () => { } }); - test("every query pins the official registry and substitutes a controlled npmrc", () => { + test("every query pins the official registry and substitutes a controlled npmrc", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { expect(argvLine(call)).toContain("--registry=" + CODEX_CLI_REGISTRY); + expect(argvLine(call)).toContain("--json"); expect(call.options.maxBuffer).toBe(1024 * 1024); // Both file configs use distinct controlled empty files inside the // isolation dir — a user ~/.npmrc or a global $PREFIX/etc/npmrc cannot answer. expect(argvLine(call)).toContain("--userconfig="); @@ -504,14 +628,14 @@ describe("registry configuration isolation", () => { expect(existsSync(calls[0]!.options.cwd as string)).toBe(false); }); - test("a hostile npm_config_* env cannot reach the spawned npm", () => { + test("a hostile npm_config_* env cannot reach the spawned npm", async () => { const prior = process.env.npm_config_registry; const priorScoped = process.env["npm_config_@openai:registry"]; process.env.npm_config_registry = "https://evil.invalid"; process.env["npm_config_@openai:registry"] = "https://evil.invalid/"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -525,11 +649,11 @@ describe("registry configuration isolation", () => { } }); - test("the controlled cwd never is the operator's project directory", () => { + test("the controlled cwd never is the operator's project directory", async () => { // A project .npmrc in the launch directory would otherwise redirect the scope; // the query must run from the sentinel-anchored isolation dir instead. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const cwd = call.options.cwd as string; @@ -548,7 +672,7 @@ describe("registry configuration isolation", () => { }; } - test("the bound launcher snapshot supplies executable and transport trust inputs", () => { + test("the bound launcher snapshot supplies executable and transport trust inputs", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const boundEnv = { ...markerBoundEnv(), HTTPS_PROXY: "http://trusted-proxy", NODE_EXTRA_CA_CERTS: "/trusted/ca.pem" }; const previous = { HTTPS_PROXY: process.env.HTTPS_PROXY, ALL_PROXY: process.env.ALL_PROXY, @@ -557,7 +681,7 @@ describe("registry configuration isolation", () => { process.env.HTTPS_PROXY = "http://project-dotenv-proxy"; process.env.ALL_PROXY = "http://project-dotenv-fallback"; process.env.NODE_EXTRA_CA_CERTS = "/project/dotenv-ca.pem"; - expect(resolveCodexCliUpdateTarget("latest", spawn, boundEnv).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn, boundEnv)).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; expect(env.PATH).toBe(boundEnv.PATH); @@ -574,14 +698,14 @@ describe("registry configuration isolation", () => { } }); - test("the plan resolves registry evidence through the attested launcher environment", async () => { + test.each(["x64", "arm64"] as const)("the plan resolves registry evidence through the attested %s launcher environment", async arch => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const boundEnv = markerBoundEnv(); const plan = await applicablePlan({ // Dropping the resolveTarget stub exercises the real resolver wiring: the // provenance snapshot env must reach npm's spawn options. resolveTarget: undefined, - inspectionDeps: { env: boundEnv }, + inspectionDeps: { env: boundEnv, arch }, spawnProcess: spawn, }); expect(plan.targetVersion).toBe("1.4.2"); @@ -596,13 +720,13 @@ describe("registry configuration isolation", () => { "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "LD_DEBUG_OUTPUT", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "ld_preload", "dyld_insert_libraries", - ])("ambient loader control %s cannot reach the spawned npm", key => { + ])("ambient loader control %s cannot reach the spawned npm", async key => { const previous = process.env[key]; process.env[key] = "/synthetic/nonexistent-loader-fixture"; try { // Capture only: no loader, native child process or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -614,7 +738,7 @@ describe("registry configuration isolation", () => { } }); - test("hostile Node startup channels cannot reach the spawned npm", () => { + test("hostile Node startup channels cannot reach the spawned npm", async () => { const priorOptions = process.env.NODE_OPTIONS; const priorPath = process.env.NODE_PATH; const priorTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED; @@ -627,7 +751,7 @@ describe("registry configuration isolation", () => { process.env.NODE_REDIRECT_WARNINGS = "C:\\evil-warnings.log"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -655,13 +779,13 @@ describe("registry configuration isolation", () => { test.each([ "OPENSSL_CONF", "OPENSSL_CONF_INCLUDE", "OPENSSL_MODULES", "OPENSSL_ENGINES", "openssl_conf", "openssl_modules", - ])("ambient OpenSSL startup control %s cannot reach the spawned npm", key => { + ])("ambient OpenSSL startup control %s cannot reach the spawned npm", async key => { const previous = process.env[key]; process.env[key] = join(tmpdir(), "ocx-ambient-openssl.cnf"); try { // Capture only: no child process, native provider or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -673,7 +797,7 @@ describe("registry configuration isolation", () => { } }); - test("supported proxy and CA channels still reach the spawned npm", () => { + test("supported proxy and CA channels still reach the spawned npm", async () => { const sentinels = { HTTP_PROXY: "http://127.0.0.1:9/ocx-proxy-sentinel", NO_PROXY: "registry.npmjs.org.invalid,127.0.0.1", @@ -683,7 +807,7 @@ describe("registry configuration isolation", () => { try { Object.assign(process.env, sentinels); const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; for (const [key, value] of Object.entries(sentinels)) { @@ -701,7 +825,7 @@ describe("registry configuration isolation", () => { } }); - test("real Node cannot write output sentinels outside the owned npm root", () => { + test("real Node cannot write output sentinels outside the owned npm root", async () => { const fixture = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-node-output-"))); const sentinels = { NODE_V8_COVERAGE: join(fixture, "escaped-coverage"), @@ -749,9 +873,9 @@ describe("registry configuration isolation", () => { checked = true; } const field = queriedField({ bin, args, options } as CapturedCall); - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(checked).toBe(true); expect(existsSync(ownedRoot)).toBe(false); } finally { @@ -764,9 +888,9 @@ describe("registry configuration isolation", () => { } }); - test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { + test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - resolveCodexCliUpdateTarget("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + await resolveArtifact("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); // With no trusted PATH the ambient PATH must never reach the child: Windows // resolves the binary itself and spawns nothing, while POSIX spawns the bare // npm name whose execvp lookup then fails under the explicit empty PATH. @@ -777,13 +901,13 @@ describe("registry configuration isolation", () => { // spawn that honours the empty PATH fails and the target stays unresolved. const enoent = { status: null as number | null, stdout: "", stderr: "spawnSync npm ENOENT", error: new Error("spawnSync npm ENOENT") }; const failingSpawn = (() => ({ ...enoent })) as never; - const refused = resolveCodexCliUpdateTarget("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + const refused = await resolveArtifact("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); expect(refused.kind).toBe("unresolved"); }); - test("npm and Node caches and logs stay inside the owned temporary root", () => { + test("npm and Node caches and logs stay inside the owned temporary root", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { @@ -795,7 +919,7 @@ describe("registry configuration isolation", () => { } }); - test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", () => { + test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-update-node-cache-")); const home = join(root, "home"), temporaryRoot = join(root, "temp"), ambientCache = join(root, "ambient-cache"); const previous = process.env.NODE_COMPILE_CACHE; @@ -822,9 +946,9 @@ describe("registry configuration isolation", () => { observedCache = env.NODE_COMPILE_CACHE; } // Only registry evidence is synthetic; npm startup above is real. - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls).toBe(3); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(temporaryRoot)).toEqual([]); @@ -838,7 +962,7 @@ describe("registry configuration isolation", () => { } }); - test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", () => { + test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", async () => { // POSIX uses a flat argv. Windows planning remains deferred; its quoted command // and file isolation are covered above without treating a shell line as argv. const fakeHome = mkdtempSync(join(tmpdir(), "ocx-update-config-test-")); @@ -864,9 +988,9 @@ describe("registry configuration isolation", () => { expect(result.stderr).not.toContain("double-loading config"); configReads++; } - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(configReads).toBe(1); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(fakeHome)).toEqual([]); @@ -875,7 +999,7 @@ describe("registry configuration isolation", () => { } }); - test("a real npm call under the isolated argv leaves the operator HOME untouched", () => { + test("a real npm call under the isolated argv leaves the operator HOME untouched", async () => { // Runs real npm exactly once: the first captured query executes for real with a // disposable HOME while the rest stay stubbed. npm_config_offline makes the // registry leg fail fast without network; the residue assertions hold either way. @@ -894,9 +1018,12 @@ describe("registry configuration isolation", () => { }; calls.push(call); const field = queriedField(call); - if (realRan) return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + if (realRan) return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); const env = { ...(options.env as NodeJS.ProcessEnv), HOME: fakeHome, USERPROFILE: fakeHome, npm_config_offline: "true" }; - const result = spawnSync(bin, args, { ...options, env, timeout: 30_000, windowsHide: true, encoding: "utf8" }); + const offlineArgs = options.windowsVerbatimArguments + ? [...args.slice(0, 3), args[3]!.slice(0, -1) + ' --offline --fetch-retries=0 --fetch-timeout=1000"'] + : [...args, "--offline", "--fetch-retries=0", "--fetch-timeout=1000"]; + const result = spawnSync(bin, offlineArgs, { ...options, env, timeout: 10_000, windowsHide: true, encoding: "utf8" }); realRan = true; // A spawn that never started (ENOENT, timeout kill) proves nothing; require // a real process exit so the residue assertions actually bound npm's writes. @@ -906,9 +1033,9 @@ describe("registry configuration isolation", () => { // The owned root is still live here, so residue inside it is observable // before the resolver's finally removes it. for (const name of readdirSync(cwd)) ownedDuringRun.push(name); - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - resolveCodexCliUpdateTarget("latest", spawn); + await resolveArtifact("latest", spawn); expect(realRan).toBe(true); const cwd = calls[0]!.options.cwd as string; // Anything npm persisted was inside the owned root and is removed with it; @@ -921,5 +1048,5 @@ describe("registry configuration isolation", () => { } finally { rmSync(fakeHome, { recursive: true, force: true }); } - }); + }, 15_000); }); diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts new file mode 100644 index 00000000000..ba4b970e1b1 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -0,0 +1,545 @@ +/** + * Worker-dispatch integration coverage for the Codex CLI update offload. + * + * The generic gate/fence and fixture-worker cases below prove the dispatch + * contract; they do not by themselves prove the production callers are + * protected — the real plan/apply worker path and the seven gated parent + * operations have their own regression cases elsewhere. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import type { TrustedNodeLaunchContext } from "../../src/cli/launcher-context"; +import { + runCodexCliUpdatePlanInWorker, runCodexCliUpdateApplyInWorker, + cancelCodexCliUpdateWorkerJob, codexCliUpdateUnjoinedWorkerCount, + codexCliUpdateUnjoinedWorkerClosedTotal, codexCliUpdateWorkerDiagnostics, +} from "../../src/codex/cli-update-job-worker"; +import { + createCodexCliUpdateFence, cancelCodexCliUpdateFence, codexCliUpdateIsCancelled, + codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, resetCodexCliSelectionGatesForTests, +} from "../../src/codex/cli-update-selection-gate"; +import { codexCliUpdateInspectionDepsFromSnapshot } from "../../src/codex/cli-update-inspection-context"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { spawnTestChild } from "../helpers/spawn-test-child"; +import { repoRoot } from "../helpers/repo-root"; +import { + FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, + FIXTURE_CONTROL_WORDS, type FixtureConfiguration, +} from "../helpers/cli-update-fixture-worker"; +type Snapshot = TrustedNodeLaunchContext["codexCliInspectionEnv"]; +type RunMessage = { + type: "run"; requestId: string; jobId: string; operation: "plan" | "apply"; + planId?: string; snapshot: Snapshot; gate: SharedArrayBuffer; fence: SharedArrayBuffer; + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +}; +const planId = "a".repeat(32); +const roots: string[] = []; +let jobSequence = 0; +const workers: OwnedWorker[] = []; +function snapshot(configDir: string): NonNullable { + return { + codexCliPath: join(configDir, "selected", "codex.cmd"), + path: "", pathExt: ".EXE;.CMD", home: configDir, nodePath: null, + managerRoots: {}, transportEnv: {}, arch: process.arch, configDir, + }; +} +function home() { + const root = mkdtempSync(join(tmpdir(), "ocx-update-worker-")); + roots.push(root); + return root; +} +// Deadline is only a stuck-fixture watchdog, never a performance threshold: +// a cold production-worker module import on Windows legitimately takes +// seconds, so the bound is generous and only failure paths ever reach it. +// No main-thread Atomics.wait: it would invalidate the responsiveness test. +async function until(check: () => boolean, label: string): Promise { + const deadline = performance.now() + 15_000; + while (!check()) { + if (performance.now() >= deadline) throw new Error("fixture did not reach " + label); + await new Promise(resolve => setTimeout(resolve, 0)); + } +} +interface OwnedWorker { + worker: Worker; + control: Int32Array; + runMessages: RunMessage[]; + messages: unknown[]; + terminateCalls: number; + closed: boolean; + errors: string[]; + finished: Promise; + detach(): void; + release(): void; +} +function ownWorker(url: URL, config?: Omit): OwnedWorker { + const worker = new Worker(url, { type: "module" }); + const control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * FIXTURE_CONTROL_WORDS)); + let complete!: () => void; + const finished = new Promise(resolve => { complete = resolve; }); + const owner: OwnedWorker = { + worker, control, runMessages: [], messages: [], terminateCalls: 0, + closed: false, errors: [], finished, + release() { Atomics.store(control, FIXTURE_RELEASE, 1); Atomics.notify(control, FIXTURE_RELEASE); }, + detach() { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + worker.removeEventListener("close", onClose); + }, + }; + const onMessage = (event: MessageEvent) => { owner.messages.push(event.data); }; + const onError = (event: ErrorEvent) => { + owner.errors.push(event.message); + event.preventDefault(); // host's own listener still observes the same error + }; + const onClose = () => { owner.closed = true; complete(); }; + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); + worker.addEventListener("close", onClose); + const post = worker.postMessage.bind(worker); + Object.defineProperty(worker, "postMessage", { + configurable: true, + value: (...args: Parameters) => { + const message = args[0] as Partial | undefined; + if (message?.type === "run") owner.runMessages.push(message as RunMessage); + return Reflect.apply(post, worker, args); + }, + }); + const terminate = worker.terminate.bind(worker); + Object.defineProperty(worker, "terminate", { + configurable: true, + value: (...args: Parameters) => { + owner.terminateCalls++; + return Reflect.apply(terminate, worker, args); + }, + }); + workers.push(owner); + if (config) post({ type: "fixture-config", control: control.buffer, ...config }); + return owner; +} +function fixture(config: Omit = {}) { + const owner = ownWorker(new URL("../helpers/cli-update-fixture-worker.ts", import.meta.url), config); + const configDir = home(), jobId = "fixture-job-" + (++jobSequence); + const options = { + jobId, snapshot: snapshot(configDir), + env: { CODEX_HOME: join(configDir, "codex"), OPENCODEX_HOME: configDir }, + spawnWorkerFn: (_url: string, _key: string) => owner.worker, + }; + return { owner, options, jobId, configDir }; +} +async function joined(owner: OwnedWorker) { + await until(() => owner.closed, "worker close"); + await owner.finished; + expect(owner.terminateCalls).toBe(0); + expect(owner.runMessages).toHaveLength(1); +} +afterEach(async () => { + // Release every exact fixture even if an assertion failed. Never terminate a + // dispatched worker whose done result might conceal an owned native child. + // A failed close wait must not lose the remaining owners: every worker gets + // its release+join attempt, and one that still will not close is re-queued + // so a later afterEach still owns it instead of leaking it untracked. + const owned = workers.splice(0); + const abandoned: OwnedWorker[] = []; + let firstError: unknown; + for (const owner of owned) { + try { + owner.release(); + if (!owner.closed) { + // A fixture before its RunMsg may be idle; FIFO close is harmless after run. + try { owner.worker.postMessage({ type: "fixture-close" }); } + catch { /* A concurrently closing worker still must emit the close observed below. */ } + } + await until(() => owner.closed, "owned worker close during cleanup"); + await owner.finished; + expect(owner.terminateCalls).toBe(0); + } catch (error) { + if (firstError === undefined) firstError = error; + if (!owner.closed) abandoned.push(owner); + } finally { + owner.detach(); + } + } + workers.push(...abandoned); + // Do not erase gate state or remove homes until every owned worker has closed. + resetCodexCliSelectionGatesForTests(); + for (const root of roots.splice(0)) await removeTreeAfterBackgroundWork(root); + if (firstError !== undefined) throw firstError; +}); +describe("process-wide selection commit gate and cancellation fence", () => { + test("parent write is nonblocking busy; release restores admission", async () => { + const first = beginCodexCliSelectionParentWrite(); + expect(first.entered).toBe(true); + try { + const second = beginCodexCliSelectionParentWrite(); + try { expect(second.entered).toBe(false); } finally { second.exit(); } + const fence = createCodexCliUpdateFence(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), fence, deadlineMs: 0, + }); + try { + expect(window.tryEnter()).toBe(false); + expect(await window.waitEnter()).toBe(false); + } finally { window.exit(); } + } finally { first.exit(); } + const next = beginCodexCliSelectionParentWrite(); + try { expect(next.entered).toBe(true); } finally { next.exit(); } + }); + test("worker waitEnter wins the gate once the parent writer releases", async () => { + const writer = beginCodexCliSelectionParentWrite(); + expect(writer.entered).toBe(true); + const fence = createCodexCliUpdateFence(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), fence, + // The sleep seam releases the parent write mid-wait deterministically. + sleep: async () => { writer.exit(); }, + }); + try { + expect(await window.waitEnter()).toBe(true); + const blocked = beginCodexCliSelectionParentWrite(); + try { expect(blocked.entered).toBe(false); } finally { blocked.exit(); } + } finally { window.exit(); writer.exit(); } + }); + test("all callers share one gate, including aliased and different directory snapshots", () => { + const root = home(); + const selections = [snapshot(root), snapshot(join(root, ".")), snapshot(join(root, "other"))]; + const gate = codexCliUpdateSelectionGate(); + const window = createWorkerCommitWindow({ gate, fence: createCodexCliUpdateFence() }); + expect(window.tryEnter()).toBe(true); + try { + for (const selected of selections) { + // Gate intentionally takes no configDir: aliases cannot form a second lock. + expect(selected.configDir).toBeDefined(); + expect(codexCliUpdateSelectionGate()).toBe(gate); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + } + } finally { window.exit(); } + // This tests shared gate behavior, not filesystem alias resolution or callers. + }); + test("cancel CAS wins before commit and cannot be overwritten", () => { + const fence = createCodexCliUpdateFence(), cancelled = codexCliUpdateIsCancelled(fence); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + expect(window.tryEnter()).toBe(true); + try { + expect(cancelled()).toBe(false); + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(cancelCodexCliUpdateFence(fence)).toBe(false); + expect(window.commit()).toBe(false); + expect(cancelled()).toBe(true); + } finally { window.exit(); } + }); + test("committed CAS wins; later cancellation is deferred, not reported aborted", () => { + const fence = createCodexCliUpdateFence(), cancelled = codexCliUpdateIsCancelled(fence); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + expect(window.tryEnter()).toBe(true); + try { + expect(window.commit()).toBe(true); + expect(cancelCodexCliUpdateFence(fence)).toBe(false); + expect(cancelled()).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + } finally { window.exit(); } + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(true); } finally { writer.exit(); } + }); +}); +describe("actual Worker dispatch and parent responsiveness", () => { + test("fresh main-thread entry and fixture imports preserve the message sentinel", async () => { + const urls = ["../../src/codex/cli-update-worker.ts", "../helpers/cli-update-fixture-worker.ts"] + .map(path => new URL(path, import.meta.url).href); + // A new main process has imported neither module before installing the sentinel; + // no query-string cache assumption or extra production export is needed. + const program = 'if(Bun.isMainThread!==true)throw new Error("not main");' + + 'const sentinel=()=>{};globalThis.onmessage=sentinel;let imports=0;' + + `try{for(const url of ${JSON.stringify(urls)}){await import(url);imports++;` + + 'if(globalThis.onmessage!==sentinel)throw new Error("main handler replaced");}' + + 'console.log(JSON.stringify({main:Bun.isMainThread,imports,sentinelPreserved:globalThis.onmessage===sentinel}));' + + '}finally{globalThis.onmessage=null;}'; + const result = await spawnTestChild([process.execPath, "--no-env-file", "--no-install", "-e", program], + { cwd: repoRoot(), env: process.env, deadlineMs: 2_500 }); + expect(result.timedOut, `${result.stdout}\n${result.stderr}`).toBe(false); + expect(result.reaped && result.pipesClosed).toBe(true); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ main: true, imports: 2, sentinelPreserved: true }); + }); + test("detached message handler keeps a real Bun child alive until observed exit", async () => { + const releasePath = join(home(), "release-child"); + const f = fixture({ reply: "ambiguous", childDrain: { releasePath } }); + const initial = codexCliUpdateUnjoinedWorkerCount(); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 10 }); + try { + expect(await operation).toMatchObject({ outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true }); + const started = f.owner.messages.find(m => (m as { type?: string }).type === "fixture-child-started") as { pid: number }; + expect(started?.pid).toBeGreaterThan(0); + expect(f.owner.closed).toBe(false); + expect(f.owner.terminateCalls).toBe(0); + expect(codexCliUpdateUnjoinedWorkerCount()).toBe(initial + 1); + expect(f.owner.messages.some(m => (m as { type?: string }).type === "fixture-child-exited")).toBe(false); + } finally { writeFileSync(releasePath, "release"); } + await joined(f.owner); + expect(f.owner.messages).toContainEqual(expect.objectContaining({ type: "fixture-child-exited", exitCode: 0 })); + await until(() => codexCliUpdateUnjoinedWorkerCount() === initial, "real child owner drained"); + }); + test("parent HTTP health and stream advance while the Worker is synchronously blocked", async () => { + const f = fixture({ block: "before-reply" }); + let push!: (bytes: Uint8Array) => void; + let finishStream!: () => void; + const encoder = new TextEncoder(); + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/health") return new Response("healthy"); + return new Response(new ReadableStream({ + start(controller) { + push = bytes => controller.enqueue(bytes); + finishStream = () => controller.close(); + controller.enqueue(encoder.encode("first")); + }, + })); + }, + }); + const operation = runCodexCliUpdateApplyInWorker(planId, f.options); + // Install a rejection consumer immediately; finally still joins the same run. + void operation.catch(() => {}); + let reader: ReadableStreamDefaultReader | undefined; + const write = beginCodexCliSelectionParentWrite(); + expect(write.entered).toBe(true); + try { + await until(() => Atomics.load(f.owner.control, FIXTURE_ENTERED) === 1, "sync worker barrier"); + expect(Atomics.load(f.owner.control, FIXTURE_RELEASE)).toBe(0); + expect(await (await fetch(new URL("/health", server.url))).text()).toBe("healthy"); + reader = (await fetch(new URL("/stream", server.url))).body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("first"); + push(encoder.encode("second")); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("second"); + expect(Atomics.load(f.owner.control, FIXTURE_FINISHED)).toBe(0); + // General parent HTTP/stream work continues even with the selection gate busy. + finishStream(); + expect((await reader.read()).done).toBe(true); + } finally { + write.exit(); + await reader?.cancel(); + server.stop(true); + f.owner.release(); + } + expect(await operation).toMatchObject({ outcome: "refused", reason: "preparation_failed" }); + await joined(f.owner); + }); + test("done is not sufficient: result stays pending until Worker close", async () => { + const f = fixture({ block: "after-reply" }); + let settled = false; + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 1_000 }); + void operation.then(() => { settled = true; }, () => { settled = true; }); + try { + await until(() => f.owner.messages.some(m => (m as { type?: string }).type === "done"), "done message"); + await Promise.resolve(); + expect(settled).toBe(false); + expect(f.owner.closed).toBe(false); + } finally { f.owner.release(); } + expect(await operation).toMatchObject({ outcome: "refused", reason: "preparation_failed" }); + await joined(f.owner); + }); + test("unknown-child done without close becomes ambiguous and NEVER terminates worker", async () => { + const f = fixture({ reply: "ambiguous", block: "after-reply" }); + const initial = codexCliUpdateUnjoinedWorkerCount(); + const initialClosed = codexCliUpdateUnjoinedWorkerClosedTotal(); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 10 }); + try { + const result = await operation; + expect(result).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + expect(f.owner.closed).toBe(false); + expect(f.owner.messages).toContainEqual(expect.objectContaining({ + type: "done", result: expect.objectContaining({ + reason: "installer_state_unknown", leaseRetained: true, verificationPid: 424242, + }), + })); + expect(Atomics.load(f.owner.control, FIXTURE_RELEASE)).toBe(0); + expect(f.owner.terminateCalls).toBe(0); + expect(codexCliUpdateUnjoinedWorkerCount()).toBe(initial + 1); + expect(f.owner.runMessages).toHaveLength(1); + expect(cancelCodexCliUpdateWorkerJob(f.jobId)).toBe(false); + // The settled job is off the live ledger but its Worker is still tracked. + const diagnostics = codexCliUpdateWorkerDiagnostics(); + expect(diagnostics.gate).toBe("free"); + expect(diagnostics.liveJobs.some(job => job.jobId === f.jobId)).toBe(false); + expect(diagnostics.unjoinedWorkers).toBe(initial + 1); + } finally { f.owner.release(); } + await joined(f.owner); + await until(() => codexCliUpdateUnjoinedWorkerCount() === initial, "unjoined set removal on actual close"); + // Removed from the live set but kept as evidence: closed-after-unknown total. + await until(() => codexCliUpdateUnjoinedWorkerClosedTotal() === initialClosed + 1, + "closed-after-unknown diagnostic"); + }); + test("real AbortSignal listener and job cancellation reach the same preparing fence", async () => { + const f = fixture({ block: "before-reply" }); + const controller = new AbortController(); + let abortEvents = 0; + const onAbort = () => { abortEvents++; }; + controller.signal.addEventListener("abort", onAbort, { once: true }); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, signal: controller.signal }); + void operation.catch(() => {}); + try { + await until(() => Atomics.load(f.owner.control, FIXTURE_ENTERED) === 1, "cancellable worker"); + const run = f.owner.runMessages[0]!; + expect(controller.signal).toBeInstanceOf(AbortSignal); + controller.abort(); + expect(abortEvents).toBe(1); + expect(codexCliUpdateIsCancelled(run.fence)()).toBe(true); + expect(cancelCodexCliUpdateWorkerJob(f.jobId)).toBe(false); + const window = createWorkerCommitWindow({ gate: run.gate, fence: run.fence }); + try { expect(window.commit()).toBe(false); } finally { window.exit(); } + } finally { + controller.signal.removeEventListener("abort", onAbort); + f.owner.release(); + } + await operation; + await joined(f.owner); + }); + test("AbortSignal remains a real native-fetch signal, separate from synchronous SAB cancellation", async () => { + const fence = createCodexCliUpdateFence(), controller = new AbortController(); + const deps: Pick = { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), + }; + let requestSeen = false; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch() { requestSeen = true; return new Promise(() => {}); }, + }); + const request = fetch(server.url, { signal: deps.signal }); + // Consume rejection immediately; abort is intentionally issued after request admission. + const outcome = request.then(() => ({ rejected: false }), error => ({ rejected: true, error })); + try { + await until(() => requestSeen, "native fetch admission"); + expect(deps.signal).toBeInstanceOf(AbortSignal); + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(deps.isCancelled!()).toBe(true); + expect(deps.signal!.aborted).toBe(false); // SAB hook did not widen/replace the signal. + controller.abort(); + expect((await outcome).rejected).toBe(true); + expect(deps.signal!.aborted).toBe(true); + } finally { controller.abort(); server.stop(true); await outcome; } + }); +}); +describe("reply correlation, loss and run-once ownership", () => { + for (const correlation of ["stale-first", "duplicate"] as const) { + test(correlation + " cannot replace the first matching reply or replay apply", async () => { + const f = fixture({ correlation }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "refused", reason: "preparation_failed", leaseRetained: false, + }); + await joined(f.owner); + expect(Atomics.load(f.owner.control, FIXTURE_RUNS)).toBe(1); + }); + } + for (const mode of ["none", "die", "throw", "malformed", "error"] as const) { + test(mode + " after run delivery is ambiguous, never a safe preparation refusal", async () => { + const f = fixture({ reply: mode }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + expect(Atomics.load(f.owner.control, FIXTURE_RUNS)).toBe(1); + }); + } + for (const staleField of ["requestId", "jobId", "both"] as const) { + test("wrong " + staleField + " followed by close does not qualify as done", async () => { + const f = fixture({ correlation: "stale-only", staleField }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + }); + } + test("spawn failure before any run delivery is refused once", async () => { + let attempts = 0; + const result = await runCodexCliUpdateApplyInWorker(planId, { + jobId: "never-started", snapshot: snapshot(home()), + spawnWorkerFn() { attempts++; throw new Error("fixture-spawn-failed"); }, + }); + expect(result).toMatchObject({ outcome: "refused", reason: "preparation_failed", leaseRetained: false }); + expect(attempts).toBe(1); + }); + test("postMessage throwing AFTER possible delivery must not falsely claim not started", async () => { + const f = fixture({}); + const post = f.owner.worker.postMessage.bind(f.owner.worker); + Object.defineProperty(f.owner.worker, "postMessage", { + configurable: true, + value: (...args: Parameters) => { + Reflect.apply(post, f.owner.worker, args); + if ((args[0] as { type?: string }).type === "run") throw new Error("after-delivery-fixture-error"); + }, + }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + }); + test("plan loss rejects plan_unavailable without a retry", async () => { + const f = fixture({ reply: "none" }); + await expect(runCodexCliUpdatePlanInWorker(f.options)).rejects.toThrow("plan_unavailable"); + await joined(f.owner); + }); + test("production entry ignores duplicate/late RunMsg and emits one correlated reply", async () => { + const owner = ownWorker(new URL("../../src/codex/cli-update-worker.ts", import.meta.url)); + const run: RunMessage = { + type: "run", operation: "plan", requestId: "run-once-request", jobId: "run-once-job", + snapshot: null, gate: codexCliUpdateSelectionGate(), fence: createCodexCliUpdateFence(), + env: { CODEX_HOME: home(), OPENCODEX_HOME: home() }, + }; + // Null trusted snapshot fails closed and must never query a real provider. + owner.worker.postMessage(run); + owner.worker.postMessage({ ...run, requestId: "late-request", jobId: "late-job" }); + await until(() => owner.closed, "production worker close"); + const replies = owner.messages.filter(m => ["done", "error"].includes((m as { type: string }).type)); + expect(replies).toHaveLength(1); + expect(replies[0]).toMatchObject({ requestId: run.requestId, jobId: run.jobId }); + expect(owner.terminateCalls).toBe(0); + // Two input messages are intentional here. No host retry/install replay occurred. + }); +}); +describe("snapshot target does not fall back to ambient homes", () => { + test("explicit snapshot and worker-home override remain distinct", async () => { + const f = fixture({}), ambient = home(); + const original = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = ambient; + try { + const captured = f.options.snapshot; + expect(await runCodexCliUpdateApplyInWorker(planId, { + ...f.options, env: { OPENCODEX_HOME: ambient, CODEX_HOME: ambient }, + })).toMatchObject({ outcome: "refused" }); + await joined(f.owner); + expect(f.owner.runMessages[0]!.snapshot).toEqual(captured); + expect(f.owner.runMessages[0]!.snapshot!.configDir).toBe(f.configDir); + expect(f.owner.runMessages[0]!.env).toEqual({ OPENCODEX_HOME: ambient, CODEX_HOME: ambient }); + const deps = codexCliUpdateInspectionDepsFromSnapshot(captured); + expect(deps.configDir).toBe(f.configDir); + expect(deps.env?.CODEX_CLI_PATH).toBe(captured.codexCliPath!); + expect(deps.env?.HOME).toBe(captured.home!); + } finally { + if (original === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = original; + } + }); + test("null or missing manager-root proof fails closed without ambient backfill", () => { + const original = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home(); + try { + for (const captured of [null, { ...snapshot(home()), managerRoots: null }]) { + const deps = codexCliUpdateInspectionDepsFromSnapshot(captured); + expect(deps.env?.PATH).toBe(""); + expect(deps.configDir).toBe("."); + expect(deps.env?.CODEX_CLI_PATH).toBeUndefined(); + } + } finally { + if (original === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = original; + } + }); +}); + diff --git a/tests/codex-integration/codex-cli-update-zero-effect.test.ts b/tests/codex-integration/codex-cli-update-zero-effect.test.ts index d985ea3144d..5b14b96ea2c 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -203,7 +203,7 @@ describe("Codex CLI updater zero-effect boundary", () => { }); expect(result.error).toBeUndefined(); expect(result.status).toBe(2); - expect(result.stderr).toContain("codex-cli-update action must be check, attest or plan"); + expect(result.stderr).toContain("codex-cli-update action must be check, attest, plan, apply or status"); expect(readFileSync(statePath)).toEqual(before); expect(existsSync(marker)).toBe(false); }); diff --git a/tests/codex-integration/codex-cli-windows-child.test.ts b/tests/codex-integration/codex-cli-windows-child.test.ts new file mode 100644 index 00000000000..58dea94aca6 --- /dev/null +++ b/tests/codex-integration/codex-cli-windows-child.test.ts @@ -0,0 +1,343 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { dlopen, ptr, read, toBuffer } from "bun:ffi"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fixturePath } from "../helpers/repo-root"; +import { queryWindowsFixtureObjectName } from "../fixtures/windows-cli-child-object-name"; +import { WindowsChildNative, securityAttributes, wide, WIN64_CHILD_ABI } from "../../src/codex/windows-cli-child-native"; +import { WindowsChildPipe } from "../../src/codex/windows-cli-child-pipe"; +import { cleanupUnstartedWindowsChild, startWindowsCliChildWithNative, windowsCliChildPolicy, + type WindowsCliChild, type WindowsCliChildInvocation, type WindowsCliChildPolicy } from "../../src/codex/windows-cli-child"; + +const windows = process.platform === "win32" && process.arch === "x64"; +const directory = mkdtempSync(join(tmpdir(), "ocx-native-child-")); +const owners: WindowsCliChild[] = []; +let sequence = 0; +const path = () => join(directory, `fixture-${++sequence}`); +const invocation = (args: string[], policy: WindowsCliChildPolicy = "pack"): WindowsCliChildInvocation => ({ + executable: process.execPath, args: [fixturePath("windows-cli-child.ts"), ...args], cwd: directory, env: process.env, policy, +}); +function launch(args: string[], policy: WindowsCliChildPolicy = "pack", native = new WindowsChildNative()) { + const child = startWindowsCliChildWithNative(invocation(args, policy), native); owners.push(child); return child; +} +async function until(predicate: () => boolean, timeout = 5000) { + const deadline = Date.now() + timeout; + while (!predicate()) { if (Date.now() > deadline) throw new Error("fixture condition deadline"); await Bun.sleep(5); } +} +async function finish(child: WindowsCliChild) { + const result = child.process ? await child.wait() : (await cleanupUnstartedWindowsChild(child), null); + expect(child.native.handles.size).toBe(0); expect(child.native.closeErrors).toEqual([]); + expect(child.native.borrowedMemory.size).toBe(0); + return result; +} +afterAll(async () => { + // These are task-owned gates and exact original process/Job handles only. + for (let i = 1; i <= sequence; i++) writeFileSync(join(directory, `fixture-${i}`), "release"); + for (const child of owners) { + if (!child.native.handles.size) continue; + if (child.setupFailure) child.reapUnstarted(); + const result = child.process ? await child.wait() : (await cleanupUnstartedWindowsChild(child), { terminal: true }); + if (!result.terminal || child.native.handles.size) throw new Error(`owned fixture not reaped: pid=${child.pid} start=${child.creationTime} handles=${child.native.handles.size}`); + } + rmSync(directory, { recursive: true }); +}); + +describe("Windows child policy", () => { + test("installer/pack have no deadline, active-process cap, abort kill, or kill-on-close", () => { + for (const kind of ["installer", "pack"] as const) { + expect(windowsCliChildPolicy(kind)).toEqual({ captureBytes: kind === "pack" ? 1048576 : 0, + deadlineMs: null, reapMs: null, terminate: false }); + } + expect(windowsCliChildPolicy("version")).toEqual({ captureBytes: 16384, deadlineMs: 8000, reapMs: 1000, terminate: true }); + // Accounting is a count, not a bounded PID array. No 32-process fanout fixture. + const accounting = Buffer.alloc(WIN64_CHILD_ABI.jobAccounting); + accounting.writeUInt32LE(4096, WIN64_CHILD_ABI.activeProcesses); + expect(accounting.readUInt32LE(40)).toBe(4096); + }); +}); + +describe.skipIf(!windows)("Windows x64 actual owned native child", () => { + test("ABI layouts feed real native calls, preserve argv/UTF-8, original handles and exit 259", async () => { + expect(WIN64_CHILD_ABI).toMatchObject({ startupInfo: 104, startupInfoEx: 112, attributes: 104, + securityAttributes: 24, processInformation: 24, overlapped: 32, event: 24, jobAccounting: 48, activeProcesses: 40 }); + const args = ["space value", 'quoted"value', "trailing\\", "", "한글🙂", "a\\\\\"b"]; + const argv = launch(["args", ...args]); + expect(argv.setupFailure).toBeNull(); expect(BigInt(argv.creationTime!)).toBeGreaterThan(0n); + const extra = dlopen("kernel32.dll", { GetProcessId: { args: ["u64"], returns: "u32" } }); + expect(extra.symbols.GetProcessId!(argv.process)).toBe(argv.pid); extra.close(); + expect(JSON.parse((await finish(argv))!.stdout)).toEqual(args); + expect((await finish(launch(["unicode"])))!.stdout).toBe("한글🙂終"); + const exit = (await finish(launch(["exit259"])))!; + expect(exit.terminal).toBe(true); expect(exit.exitCode).toBe(259); + expect(argv.observe()).toEqual({ terminal: true, exitCode: 0, unknown: false }); + expect((await argv.wait()).terminal).toBe(true); expect(argv.reapUnstarted()).toBe(false); + }); + + test("Unicode environment is explicit, ordinally sorted and rejects case aliases before CreateProcess", async () => { + const native = new WindowsChildNative(); + const child = startWindowsCliChildWithNative({ ...invocation(["env", "Z", "a", "한글", "EMPTY", "MISSING"]), + env: { Z: "last", a: "first", "한글": "값🙂", EMPTY: "" } }, native); owners.push(child); + expect(JSON.parse((await finish(child))!.stdout)).toEqual(["last", "first", "값🙂", "", null]); + const marker = path(), invalid = startWindowsCliChildWithNative({ ...invocation(["marker", marker]), + env: { Path: "first", PATH: "second" } }, new WindowsChildNative()); owners.push(invalid); + expect(invalid.setupFailure).toBe("windows_child_invalid_environment"); expect(invalid.process).toBe(0n); + await finish(invalid); expect(existsSync(marker)).toBe(false); + }); + + test("real named pipe has a protected single-principal DACL, rejects remote clients and uses overlapped mode", async () => { + const native = new WindowsChildNative(), pipe = new WindowsChildPipe(native); + const extra = dlopen("advapi32.dll", { + GetSecurityDescriptorControl: { args: ["ptr", "ptr", "ptr"], returns: "i32" }, + GetSecurityDescriptorDacl: { args: ["ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + }); + const create = native.k.CreateNamedPipeW!; + native.k.CreateNamedPipeW = (...args) => { + expect(args[1]).toBe(0x40080001); expect(args[2]).toBe(8); expect(args[3]).toBe(1); + const sa = toBuffer(args[7]!, 0, 24), descriptor = sa.readBigUInt64LE(8); + expect(sa.readUInt32LE()).toBe(24); expect(sa.readUInt32LE(16)).toBe(0); expect(descriptor).not.toBe(0n); + const control = Buffer.alloc(2), revision = Buffer.alloc(4), present = Buffer.alloc(4), acl = Buffer.alloc(8), defaulted = Buffer.alloc(4); + expect(extra.symbols.GetSecurityDescriptorControl!(descriptor, ptr(control), ptr(revision))).toBe(1); + expect(control.readUInt16LE() & 0x1000).toBe(0x1000); // SE_DACL_PROTECTED + expect(extra.symbols.GetSecurityDescriptorDacl!(descriptor, ptr(present), ptr(acl), ptr(defaulted))).toBe(1); + expect(present.readUInt32LE()).toBe(1); expect(defaulted.readUInt32LE()).toBe(0); + expect(read.u16(acl.readBigUInt64LE(), 4)).toBe(1); // One allow ACE, not a default ACL. + return create(...args); + }; + try { pipe.open(); } finally { await pipe.dispose(); extra.close(); } + expect(native.handles.size).toBe(0); expect(native.dispose()).toBe(true); + }); + + test("two overlapping owned setups whitelist their own stdio and exclude a foreign inheritable file by identity", async () => { + const first = new WindowsChildNative(), second = new WindowsChildNative(); + const extra = dlopen("kernel32.dll", { GetHandleInformation: { args: ["u64", "ptr"], returns: "i32" } }); + const inheritance = (handle: bigint) => { + const flags = Buffer.alloc(4); + expect(extra.symbols.GetHandleInformation!(handle, ptr(flags))).toBe(1); + return flags.readUInt32LE() & 1; + }; + const sentinelPath = path(); writeFileSync(sentinelPath, "foreign-sentinel"); + const attributes = securityAttributes(true); + const sentinel = first.keep(first.k.CreateFileW!(ptr(wide(sentinelPath)), 0x80000000, 3, ptr(attributes), 3, 0, 0n)); + const sentinelName = queryWindowsFixtureObjectName(sentinel)!; expect(sentinelName).toBeString(); + const firstAudit = path(), secondAudit = path(); + const lists: bigint[][] = [[], []]; + for (const [index, native] of [first, second].entries()) { + const original = native.k.UpdateProcThreadAttribute!; + native.k.UpdateProcThreadAttribute = (...args) => { + expect(args[2]).toBe(0x20002n); expect(args[4]).toBe(24n); + lists[index] = [0, 8, 16].map(offset => read.u64(args[3]!, offset)); + return original(...args); + }; + const resume = native.k.ResumeThread!; + native.k.ResumeThread = thread => { expect(inheritance(BigInt(thread))).toBe(0); return resume(thread); }; + } + const createFirst = first.k.CreateProcessW!, createSecond = second.k.CreateProcessW!; + let other: WindowsCliChild | undefined; + first.k.CreateProcessW = (...firstArgs) => { + let firstResult = 0; + second.k.CreateProcessW = (...secondArgs) => { + // Both complete stdio sets are still inheritable here, before either CreateProcess. + const objects = lists.map(list => list.map(handle => ({ handle: handle.toString(), name: queryWindowsFixtureObjectName(handle)! }))); + expect(objects.flat().every(item => typeof item.name === "string")).toBe(true); + for (const [index, native] of [first, second].entries()) { + for (const handle of native.handles) { + expect(inheritance(handle)).toBe(handle === sentinel || lists[index]!.includes(handle) ? 1 : 0); + } + } + // Each pipe name and the sentinel file are unique objects. NUL names are + // deliberately not identity evidence for another invocation's NUL handles. + writeFileSync(firstAudit, JSON.stringify({ own: objects[0], foreign: [objects[1]![1], { handle: sentinel.toString(), name: sentinelName }] })); + writeFileSync(secondAudit, JSON.stringify({ own: objects[1], foreign: [objects[0]![1], { handle: sentinel.toString(), name: sentinelName }] })); + for (const args of [firstArgs, secondArgs]) { expect(args[4]).toBe(1); expect(Number(args[5]) & 0x80004).toBe(0x80004); } + firstResult = createFirst(...firstArgs); + return createSecond(...secondArgs); + }; + other = launch(["audit", secondAudit], "pack", second); + return firstResult; + }; + const child = launch(["audit", firstAudit], "pack", first); + first.close(sentinel); // Fixture-owned foreign handle, never in either child's whitelist. + for (const item of [child, other!]) { + expect(item.setupFailure).toBeNull(); + for (const handle of item.native.handles) expect(inheritance(handle)).toBe(0); + const result = JSON.parse((await finish(item))!.stdout); + expect(result.own).toEqual([true, true, true]); expect(result.foreign).toEqual([false, false]); + expect(result.standards).toEqual([true, true, true]); + } + extra.close(); + // Forward isolation only; foreign unrestricted CreateProcess(TRUE) is not claimed safe. + }); + + test("primary exit is nonterminal while a normally created stdout-free descendant remains in the Job", async () => { + const gate = path(), child = launch(["descendant", gate]); + try { + await until(() => existsSync(`${gate}.ready`)); + await until(() => Number(child.native.k.WaitForSingleObject!(child.process, 0)) === 0); + expect(child.observe()).toEqual({ terminal: false, exitCode: null, unknown: false }); + expect(child.native.handles.size).toBeGreaterThan(0); + } finally { writeFileSync(gate, "release"); } + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(`${gate}.done`)).toBe(true); + }); + + test("real incompatible nested Job assignment refuses before fixture code", async () => { + const native = new WindowsChildNative(), marker = path(); + const gate = path(), other = launch(["gate", gate]); + const outer = native.keep(native.k.CreateJobObjectW!(null, null)); + try { + const assign = native.k.AssignProcessToJobObject!; + native.k.AssignProcessToJobObject = (job, process) => { + expect(assign(outer, process)).toBe(1); + // Establish a different nonempty hierarchy. A process already in outer + // cannot join this nonempty Job outside its current Job hierarchy. + expect(assign(job, other.process)).toBe(1); + return assign(job, process); + }; + const child = launch(["marker", marker], "pack", native); + expect(child.setupFailure).toBe("job_assignment"); + native.close(outer); + writeFileSync(gate, "release"); await finish(other); + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(marker)).toBe(false); + } finally { native.close(outer); writeFileSync(gate, "release"); } + }); + + test("assignment/resume failures kill only the still-suspended owned primary before user code", async () => { + for (const stage of ["AssignProcessToJobObject", "ResumeThread"] as const) { + const native = new WindowsChildNative(), marker = path(); + if (stage === "AssignProcessToJobObject") native.k.AssignProcessToJobObject = () => 0; + else native.k.ResumeThread = () => 0xffffffff; + const child = launch(["marker", marker], "installer", native); + expect(child.setupFailure).toBe(stage === "ResumeThread" ? "resume_thread" : "job_assignment"); + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(marker)).toBe(false); + } + }); + + test("failed pre-user-code termination stays owned and unknown until exact handle reap", async () => { + const native = new WindowsChildNative(), marker = path(), terminate = native.k.TerminateProcess!; + native.k.ResumeThread = () => 0xffffffff; native.k.TerminateProcess = () => 0; + const child = launch(["marker", marker], "installer", native); + expect((await child.wait()).terminal).toBe(false); expect(native.handles.size).toBeGreaterThan(0); + native.k.TerminateProcess = terminate; expect(child.reapUnstarted()).toBe(true); + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(marker)).toBe(false); + }); + + test("pre-create setup failures release every handle exactly once and delete initialized attributes", async () => { + for (const stage of ["CreateNamedPipeW", "CreateEventW", "CreateFileW", "InitializeProcThreadAttributeList", "UpdateProcThreadAttribute", "CreateProcessW"] as const) { + const native = new WindowsChildNative(), deleted: number[] = [], closed = new Set(); + const keep = native.keep.bind(native); + native.keep = value => { const handle = keep(value); closed.delete(String(handle)); return handle; }; + const originalClose = native.k.CloseHandle!, originalDelete = native.k.DeleteProcThreadAttributeList!; + native.k.CloseHandle = handle => { expect(closed.has(String(handle))).toBe(false); closed.add(String(handle)); return originalClose(handle); }; + native.k.DeleteProcThreadAttributeList = pointer => { + expect(native.borrowedMemory.size).toBe(2); + deleted.push(1); return originalDelete(pointer); + }; + if (stage === "CreateNamedPipeW" || stage === "CreateEventW" || stage === "CreateFileW") native.k[stage] = (() => 0n) as never; + else if (stage === "InitializeProcThreadAttributeList") { + const initialize = native.k.InitializeProcThreadAttributeList!; + native.k.InitializeProcThreadAttributeList = (...args) => args[0] === null ? initialize(...args) : 0; + } else native.k[stage] = (() => 0) as never; + const child = launch(["marker", path()], "pack", native); + expect(child.process).toBe(0n); expect(child.setupFailure).toBeString(); + await finish(child); + expect(deleted.length).toBe(stage === "UpdateProcThreadAttribute" || stage === "CreateProcessW" ? 1 : 0); + } + }); + + test("pack byte cap drains/discards excess without termination; installer also has no kill policy", async () => { + for (const policy of ["pack", "installer"] as const) { + const native = new WindowsChildNative(); let kills = 0; + native.k.TerminateJobObject = () => { kills++; throw new Error("unexpected installer/pack termination"); }; + const result = (await finish(launch(["overflow", String(2 * 1024 * 1024)], policy, native)))!; + expect(result.terminal).toBe(true); expect(result.exitCode).toBe(0); expect(kills).toBe(0); + expect(result.stdout.length).toBe(policy === "pack" ? 1024 * 1024 : 0); + expect(result.overflow).toBe(policy === "pack"); + } + }); + + test("ordinary last Job handle close does not kill a running installer", async () => { + const gate = path(), child = launch(["gate", gate], "installer"); + await until(() => existsSync(`${gate}.ready`)); + child.native.close(child.job); // Deliberate own-fixture close, loses the helper's terminal proof. + expect(Number(child.native.k.WaitForSingleObject!(child.process, 0))).toBe(258); + expect(child.observe().unknown).toBe(true); + writeFileSync(gate, "release"); + await until(() => Number(child.native.k.WaitForSingleObject!(child.process, 0)) === 0); + expect(existsSync(`${gate}.done`)).toBe(true); + // The test intentionally destroyed the Job proof; exact own primary and I/O cleanup only. + await child.pipe.dispose(); child.native.close(child.process); expect(child.native.dispose()).toBe(true); + expect(child.native.handles.size).toBe(0); + }); + + test("WAIT_FAILED/query failure never counts as terminal; >32 active accounting is nonterminal", async () => { + for (const fault of ["wait", "query", "many"] as const) { + const native = new WindowsChildNative(), wait = native.k.WaitForSingleObject!, query = native.k.QueryInformationJobObject!; + const child = launch(["unicode"], "pack", native); + if (fault === "wait") native.k.WaitForSingleObject = () => 0xffffffff; + else if (fault === "query") native.k.QueryInformationJobObject = () => 0; + else native.k.QueryInformationJobObject = (job, kind, output, length, size) => { + const result = query(job, kind, output, length, size); + // FFI view is used only after a real native query succeeds. + if (result) toBuffer(output!, 0, 48).writeUInt32LE(4096, 40); + return result; + }; + expect(child.observe().terminal).toBe(false); + if (fault !== "many") expect((await child.wait()).terminal).toBe(false); + native.k.WaitForSingleObject = wait; native.k.QueryInformationJobObject = query; + expect((await finish(child))!.terminal).toBe(true); + } + }); + + test("read error is a failure, not lifetime proof, and overlapped cancellation settles before close", async () => { + const gate = path(), native = new WindowsChildNative(); + const extra = dlopen("kernel32.dll", { SetLastError: { args: ["u32"], returns: "void" } }); + const readFile = native.k.ReadFile!; + native.k.ReadFile = () => { extra.symbols.SetLastError!(31); return 0; }; + const child = launch(["gate", gate], "pack", native); + child.pipe.pump(() => {}); expect(child.pipe.error).toBe(true); expect(child.observe().terminal).toBe(false); + native.k.ReadFile = readFile; writeFileSync(gate, "release"); + expect((await finish(child))!.failed).toBe(true); extra.close(); + + const io = new WindowsChildNative(), pipe = new WindowsChildPipe(io); pipe.open(); + pipe.pump(() => {}); expect(pipe.pending).toBe(true); + const close = io.k.CloseHandle!, cancel = io.k.CancelIoEx!; let canceled = false; + io.k.CancelIoEx = (...args) => { canceled = true; return cancel(...args); }; + io.k.CloseHandle = handle => { + if (handle === pipe.handle || handle === pipe.event) expect(pipe.pending).toBe(false); + return close(handle); + }; + await pipe.dispose(); expect(canceled).toBe(true); expect(io.handles.size).toBe(0); expect(io.dispose()).toBe(true); + }); + + test("version overflow and failed termination remain unknown until actual Job drain", async () => { + const native = new WindowsChildNative(); let calls = 0; + native.k.TerminateJobObject = () => { calls++; return 0; }; + const child = launch(["overflow", "32768", "300"], "version", native); + const unknown = await child.wait(); + expect(unknown.terminal).toBe(false); expect(unknown.overflow).toBe(true); expect(calls).toBe(1); + expect(native.handles.size).toBeGreaterThan(0); + await until(() => child.observe().terminal); + const final = (await finish(child))!; expect(final.terminal).toBe(true); expect(final.failed).toBe(true); + }); + + test("version reap deadline is unknown even when termination was acknowledged", async () => { + const native = new WindowsChildNative(); let calls = 0; + // An acknowledged request is deliberately not applied by the native fault seam. + // The fixture itself exits, proving that acknowledgment is never terminal evidence. + native.k.TerminateJobObject = () => { calls++; return 1; }; + const child = launch(["overflow", "32768", "1500"], "version", native), started = Date.now(); + expect((await child.wait()).terminal).toBe(false); expect(calls).toBe(1); + expect(Date.now() - started).toBeGreaterThanOrEqual(1000); + expect(Number(native.k.WaitForSingleObject!(child.process, 0))).toBe(258); + await until(() => child.observe().terminal); + expect((await finish(child))!.terminal).toBe(true); + }); + + test("version 8 second deadline terminates only its owned Job and reaps within the separate policy", async () => { + const gate = path(), child = launch(["gate", gate], "version"), started = Date.now(); + const result = (await finish(child))!; + expect(result.terminal).toBe(true); expect(result.failed).toBe(true); expect(result.reason).toBe("deadline"); + expect(Date.now() - started).toBeGreaterThanOrEqual(7900); expect(Date.now() - started).toBeLessThan(10000); + expect(existsSync(`${gate}.done`)).toBe(false); + }, 12000); +}); diff --git a/tests/codex-integration/codex-cli-windows-installation-files.test.ts b/tests/codex-integration/codex-cli-windows-installation-files.test.ts index 2f8919591fc..4182e0d9c98 100644 --- a/tests/codex-integration/codex-cli-windows-installation-files.test.ts +++ b/tests/codex-integration/codex-cli-windows-installation-files.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, symlinkSync, writeFileSync, writeSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, symlinkSync, truncateSync, writeFileSync, writeSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,6 +10,7 @@ import { setWindowsInstallationFilesOpenedForTests, } from "../../src/codex/windows-installation-files"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { MAX_NATIVE_BACKING_BYTES } from "../../src/codex/cli-installation-limits"; const nativeTest = process.platform === "win32" && process.arch === "x64" ? test : test.skip; let fixture = ""; @@ -44,9 +46,11 @@ describe("explicit Windows installation file snapshot", () => { expect(await inspectWindowsInstallationFiles(Array.from({ length: 13 }, () => ({ path: "C:\\package.json", maxBytes: 10 })))) .toEqual({ kind: "refused", reason: "invalid-request" }); expect(await inspectWindowsInstallationFiles([ - { path: "C:\\node.exe", maxBytes: 256 * 1024 * 1024, hashOnly: true }, - { path: "C:\\other.exe", maxBytes: 256 * 1024 * 1024, hashOnly: true }, + { path: "C:\\native.exe", maxBytes: MAX_NATIVE_BACKING_BYTES, hashOnly: true }, + { path: "C:\\other.exe", maxBytes: MAX_NATIVE_BACKING_BYTES, hashOnly: true }, ])).toEqual({ kind: "refused", reason: "invalid-request" }); + expect(await inspectWindowsInstallationFiles([{ path: "C:\\native.exe", maxBytes: MAX_NATIVE_BACKING_BYTES + 1, hashOnly: true }])) + .toEqual({ kind: "refused", reason: "invalid-request" }); }); nativeTest("reports absent leaf and ancestor as not-found", async () => { @@ -162,6 +166,22 @@ describe("explicit Windows installation file snapshot", () => { expect(file.digest).toBe(""); }); + nativeTest("one byte over the native limit refuses before reads and releases its handles", async () => { + const path = join(fixture, "oversized-native.fixture"); + writeFileSync(path, ""); + expect(process.env.SystemRoot).toBeDefined(); + const sparse = spawnSync(join(process.env.SystemRoot!, "System32", "fsutil.exe"), ["sparse", "setflag", path], + { windowsHide: true, encoding: "utf8", timeout: 5_000 }); + expect(sparse.error).toBeUndefined(); expect(sparse.status).toBe(0); + truncateSync(path, MAX_NATIVE_BACKING_BYTES + 1); + let opened = 0; + setWindowsInstallationFilesOpenedForTests(() => { opened++; }); + expect(await inspectWindowsInstallationFiles([{ path, maxBytes: MAX_NATIVE_BACKING_BYTES, hashOnly: true }])) + .toEqual({ kind: "refused", reason: "size-limit" }); + expect(opened).toBe(0); + renameSync(path, path + ".released"); + }); + nativeTest("prefixOnly returns a bounded prefix of an oversized file without a digest", async () => { const path = join(fixture, "wrapper.cmd"); const prefix = Buffer.from("@echo off\r\nrem marker\r\n"); diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts new file mode 100644 index 00000000000..551d3bd0853 --- /dev/null +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -0,0 +1,367 @@ +import { afterEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { inspectCodexCliInstall, MAX_NATIVE_BACKING_BYTES } from "../../src/codex/cli-install-provenance"; +import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { runCodexCliUpdatePlanInWorker } from "../../src/codex/cli-update-job-worker"; +import { withWindowsCodexCliPlanToolchain } from "../../src/codex/cli-windows-plan-provenance"; +import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { setWindowsInstallationFilesOpenedForTests, setWindowsInstallationFilesReadForTests } from "../../src/codex/windows-installation-files"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; +import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; + +const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; +const roots: string[] = []; +const liveWorkerRoots = new Set(); +afterEach(() => { + initializeNodeLauncherContext(["bun", "cli"], {}); + setWindowsInstallationFilesOpenedForTests(); + setWindowsInstallationFilesReadForTests(); + for (const root of roots.splice(0)) { + // A test timeout does not cancel its async body. Keep even that body's + // fixture owned until a real Worker close, never remove beneath native I/O. + if (liveWorkerRoots.has(root)) { roots.push(root); continue; } + removeTreeWithRetry(root); + } +}); +// npm's independent cmd-shim fixture; no candidate is ever executed. +const cmd = ['@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ':start', 'SETLOCAL', + 'CALL :find_dp0', '', 'IF EXIST "%dp0%\\node.exe" (', ' SET "_prog=%dp0%\\node.exe"', ') ELSE (', + ' SET "_prog=node"', ' SET PATHEXT=%PATHEXT:;.JS;=;%', ')', '', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*', +].join('\r\n'); +function fixture(separateNodeToolchain = false) { + const root = mkdtempSync(join(tmpdir(), "ocx-windows-plan-")); roots.push(root); + const prefix = join(root, "npm"); + const toolchainPrefix = separateNodeToolchain ? join(root, "nodejs") : prefix; + const npmRoot = join(toolchainPrefix, "node_modules", "npm"); + const packageRoot = join(prefix, "node_modules", "@openai", "codex"); + // Closest Node lookup lane: there is no earlier ancestor package to shadow it. + const backing = join(packageRoot, "bin", "node_modules", "@openai", "codex-win32-x64"); + const files = new Map([ + [join(prefix, "codex.cmd"), cmd], + [join(prefix, "codex.opencodex-real.cmd"), "stale unselected shim backup"], + [join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "1.2.3", bin: { codex: "bin/codex.js" } })], + [join(packageRoot, "bin", "codex.js"), 'throw new Error("Codex must never execute");'], + [join(backing, "package.json"), JSON.stringify({ name: "@openai/codex", version: "1.2.3-win32-x64" })], + [join(backing, "vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe"), "synthetic backing bytes"], + [join(npmRoot, "package.json"), JSON.stringify({ name: "npm", version: "11.0.0", bin: { npm: "bin/npm-cli.js" } })], + [join(npmRoot, "bin", "npm-cli.js"), 'throw new Error("npm must never execute");'], + ]); + const pe = Buffer.alloc(128); pe.write('MZ'); pe.writeUInt32LE(64, 60); pe.write('PE\0\0', 64); pe.writeUInt16LE(0x8664, 68); + files.set(join(toolchainPrefix, "node.exe"), pe); + for (const [path, value] of files) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, value); } + const deps = { env: { CODEX_CLI_PATH: join(prefix, "codex.cmd"), PATH: toolchainPrefix, PATHEXT: ".EXE;.CMD" }, + configDir: join(root, "absent-home"), + selectionAttested: true, arch: "x64" as const, observeInstallContent: true }; + return { root, prefix, toolchainPrefix, npmRoot, packageRoot, backing, files, deps }; +} + +native("production Windows inspector binds selected npm shim, package, backing and toolchain with no candidate execution", async () => { + const f = fixture(); const before = [...f.files].map(([path]) => readFileSync(path)); + const report = await inspectCodexCliInstall(f.deps); + expect(report).toMatchObject({ managed: true, selectionAttested: true, provenance: "npm-global", reason: "managed_npm_global", packageVersion: "1.2.3" }); + expect(report.installContentDigest).toMatch(/^[a-f0-9]{64}$/); + expect((await inspectCodexCliInstall(f.deps)).installContentDigest).toBe(report.installContentDigest); + expect(JSON.stringify(report)).not.toContain(f.root); + expect([...f.files].map(([path]) => readFileSync(path))).toEqual(before); +}); + +native("a bare Windows check honors the bounded content-observation opt-in", async () => { + const f = fixture(); + // Production check context: selectionAttested without observeInstallContent. + const deps = { env: f.deps.env, configDir: f.deps.configDir, + selectionAttested: f.deps.selectionAttested, arch: f.deps.arch }; + const reads: { path: string; bytes: number }[] = []; + setWindowsInstallationFilesReadForTests((path, bytes) => reads.push({ path, bytes })); + const report = await inspectCodexCliInstall(deps); + expect(report).toMatchObject({ managed: true, selectionAttested: true, provenance: "npm-global", + reason: "managed_npm_global", packageVersion: "1.2.3", installContentDigest: null }); + expect(report.evidence).not.toContain("install_content_digest"); + // The same files stay opened, held and identity-bound; only the content streams stop. + const bytesFor = (suffix: string) => reads.filter(read => read.path.endsWith(suffix)).map(read => read.bytes); + expect(bytesFor("codex.exe")).toEqual([0]); + for (const suffix of ["codex.js", "npm-cli.js"]) { + const fileReads = bytesFor(suffix); + expect(fileReads.length).toBeGreaterThan(0); + expect(fileReads.every(bytes => bytes === 0)).toBe(true); + } + const nodeReads = bytesFor("node.exe"); + expect(nodeReads.length).toBeGreaterThan(0); + expect(nodeReads.filter(bytes => bytes > 0)).toEqual([128]); + // Only bounded ownership reads remain: nothing exceeds the 4 KiB Node header bound. + expect(Math.max(0, ...reads.map(read => read.bytes))).toBeLessThanOrEqual(4096); + expect(reads.some(read => read.path.endsWith("codex.cmd") && read.bytes > 0)).toBe(true); +}); + +native("a production plan is deterministic while Windows apply stays explicitly disabled", async () => { + const f = fixture(); + const deps = { inspectionDeps: f.deps, resolveTarget: () => ({ kind: "resolved" as const, version: "1.2.4", integrity: "sha512-" + Buffer.alloc(64, 1).toString("base64") }), + scanProcesses: () => ({ kind: "observed" as const, processes: [] }) }; + const first = await createCodexCliUpdatePlan(deps); + expect(first).toMatchObject({ managed: true, applicable: false, refusal: "windows_apply_disabled", installedVersion: "1.2.3", targetVersion: "1.2.4" }); + expect(first.planId).toMatch(/^[a-f0-9]{32}$/); + expect((await createCodexCliUpdatePlan(deps)).planId).toBe(first.planId); + writeFileSync(join(f.backing, "vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe"), "changed backing bytes"); + expect((await createCodexCliUpdatePlan(deps)).planId).not.toBe(first.planId); +}); + +native("a Windows plan id with applicable=false cannot enter a management apply job", async () => { + const f = fixture(); + const deps = { inspectionDeps: f.deps, + resolveTarget: () => ({ kind: "resolved" as const, version: "1.2.4", integrity: "sha512-" + Buffer.alloc(64, 1).toString("base64") }), + scanProcesses: () => ({ kind: "observed" as const, processes: [] }) }; + const plan = await createCodexCliUpdatePlan(deps); + expect(plan.planId).toMatch(/^[a-f0-9]{32}$/); + expect(plan).toMatchObject({ applicable: false, refusal: "windows_apply_disabled" }); + + const oldHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = f.deps.configDir; + try { + const request = new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "windows-nonapplicable-plan-001", planId: plan.planId, confirmed: true }) }); + const ctx: ManagementContext = { req: request, url: new URL(request.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: "unsupported_store" }); + + let leaseAttempts = 0, installAttempts = 0; + const apply = await applyCodexCliUpdate(plan.planId!, { platform: "win32", inspectionDeps: f.deps, + acquireLease: () => { leaseAttempts += 1; return null; }, + install: async () => { installAttempts += 1; return { terminal: true, exitCode: 0 }; } }); + expect(apply).toMatchObject({ outcome: "refused", reason: "windows_apply_disabled" }); + expect(leaseAttempts).toBe(0); + expect(installAttempts).toBe(0); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + } +}); + +native("a published-size Windows npm backing binds; one past the shared finite limit refuses", async () => { + const f = fixture(); + const binary = join(f.backing, "vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe"); + // An NTFS sparse file has the installed 0.153.4-win32-x64 backing's logical + // size without copying its bytes or allocating a dense 295 MB fixture. + writeFileSync(binary, ""); + expect(process.env.SystemRoot).toBeDefined(); + const sparse = spawnSync(join(process.env.SystemRoot!, "System32", "fsutil.exe"), ["sparse", "setflag", binary], + { windowsHide: true, encoding: "utf8", timeout: 5_000 }); + expect(sparse.error).toBeUndefined(); expect(sparse.status).toBe(0); + truncateSync(binary, 295_408_944); + const report = await inspectCodexCliInstall(f.deps); + expect(report.managed).toBe(true); expect(report.installContentDigest).toMatch(/^[a-f0-9]{64}$/); + truncateSync(binary, MAX_NATIVE_BACKING_BYTES + 1); + expect((await inspectCodexCliInstall(f.deps)).managed).toBe(false); +}, 30_000); + +native("unattested, mismatched selection, app/Store/version-manager and unverified launchers remain unmanaged", async () => { + const f = fixture(); + for (const deps of [ { ...f.deps, selectionAttested: false }, { ...f.deps, selectedCommand: join(f.root, "other.cmd") }, + ...["WindowsApps", "OpenAI/Codex/bin/app", ".nvm", "standalone"].map(dir => ({ ...f.deps, + env: { ...f.deps.env, CODEX_CLI_PATH: join(f.root, dir, "codex.exe") } })), + ]) expect((await inspectCodexCliInstall(deps)).managed).toBe(false); + writeFileSync(join(f.prefix, "codex.cmd"), cmd + "\r\necho retargeted"); + expect((await inspectCodexCliInstall(f.deps)).managed).toBe(false); +}); + +native("a junction prefix is refused and prefix retarget attempts fail throughout the coherent observation", async () => { + const f = fixture(); const alias = join(f.root, "alias"); symlinkSync(f.prefix, alias, "junction"); + expect((await inspectCodexCliInstall({ ...f.deps, env: { ...f.deps.env, CODEX_CLI_PATH: join(alias, "codex.cmd") } })).managed).toBe(false); + let blocked = 0; + setWindowsInstallationFilesOpenedForTests(() => { try { renameSync(f.prefix, f.prefix + "-moved"); } catch { blocked++; } }); + expect((await inspectCodexCliInstall(f.deps)).managed).toBe(true); + expect(blocked).toBeGreaterThan(0); +}); + +native("a selected override changing during observation refuses the original prefix", async () => { + const f = fixture(); + setWindowsInstallationFilesOpenedForTests(() => { f.deps.env.CODEX_CLI_PATH = join(f.root, "other", "codex.cmd"); }); + expect((await inspectCodexCliInstall(f.deps)).managed).toBe(false); +}); + +native("npm toolchain drift after inspection refuses metadata without executing the replacement", async () => { + const f = fixture(); let queries = 0; + const plan = await createCodexCliUpdatePlan({ inspectionDeps: f.deps, inspect: async deps => { + const report = await inspectCodexCliInstall(deps); expect(report.managed).toBe(true); + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), "throw new Error('replacement');"); + return report; + }, resolveTarget: () => { queries++; throw new Error("must not query changed toolchain"); } }); + expect(queries).toBe(0); expect(plan.refusal).toBe("target_unresolved"); +}); + +native("the observed toolchain denies replacement until an asynchronous resolver settles", async () => { + const f = fixture(); const report = await inspectCodexCliInstall(f.deps); + expect(report.managed).toBe(true); + const node = join(f.toolchainPrefix, "node.exe"); + const npmCli = join(f.npmRoot, "bin", "npm-cli.js"); + const moved = f.prefix + "-moved"; + let enter!: () => void, release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + let settled = false; + const held = withWindowsCodexCliPlanToolchain(report, async () => { + enter(); await gate; return "resolved"; + }).then(value => { settled = true; return value; }); + let result: string | null; + try { + await Promise.race([entered, held.then(() => { throw new Error("toolchain resolver was not entered"); })]); + // A sync finalize returning an unawaited Promise can close its handles in a + // microtask; cross an event-loop turn before checking the still-pending gate. + await new Promise(resolve => { setImmediate(resolve); }); + expect(settled).toBe(false); + for (const path of [node, npmCli]) expect(() => writeFileSync(path, "replacement")).toThrow(); + expect(() => renameSync(f.prefix, moved)).toThrow(); + } finally { + release(); result = await held; + } + expect(result).toBe("resolved"); + expect(settled).toBe(true); + for (const path of [node, npmCli]) { + writeFileSync(path, "replacement"); + expect(readFileSync(path, "utf8")).toBe("replacement"); + } + renameSync(f.prefix, moved); + expect(readFileSync(join(moved, "node_modules", "npm", "bin", "npm-cli.js"), "utf8")).toBe("replacement"); +}); + +native("a separate PATH Node is refused without proof of the target cmd-shim's search result", async () => { + const f = fixture(true); + expect((await inspectCodexCliInstall({ ...f.deps, selectionAttested: false })).managed).toBe(false); + const report = await inspectCodexCliInstall(f.deps); + expect(report).toMatchObject({ managed: false, installContentDigest: null }); + let targetQueries = 0; + const plan = await createCodexCliUpdatePlan({ inspectionDeps: f.deps, + resolveTarget: () => { targetQueries++; throw new Error("unproven Node must not query registry"); } }); + expect(targetQueries).toBe(0); + expect(plan.managed).toBe(false); + expect(plan.refusal).toBe("windows_inspection_deferred"); + expect(JSON.stringify(plan)).not.toContain(f.root); +}); + +native("synthetic CMD lookup selects a current-directory node.cmd ahead of PATH", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-cmd-search-")); roots.push(root); + const current = join(root, "current"); const pathDir = join(root, "path"); + mkdirSync(current); mkdirSync(pathDir); + writeFileSync(join(current, "node.cmd"), "@echo off\r\necho fixture-current-directory\r\n"); + writeFileSync(join(pathDir, "node.cmd"), "@echo off\r\necho fixture-path\r\n"); + const systemRoot = process.env.SystemRoot!; + const cmdExe = join(systemRoot, "System32", "cmd.exe"); + const run = spawnSync(cmdExe, ["/d", "/c", "node"], { cwd: current, encoding: "utf8", windowsHide: true, + timeout: 5_000, env: { SystemRoot: systemRoot, COMSPEC: cmdExe, PATH: pathDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD" } }); + expect(run.error).toBeUndefined(); + expect(run.status).toBe(0); + expect(run.stdout).toContain("fixture-current-directory"); + expect(run.stdout).not.toContain("fixture-path"); +}); + +native("a refused or reparse prefix-local Node is not treated as absent for PATH fallback", async () => { + const f = fixture(true); + mkdirSync(join(f.prefix, "node.exe")); + const report = await inspectCodexCliInstall(f.deps); + expect(report.managed).toBe(false); + expect(report.installContentDigest).toBeNull(); + + const g = fixture(true); + const target = join(g.root, "node-target"); mkdirSync(target); + symlinkSync(target, join(g.prefix, "node.exe"), "junction"); + const reparse = await inspectCodexCliInstall(g.deps); + expect(reparse.managed).toBe(false); + expect(reparse.installContentDigest).toBeNull(); +}); + +native("actual CLI handler reaches production inspector and plan from the proof-bound selection", async () => { + const f = fixture(); const proof = "W".repeat(43); + initializeNodeLauncherContext(["bun", "cli", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], { + [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ version: 1, proof, anthropicEnvSlots: [], + codexCliInspectionEnv: { codexCliPath: f.deps.env.CODEX_CLI_PATH, path: f.prefix, + pathExt: ".EXE;.CMD", managerRoots: {}, configDir: f.deps.configDir, arch: "x64" } }), + }); + let reached = false; + expect(await handleCodexCliUpdateCommand(["plan", "--json"], { createPlan: async deps => { + const plan = await createCodexCliUpdatePlan({ ...deps, + resolveTarget: () => ({ kind: "resolved", version: "1.2.4", integrity: "sha512-" + Buffer.alloc(64, 1).toString("base64") }), + scanProcesses: () => ({ kind: "observed", processes: [] }), + }); + expect(plan.managed).toBe(true); expect(plan.planId).toMatch(/^[a-f0-9]{32}$/); + expect(plan.refusal).toBe("windows_apply_disabled"); reached = true; return plan; + } })).toBe(0); + expect(reached).toBe(true); +}); + +native("a fresh production Worker rebuilds private Windows plan evidence before reading fixture metadata", async () => { + const f = fixture(); const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); + copyFileSync(hostNode!, join(f.toolchainPrefix, "node.exe")); + const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; + const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, + tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; + // Only this owned npm protocol fixture runs; Codex and real npm never execute. + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), + `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); + let starts = 0, worker: Worker | undefined, closed = false; + try { + const plan = await runCodexCliUpdatePlanInWorker({ + snapshot: { codexCliPath: f.deps.env.CODEX_CLI_PATH, path: f.prefix, pathExt: ".EXE;.CMD", + home: f.root, nodePath: null, managerRoots: {}, transportEnv: {}, configDir: f.deps.configDir, arch: "x64" }, + spawnWorkerFn: (url, key) => { + expect(key).toBe("cli-update-worker"); starts++; + // Pin this integration to current source, not a leftover generated bundle. + worker = new Worker(url, { type: "module" }); + liveWorkerRoots.add(f.root); + worker.addEventListener("close", () => { closed = true; liveWorkerRoots.delete(f.root); }, { once: true }); + return worker; + }, + }); + expect(starts).toBe(1); + expect(closed).toBe(true); + expect(plan).toMatchObject({ managed: true, installedVersion: "1.2.3", targetVersion: "1.2.4", + targetIntegrity: integrity, applicable: false }); + expect(plan.installContentDigest).toMatch(/^[a-f0-9]{64}$/); + // Real process observation may find active/unreadable sessions. Either refusal + // is after private-evidence admission and successful fixture metadata resolution. + expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + expect(JSON.stringify(plan)).not.toContain(f.root); + } finally { + // afterEach retains this fixture even if the test times out before this + // finally is reached. No terminate() or fabricated terminal evidence. + if (worker && !closed) liveWorkerRoots.add(f.root); + } +}, 60_000); + +native("published Node CLI uses the production inspector and observed toolchain for read-only metadata", () => { + const f = fixture(); const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); + const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); + const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; + const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, + tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; + // Controlled registry protocol fixture; no real npm installation or network request. + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), + `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); + const run = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: repoRoot(), encoding: "utf8", windowsHide: true, timeout: 45_000, + env: { ...process.env, CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, + OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, + }); + expect(run.error).toBeUndefined(); expect(run.status).toBe(0); + const plan = JSON.parse(run.stdout); + expect(plan.managed).toBe(true); expect(plan.targetVersion).toBe("1.2.4"); + expect(plan.targetIntegrity).toBe(integrity); + expect(plan.applicable).toBe(false); + expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + expect(JSON.stringify(plan)).not.toContain(f.root); +}, 60_000); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 32e61e52725..07276bc003d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,10 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "service-proxy-env.test.ts": "service", "bridge-terminal-refusal.test.ts": "adapters", + "codex-cli-windows-child.test.ts": "codex-integration", + "codex-cli-update-artifacts.test.ts": "codex-integration", + "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-worker.test.ts": "codex-integration", "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses", @@ -1402,6 +1406,7 @@ "management-desktop-compatibility-runtime-routes.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "optional-desktop-upstream.test.ts": "lib", + "codex-cli-windows-plan.test.ts": "codex-integration", "windows-native-principal.test.ts": "windows", "opaque-proxy-tunnel.test.ts": "lib", "opaque-proxy-tunnel-protocol.test.ts": "lib", diff --git a/tests/fixtures/windows-cli-child-object-name.ts b/tests/fixtures/windows-cli-child-object-name.ts new file mode 100644 index 00000000000..8637a22ec59 --- /dev/null +++ b/tests/fixtures/windows-cli-child-object-name.ts @@ -0,0 +1,17 @@ +import { dlopen, ptr, read } from "bun:ffi"; + +/** Object identity, not merely a valid/reused numeric HANDLE in a different process. */ +export function queryWindowsFixtureObjectName(handle: bigint): string | null { + const nt = dlopen("ntdll.dll", { + NtQueryObject: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + }); + try { + const bytes = Buffer.alloc(65536), size = Buffer.alloc(4); + if (Number(nt.symbols.NtQueryObject!(handle, 1, ptr(bytes), bytes.length, ptr(size))) !== 0) return null; + const length = bytes.readUInt16LE(), address = bytes.readBigUInt64LE(8); + if (!length || length > 32768 || !address) return null; + let result = ""; + for (let offset = 0; offset < length; offset += 2) result += String.fromCharCode(read.u16(address, offset)); + return result; + } finally { nt.close(); } +} diff --git a/tests/fixtures/windows-cli-child.ts b/tests/fixtures/windows-cli-child.ts new file mode 100644 index 00000000000..e748c67a9c5 --- /dev/null +++ b/tests/fixtures/windows-cli-child.ts @@ -0,0 +1,57 @@ +// Actual owned native fixture; never imports or starts the proxy or an installed Codex. +import { dlopen, ptr } from "bun:ffi"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { queryWindowsFixtureObjectName } from "./windows-cli-child-object-name"; + +const [mode, path] = process.argv.slice(2); +switch (mode) { + case "args": process.stdout.write(JSON.stringify(process.argv.slice(3))); break; + case "env": process.stdout.write(JSON.stringify(process.argv.slice(3).map(key => process.env[key] ?? null))); break; + case "unicode": { + const bytes = Buffer.from("한글🙂終"); + for (const byte of bytes) { process.stdout.write(Buffer.from([byte])); await Bun.sleep(2); } + break; + } + case "overflow": { + const bytes = Buffer.alloc(Number(path), 120); + await Bun.write(Bun.stdout, bytes); + if (process.argv[4]) await Bun.sleep(Number(process.argv[4])); + break; + } + case "exit259": { + const { symbols } = dlopen("kernel32.dll", { ExitProcess: { args: ["u32"], returns: "void" } }); + symbols.ExitProcess!(259); break; + } + case "marker": writeFileSync(path!, "user-code-ran"); break; + case "gate": { + writeFileSync(`${path}.ready`, "ready"); + const deadline = Date.now() + 15000; + while (!existsSync(path!) && Date.now() < deadline) await Bun.sleep(5); + writeFileSync(`${path}.done`, "done"); break; + } + case "descendant": { + // Normal CreateProcess descendants inherit the helper's Job. No Bun runtime + // subprocess finalizer/Job can alter the lifetime being exercised here. + const k = dlopen("kernel32.dll", { + CreateProcessW: { args: ["ptr", "ptr", "ptr", "ptr", "i32", "u32", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + CloseHandle: { args: ["u64"], returns: "i32" }, ExitProcess: { args: ["u32"], returns: "void" }, + }).symbols; + const wide = (text: string) => Buffer.from(text + "\0", "utf16le"); + const quote = (arg: string) => '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"'; + const exe = wide(process.execPath), command = wide([process.execPath, import.meta.path, "gate", path!].map(quote).join(" ")); + const startup = Buffer.alloc(104), info = Buffer.alloc(24); startup.writeUInt32LE(104); + if (!k.CreateProcessW!(ptr(exe), ptr(command), null, null, 0, 0x08000000, null, null, ptr(startup), ptr(info))) throw new Error("fixture descendant spawn"); + writeFileSync(`${path}.primary`, JSON.stringify({ pid: info.readUInt32LE(16) })); + k.CloseHandle!(info.readBigUInt64LE()); k.CloseHandle!(info.readBigUInt64LE(8)); + k.ExitProcess!(0); break; + } + case "audit": { + const input = JSON.parse(readFileSync(path!, "utf8")) as { own: { handle: string; name: string }[]; foreign: { handle: string; name: string }[] }; + const own = input.own.map(item => queryWindowsFixtureObjectName(BigInt(item.handle)) === item.name); + const foreign = input.foreign.map(item => queryWindowsFixtureObjectName(BigInt(item.handle)) === item.name); + const k = dlopen("kernel32.dll", { GetStdHandle: { args: ["i32"], returns: "u64" } }); + const standards = input.own.map((item, index) => BigInt(k.symbols.GetStdHandle!(-10 - index)) === BigInt(item.handle)); + process.stdout.write(JSON.stringify({ own, foreign, standards })); k.close(); break; + } + default: throw new Error(`unexpected fixture mode ${mode}`); +} diff --git a/tests/helpers/cli-update-fixture-worker.ts b/tests/helpers/cli-update-fixture-worker.ts new file mode 100644 index 00000000000..9e361dd13c0 --- /dev/null +++ b/tests/helpers/cli-update-fixture-worker.ts @@ -0,0 +1,146 @@ +/** + * Test fixture only. No provider, installer, or production worker entry runs. + * Configure before the host's RunMsg via postMessage; Worker FIFO preserves this order. + * Every blocking fixture must be released through its SAB in the test's finally block. + */ +export const FIXTURE_ENTERED = 0; +export const FIXTURE_RELEASE = 1; +export const FIXTURE_RUNS = 2; +export const FIXTURE_REPLIES = 3; +export const FIXTURE_FINISHED = 4; +export const FIXTURE_COMMIT_RESULT = 5; +export const FIXTURE_CONTROL_WORDS = 6; + +export interface FixtureConfiguration { + type: "fixture-config"; + control: SharedArrayBuffer; + block?: "before-reply" | "after-reply"; + reply?: "refused" | "ambiguous" | "none" | "malformed" | "error" | "throw" | "die"; + correlation?: "matching" | "stale-first" | "stale-only" | "duplicate"; + staleField?: "requestId" | "jobId" | "both"; + /** Uses only the frozen 0/1/2 CAS protocol, not an invented production export. */ + commitFenceBeforeReply?: boolean; + /** Real Bun child waits for a test-owned release file, not a simulated SAB child. */ + childDrain?: { releasePath: string }; +} +interface RunMessage { + type: "run"; + requestId: string; + jobId: string; + operation: "plan" | "apply"; + planId?: string; + channel?: unknown; + snapshot: unknown; + fence: SharedArrayBuffer; + gate: SharedArrayBuffer; + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +} +type ControlMessage = FixtureConfiguration | { type: "fixture-close" }; +type WorkerScope = { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(value: unknown): void; +}; +const scope = globalThis as unknown as WorkerScope; + +if (typeof Bun !== "undefined" && Bun.isMainThread === false + && typeof self !== "undefined" && typeof scope.postMessage === "function") { + let configuration: FixtureConfiguration | undefined; + let running = false; + const send = (view: Int32Array, value: unknown) => { + Atomics.add(view, FIXTURE_REPLIES, 1); + scope.postMessage(value); + }; + const barrier = (view: Int32Array) => { + Atomics.store(view, FIXTURE_ENTERED, 1); + Atomics.notify(view, FIXTURE_ENTERED); + while (Atomics.load(view, FIXTURE_RELEASE) === 0) { + Atomics.wait(view, FIXTURE_RELEASE, 0); + } + }; + scope.onmessage = async event => { + const message = event.data; + if (message.type === "fixture-config") { + if (configuration || running) throw new Error("fixture configured twice"); + configuration = message; + return; + } + if (message.type === "fixture-close") { + scope.onmessage = null; + return; + } + if (message.type !== "run") return; + if (!configuration) throw new Error("fixture must be configured before run"); + const config = configuration, view = new Int32Array(config.control); + Atomics.add(view, FIXTURE_RUNS, 1); + if (running) return; // Match production run-once: never execute a late second run. + running = true; + let child: ReturnType | undefined; + try { + if (config.childDrain) { + child = Bun.spawn([process.execPath, "--no-env-file", "--no-install", "-e", + 'const {existsSync}=await import("node:fs");console.log("ready");const timer=setInterval(()=>{if(existsSync(process.argv[1]))clearInterval(timer)},5);', + config.childDrain.releasePath], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }); + const reader = (child.stdout as ReadableStream).getReader(); + const ready = await reader.read(); reader.releaseLock(); + if (new TextDecoder().decode(ready.value).trim() !== "ready") throw new Error("fixture child not ready"); + scope.postMessage({ type: "fixture-child-started", pid: child.pid }); + } + if (config.block === "before-reply") barrier(view); + if (config.commitFenceBeforeReply) { + // Commit cannot overwrite a parent cancellation that has already won. + const observed = Atomics.compareExchange(new Int32Array(message.fence), 0, 0, 2); + Atomics.store(view, FIXTURE_COMMIT_RESULT, observed); + } + if (config.reply === "throw") throw new Error("fixture-worker-error"); + if (config.reply === "die") return; + if (config.reply === "none") return; + const envelope = { + requestId: message.requestId, jobId: message.jobId, operation: message.operation, + }; + if (config.reply === "error") { + send(view, { type: "error", ...envelope, message: "fixture-error" }); + return; + } + if (config.reply === "malformed") { + send(view, { type: "done", ...envelope, result: { outcome: "invented" } }); + return; + } + // A plan test must supply a confirmed frozen plan payload after API handoff. + if (message.operation !== "apply") throw new Error("fixture plan payload not configured"); + const ambiguous = config.reply === "ambiguous"; + const result = { + outcome: ambiguous ? "ambiguous" : "refused", + reason: ambiguous ? "installer_state_unknown" : "preparation_failed", + planId: message.planId!, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: ambiguous, verificationPid: ambiguous ? 424242 : null, + cleanupErrors: [], + }; + if (config.correlation === "stale-first" || config.correlation === "stale-only") { + send(view, { type: "done", ...envelope, + requestId: config.staleField === "jobId" ? message.requestId : "stale-" + message.requestId, + jobId: config.staleField === "requestId" ? message.jobId : "stale-" + message.jobId, + result: { ...result, outcome: "applied", reason: "verified_target" } }); + } + if (config.correlation === "stale-only") return; + send(view, { type: "done", ...envelope, result }); + if (config.correlation === "duplicate") { + // Conflicting late duplicate must not replace the first correlated result. + send(view, { type: "done", ...envelope, + result: { ...result, outcome: "applied", reason: "verified_target" } }); + send(view, { type: "error", ...envelope, message: "late-duplicate-error" }); + } + if (config.block === "after-reply") barrier(view); + } finally { + Atomics.store(view, FIXTURE_FINISHED, 1); + Atomics.notify(view, FIXTURE_FINISHED); + // "none"/"die" deliberately close without replying; after-reply blocks until released. + scope.onmessage = null; + if (child) { + const exitCode = await child.exited; + scope.postMessage({ type: "fixture-child-exited", pid: child.pid, exitCode }); + } + } + }; +} + diff --git a/tests/helpers/codex-cli-apply-artifact.ts b/tests/helpers/codex-cli-apply-artifact.ts new file mode 100644 index 00000000000..ac6fde0e862 --- /dev/null +++ b/tests/helpers/codex-cli-apply-artifact.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import type { CodexCliArtifact, CodexCliArtifactSet } from "../../src/codex/cli-update-artifacts"; +import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; + +export function fixtureTar(files: Record): Buffer { + const parts: Buffer[] = []; + for (const [name, value] of Object.entries(files)) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); + const header = Buffer.alloc(512); + header.write("package/" + name); + const octal = (offset: number, length: number, number: number) => header.write(number.toString(8).padStart(length - 1, "0") + "\0", offset, length); + octal(100, 8, 0o755); octal(108, 8, 0); octal(116, 8, 0); + octal(124, 12, bytes.length); octal(136, 12, 0); + header.fill(32, 148, 156); header.write("0", 156); header.write("ustar\0", 257); + octal(148, 8, header.reduce((sum, byte) => sum + byte, 0)); + parts.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + parts.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(parts)); +} +export function fixtureArtifact(bytes: Buffer, version = "1.1.0", installName = "@openai/codex", name = "@openai/codex"): CodexCliArtifact { + return { name, installName, version, integrity: "sha512-" + createHash("sha512").update(bytes).digest("base64"), + tarball: `https://registry.npmjs.org/@openai/codex/-/codex-${version}.tgz` }; +} +export function fixtureArtifactSet(platform: NodeJS.Platform = "linux", arch = "x64", version = "1.1.0") { + const layout = codexCliPlatformBacking(platform, arch)!; + const variant = `${version}-${layout.package.slice("@openai/codex-".length)}`; + const mainManifest = { name: "@openai/codex", version, bin: { codex: "bin/codex.js" }, + optionalDependencies: Object.fromEntries(["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"] + .map(suffix => [`@openai/codex-${suffix}`, `npm:@openai/codex@${version}-${suffix}`])) }; + const mainFiles = { "package.json": JSON.stringify(mainManifest) + "\n", "bin/codex.js": [ + "#!/usr/bin/env node", "// Synthetic fixture: resolve the installed platform alias like the published launcher.", + `const root = require('node:path').dirname(require.resolve('${layout.package}/package.json'));`, + `const native = require('node:path').join(root, 'vendor/${layout.triple}/bin/codex');`, + "const child = require('node:child_process').spawnSync(native, process.argv.slice(2), {stdio:'inherit'});", + "process.exit(child.status ?? 1);", "", + ].join("\n") }; + const backingFiles = { "package.json": JSON.stringify({ name: "@openai/codex", version: variant }) + "\n", + [`vendor/${layout.triple}/bin/codex`]: `#!/bin/sh\n# synthetic backing\n[ "$1" = "--version" ] || exit 2\nprintf 'codex-cli ${version}\\n'\n` }; + const main = fixtureTar(mainFiles), backing = fixtureTar(backingFiles); + const artifacts: CodexCliArtifactSet = { platform, arch, main: fixtureArtifact(main, version), + backing: fixtureArtifact(backing, variant, layout.package) }; + return { artifacts, main, backing, mainManifest, mainFiles, backingFiles }; +} diff --git a/tests/helpers/codex-cli-apply-fixture.ts b/tests/helpers/codex-cli-apply-fixture.ts new file mode 100644 index 00000000000..73371d5d17c --- /dev/null +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createNpmConfigIsolation, isolatedNpmTarget } from "../../src/codex/cli-update-plan"; +import { fixtureArtifact, fixtureArtifactSet } from "./codex-cli-apply-artifact"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; + +/** Admission waits must also observe an early refusal/rejection. Otherwise a + * diagnostic failure before install/fetch entry becomes a misleading timeout. + */ +export async function waitForApplyAdmission(admitted: Promise, running: Promise): Promise { + const first = await Promise.race([ + admitted.then(() => ({ kind: "admitted" as const })), + running.then(result => ({ kind: "early" as const, result }), error => ({ kind: "rejected" as const, code: error?.code ?? error?.name ?? "unknown" })), + ]); + if (first.kind === "early") throw new Error(`apply ended before admission: outcome=${first.result.outcome} reason=${first.result.reason} installerExitCode=${first.result.installerExitCode}`); + if (first.kind === "rejected") throw new Error(`apply rejected before admission: ${first.code}`); +} + +/** All npm config/cache/prefix/temp effects belong to root; no network or scripts. */ +export function privateNpm(root: string, args: string[]) { + // Each command starts with a new empty cache, not the previous pack/install's. + const work = mkdtempSync(join(root, "npm-work-")); + const isolation = createNpmConfigIsolation(work); + const target = isolatedNpmTarget([...args, "--ignore-scripts", "--no-audit", "--no-fund", "--offline", "--omit=optional"], isolation); + if (!target) throw new Error("real npm unavailable"); + const result = spawnSync(target.bin, target.args, { ...target.options, encoding: "utf8", timeout: 60_000, + env: { ...isolation.env, HOME: root, TMPDIR: root } }); + if (result.error || result.status !== 0) throw new Error(`private npm failed: ${result.status} ${result.error?.message ?? ""}\n${result.stderr}`); + return result; +} +/** The same synthetic npm entrypoint used by the published-launcher fixture. */ +export function writeFixtureNpmWrapper(fakeBin: string, node: string, realNpm: string, + metadata: Record, packs: Record, trace: string): void { + // An extensionless Node entrypoint inherits its nearest package's module type. + // HTTP fixtures live under this ESM repository; keep only this synthetic bin + // CommonJS without changing either installed package's original manifest. + writeFileSync(join(fakeBin, "package.json"), JSON.stringify({ private: true, type: "commonjs" }) + "\n"); + const wrapper = [ + `#!${node}`, 'const fs = require("node:fs"), path = require("node:path"), cp = require("node:child_process");', + `const metadata = ${JSON.stringify(metadata)}, packs = ${JSON.stringify(packs)};`, + 'const args = process.argv.slice(2);', + 'if (args[0] === "view") { if (!metadata[args[1]]) process.exit(81); console.log(JSON.stringify(metadata[args[1]])); process.exit(0); }', + 'if (args[0] === "pack") { const source = packs[args[1]]; if (!source) process.exit(82); const dir = args[args.indexOf("--pack-destination") + 1]; const name = path.basename(source); fs.copyFileSync(source, path.join(dir, name)); console.log(JSON.stringify([{filename:name}])); process.exit(0); }', + `if (args[0] !== "install") process.exit(83); fs.appendFileSync(${JSON.stringify(trace)}, JSON.stringify(args) + "\\n");`, + `const installed = cp.spawnSync(${JSON.stringify(node)}, [${JSON.stringify(realNpm)}, ...args], {env:process.env, stdio:"inherit"}); process.exit(installed.status ?? 84);`, + ].join("\n") + "\n"; + writeFileSync(join(fakeBin, "npm"), wrapper); chmodSync(join(fakeBin, "npm"), 0o755); +} +/** Tiny synthetic packages are packed by real npm; metadata and pack bytes are + * substituted by an owned npm wrapper, while install delegates to real npm. + * This exercises published Node -> Bun CLI -> resolver/provenance -> plan/apply. + */ +export function createCodexCliApplyFixture(root: string, platform: NodeJS.Platform = process.platform, arch = process.arch, targetNativeSource?: string) { + root = realpathSync.native(root); + const prefix = join(root, "prefix"), home = join(root, "home"), temporaryRoot = join(root, "tmp"), fakeBin = join(root, "bin"); + for (const dir of [prefix, home, temporaryRoot, fakeBin]) mkdirSync(dir, { recursive: true }); + const versions = ["1.0.0", "1.1.0"].map(version => { + const fixture = fixtureArtifactSet(platform, arch, version); + if (version === "1.1.0" && targetNativeSource !== undefined) { + fixture.backingFiles[Object.keys(fixture.backingFiles).find(path => path.endsWith("/bin/codex"))!] = targetNativeSource; + } + // An auxiliary native member makes partial readback independently observable. + fixture.backingFiles["vendor/auxiliary.txt"] = `synthetic auxiliary ${version}\n`; + const mainSource = join(root, `main-${version}`), backingSource = join(root, `backing-${version}`); + for (const [source, files] of [[mainSource, fixture.mainFiles], [backingSource, fixture.backingFiles]] as const) { + for (const [name, bytes] of Object.entries(files)) { + mkdirSync(dirname(join(source, name)), { recursive: true }); + writeFileSync(join(source, name), bytes); + if (name !== "package.json") chmodSync(join(source, name), 0o755); + } + } + const pack = (source: string) => { + const info = JSON.parse(privateNpm(root, ["pack", source, "--json", "--pack-destination", source]).stdout); + const path = join(source, info[0].filename); + return { path, bytes: readFileSync(path) }; + }; + const main = pack(mainSource), backing = pack(backingSource); + const artifacts = { ...fixture.artifacts, main: fixtureArtifact(main.bytes, version), + backing: fixtureArtifact(backing.bytes, fixture.artifacts.backing.version, fixture.artifacts.backing.installName) }; + return { artifacts, main, backing, mainManifest: fixture.mainManifest }; + }); + const old = versions[0]!, target = versions[1]!; + const installArgs = (v: typeof old) => ["install", "-g", "--prefix", prefix, "--package-lock=false", + `${v.artifacts.main.installName}@file:${v.main.path}`, `${v.artifacts.backing.installName}@file:${v.backing.path}`]; + privateNpm(root, installArgs(old)); + const trace = join(root, "install-trace.jsonl"); + writeFileSync(trace, ""); + const node = Bun.which("node"), npm = Bun.which("npm"); + if (!node || !npm) throw new Error("fixture toolchain unavailable"); + const realNpm = realpathSync.native(npm); + const metadata = Object.fromEntries([old, target].flatMap(v => [ + ["@openai/codex@" + v.artifacts.main.version, { ...v.mainManifest, dist: { integrity: v.artifacts.main.integrity, tarball: v.artifacts.main.tarball } }], + ["@openai/codex@" + v.artifacts.backing.version, { name: v.artifacts.backing.name, version: v.artifacts.backing.version, + dist: { integrity: v.artifacts.backing.integrity, tarball: v.artifacts.backing.tarball } }], + ])); + metadata["@openai/codex@latest"] = metadata["@openai/codex@1.1.0"]; + const packs = Object.fromEntries([old, target].flatMap(v => [ + ["@openai/codex@" + v.artifacts.main.version, v.main.path], ["@openai/codex@" + v.artifacts.backing.version, v.backing.path], + ])); + if (process.platform !== "win32") { + writeFixtureNpmWrapper(fakeBin, node, realNpm, metadata, packs, trace); + } + const launcher = process.platform === "win32" ? join(prefix, "codex.cmd") : join(prefix, "bin", "codex"); + const env = { ...process.env, HOME: home, TMPDIR: temporaryRoot, OPENCODEX_HOME: join(home, "ocx"), + PATH: [fakeBin, join(prefix, "bin"), dirname(node), process.env.PATH ?? ""].join(process.platform === "win32" ? ";" : ":"), + CODEX_CLI_PATH: launcher, OCX_NO_DESKTOP_HANDOFF: "1", OPENCODEX_BUN_PATH: process.execPath }; + return { root, prefix, home, temporaryRoot, launcher, env, trace, old, target, privateNpm: (args: string[]) => privateNpm(root, args) }; +} diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index ff380295506..3aac1d2f267 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -1,9 +1,15 @@ -import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; +import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; +import * as updateJobs from "../../src/codex/cli-update-job"; +import * as cliUpdatePlanModule from "../../src/codex/cli-update-plan"; +import * as cliUpdateWorkerModule from "../../src/codex/cli-update-job-worker"; +import type { ManagementContext } from "../../src/server/management/context"; +import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import { scanRoutes, distinctRoutes } from "../helpers/management-route-scan"; @@ -59,6 +65,230 @@ const moduleOf = (file: string): string => file.replace(/^src\//, "").replace(/\ const key = (method: string, path: string): string => `${method} ${path}`; describe("management route registry reconciliation", () => { + test("dashboard plan uses the shared proof-bound production inspection path", async () => { + const proof = "P".repeat(43); + const root = process.platform === "win32" ? "C:\\fixture-only" : "/tmp/fixture-only"; + const candidate = process.platform === "win32" ? "C:\\fixture-only\\codex.cmd" : "/tmp/fixture-only/codex"; + const env: NodeJS.ProcessEnv = { [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ version: 1, proof, anthropicEnvSlots: [], + codexCliInspectionEnv: { codexCliPath: candidate, path: root, pathExt: null, managerRoots: {}, transportEnv: {}, + arch: process.arch, configDir: root } }) }; + initializeNodeLauncherContext(["bun", "test", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], env); + try { + const request = new Request("http://localhost/api/codex/cli-update/plan"); + const ctx: ManagementContext = { req: request, url: new URL(request.url), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ applicable: false, schemaVersion: 1 }); + } finally { initializeNodeLauncherContext(["bun", "test"], {}); } + }); + + test("a gated registry child leaves concurrent management requests responsive", async () => { + // Regression for the planning P1: a slow npm query must not park the proxy event + // loop. The gated child answers like npm view --json but only once the release + // file appears — with its own cap so a regression cannot hang the suite — and it + // runs through the production async spawn runner, not a mocked event loop. + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-gated-npm-"))); + const gateFile = join(dir, "release"); + const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; + const mainJson = JSON.stringify({ name: "@openai/codex", version: "1.4.2", + optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" } }); + const backingJson = JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.4.2", + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex-linux-x64/-/codex-linux-x64-1.4.2.tgz" } }); + const gatedSpawn: cliUpdatePlanModule.CodexCliUpdateSpawn = (_bin, args, options) => + cliUpdatePlanModule.spawnCodexCliUpdateMetadata(process.execPath, [ + "-e", + 'const fs=require("node:fs"),g=process.env.OCX_GATE_FILE,a=process.env.OCX_GATE_ANSWER,d=Date.now()+Number(process.env.OCX_GATE_CAP_MS);' + + 'const t=setInterval(()=>{if(!fs.existsSync(g)&&Date.now()<=d)return;clearInterval(t);process.stdout.write(a+"\\n",()=>process.exit(0))},15)', + ], { ...options, windowsVerbatimArguments: false, + env: { ...options.env, OCX_GATE_FILE: gateFile, OCX_GATE_CAP_MS: "4000", + OCX_GATE_ANSWER: args.join(" ").includes("@openai/codex-linux-x64@") ? backingJson : mainJson } }); + const managed = { schemaVersion: 1, candidateAvailable: true, candidateVersion: "1.0.0", + candidateSource: "environment", selectionAttested: true, + versionEvidence: { kind: "package-manifest" }, provenance: "npm-global", managed: true, + reason: "managed_npm_global", location: "/@openai/codex", + installDigest: "a".repeat(64), installContentDigest: "c".repeat(64), + packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, + evidence: ["package_manifest", "global_npm_layout"] }; + const realPlan = cliUpdatePlanModule.createCodexCliUpdatePlan; + // The route dispatches into another realm: parent planner spies cannot reach it. + // This dispatch seam tests HTTP + real async metadata-child behavior only; + // cli-update-worker.test.ts separately exercises the actual Worker boundary. + const planner = spyOn(cliUpdateWorkerModule, "runCodexCliUpdatePlanInWorker").mockImplementation(() => + realPlan({ platform: "linux", inspectionDeps: { arch: "x64" }, inspect: async () => managed as never, + spawnProcess: gatedSpawn, + scanProcessesAsync: async () => ({ kind: "observed", processes: [] }) })); + const oldHome = process.env.OPENCODEX_HOME; + const emptyHome = join(dir, "empty-home"); + mkdirSync(emptyHome, { recursive: true }); + process.env.OPENCODEX_HOME = emptyHome; + try { + const makeCtx = (pathname: string): ManagementContext => { + const req = new Request(`http://localhost${pathname}`); + return { req, url: new URL(req.url), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + }; + const planPromise = handleCodexCliUpdateRoutes(makeCtx("/api/codex/cli-update/plan")); + // Let the first gated child spawn, then an unrelated request must still answer. + const firedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 150)); + const concurrent = await Promise.race([ + handleCodexCliUpdateRoutes(makeCtx("/api/codex/cli-update/jobs/11111111-2222-3333-4444-555555555555")) + .then(response => ({ kind: "responded" as const, response })), + new Promise<{ kind: "blocked" }>(resolve => setTimeout(() => resolve({ kind: "blocked" }), 3_000)), + ]); + // Under a synchronous spawn this race can only settle after the child cap. + expect(concurrent.kind).toBe("responded"); + // A parked event loop defers BOTH arms past the child cap, so the response + // arriving "first" is not enough — the wall clock is the discriminator. + expect(Date.now() - firedAt).toBeLessThan(2_500); + if (concurrent.kind === "responded") { + // POSIX reads an empty store (404); Windows refuses the unsupported store + // lane (503). Either way the unrelated request answered while planning waited. + expect([404, 503]).toContain(concurrent.response?.status ?? 0); + } + writeFileSync(gateFile, "release"); + const response = await planPromise; + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + expect(planner).toHaveBeenCalledTimes(1); + } finally { + planner.mockRestore(); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("plan dispatch failure returns HTTP 503 without a second dispatch", async () => { + const dispatch = spyOn(cliUpdateWorkerModule, "runCodexCliUpdatePlanInWorker") + .mockRejectedValue(new Error("plan_unavailable")); + const req = new Request("http://localhost/api/codex/cli-update/plan"); + const ctx: ManagementContext = { req, url: new URL(req.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + try { + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: "plan_unavailable" }); + expect(dispatch).toHaveBeenCalledTimes(1); + } finally { dispatch.mockRestore(); } + }); + + test("Codex CLI HTTP apply requires a GUI session and trusted loopback before admission", async () => { + const config = { port: 10100, providers: {} } as import("../../src/types").OcxConfig; + const request = new Request("http://localhost/api/codex/cli-update/apply", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: true }), + }); + const ctx: ManagementContext = { req: request, url: new URL(request.url), config, deps: {}, version: "test", + principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ error: "local_dashboard_confirmation_required" }); + ctx.principal = "gui-session"; + ctx.trustedLoopbackIngress = false; + const untrustedIngress = await handleCodexCliUpdateRoutes(ctx); + expect(untrustedIngress?.status).toBe(403); + expect(await untrustedIngress?.json()).toEqual({ error: "local_dashboard_confirmation_required" }); + ctx.trustedLoopbackIngress = true; + ctx.req = new Request(request.url, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: false }) }); + const unconfirmed = await handleCodexCliUpdateRoutes(ctx); + expect(unconfirmed?.status).toBe(400); + ctx.req = new Request(request.url, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: true, + prefix: "/tmp/ignored", executable: "/tmp/ignored", managed: true, selectionAttested: true }) }); + const clientEvidence = await handleCodexCliUpdateRoutes(ctx); + expect(clientEvidence?.status).toBe(400); + }); + + test.each(["job_store_busy", "store_full"])("HTTP admission %s schedules no worker and only contention advertises retry", async code => { + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockImplementation(() => { throw new Error(code); }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob"); + try { + const req = new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "busy-response-01", planId: "e".repeat(32), confirmed: true }) }); + const ctx: ManagementContext = { req, url: new URL(req.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", + principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: code }); + expect(response?.headers.get("Retry-After")).toBe(code === "job_store_busy" ? "1" : null); + expect(worker).not.toHaveBeenCalled(); + } finally { admission.mockRestore(); worker.mockRestore(); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-"))); + const parent = join(outer, "private-parent"), configDir = join(parent, "ocx"); + const oldHome = process.env.OPENCODEX_HOME; + try { + chmodSync(outer, 0o700); + mkdirSync(parent, { mode: 0o700 }); chmodSync(parent, 0o700); + mkdirSync(configDir, { mode: 0o700 }); chmodSync(configDir, 0o700); + process.env.OPENCODEX_HOME = realpathSync.native(configDir); + const makeRequest = () => new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "lost-response-retry-01", planId: "e".repeat(32), confirmed: true }) }); + const ctx: ManagementContext = { req: makeRequest(), url: new URL("http://localhost/api/codex/cli-update/apply"), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", + principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const storeDir = join(configDir, "codex-cli-update-jobs"), heldLock = join(storeDir, "admission.lock"); + mkdirSync(storeDir, { mode: 0o700 }); chmodSync(storeDir, 0o700); + mkdirSync(heldLock, { mode: 0o700 }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob"); + try { + const busy = await handleCodexCliUpdateRoutes(ctx); + expect(busy?.status).toBe(503); + expect(await busy?.json()).toEqual({ error: "job_store_busy" }); + expect(busy?.headers.get("Retry-After")).toBe("1"); + expect(worker).not.toHaveBeenCalled(); + expect(existsSync(join(storeDir, "jobs.json"))).toBe(false); + } finally { worker.mockRestore(); rmSync(heldLock, { recursive: true }); } + ctx.req = makeRequest(); + const first = await handleCodexCliUpdateRoutes(ctx); + const firstBody = await first!.json(); + ctx.req = makeRequest(); + const retry = await handleCodexCliUpdateRoutes(ctx); + const retryBody = await retry!.json(); + const diagnostic = (body: { error?: unknown }) => JSON.stringify({ error: + typeof body.error === "string" && ["invalid_request", "idempotency_conflict", "store_full", + "unsupported_store", "unsafe_store", "admission_failed"].includes(body.error) ? body.error : null }); + expect(first?.status, diagnostic(firstBody)).toBe(202); + expect(retry?.status, diagnostic(retryBody)).toBe(202); + expect(retryBody.job.jobId).toBe(firstBody.job.jobId); + let status: { jobId?: string; ownerState?: string; outcome?: string; reason?: string } = {}; + for (let i = 0; i < 100; i += 1) { + const statusRequest = new Request(`http://localhost/api/codex/cli-update/jobs/${firstBody.job.jobId}`); + ctx.req = statusRequest; ctx.url = new URL(statusRequest.url); + status = await (await handleCodexCliUpdateRoutes(ctx))!.json() as typeof status; + if (status.ownerState === "finished") break; + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(status).toMatchObject({ jobId: firstBody.job.jobId, ownerState: "finished", outcome: "refused" }); + expect(["not_managed", "mutation_evidence_unverified"].includes(status.reason ?? "")).toBe(true); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + rmSync(outer, { recursive: true }); + } + }); + test("data-key reveal is declared as a dashboard-session-only read", () => { expect(MANAGEMENT_ROUTES.find(route => route.method === "POST" && route.path === "/api/keys/reveal")) .toMatchObject({ module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only" } }); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 354e3943fe0..cb4a7482819 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import * as runnerBun from "../scripts/lib/test-runner-bun"; import { testRunnerBun } from "../package.json"; import { anchors, @@ -469,24 +470,43 @@ describe("move end to end", () => { } }); - test("domain verification launches isolate tests through the pinned runner", () => { + const otherBunVersion = testRunnerBun === "1.4.3" ? "1.4.4" : "1.4.3"; + test.each([ + ["matching caller", testRunnerBun, testRunnerBun, false], + ["different caller", otherBunVersion, testRunnerBun, false], + ["wrong override version", otherBunVersion, otherBunVersion, true], + ] as const)("domain verification preserves the configured runner role: %s", (_name, currentVersion, overrideVersion, refused) => { const { root, cleanup } = scratchRepo(); const layoutPath = join(root, "scripts", "test-layout", "layout.json"); try { runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const binaryName = process.platform === "win32" ? "bun.exe" : "bun"; + const pinnedRunner = join(root, "test-runner", binaryName); + const caller = currentVersion === testRunnerBun ? pinnedRunner : join(root, "runtime", binaryName); + const probes: string[] = []; + // Keep role resolution real, but version probes independent of the host and the spawn mock. + const select = spyOn(runnerBun, "getTestRunnerBun").mockImplementation(() => runnerBun.resolveTestRunnerBun({ + pin: testRunnerBun, currentVersion, execPath: caller, + env: { OCX_TEST_RUNNER_BUN: pinnedRunner }, pathEntries: [], homeDir: root, platform: process.platform, + probeVersion: binary => { probes.push(binary); return binary === pinnedRunner ? overrideVersion : undefined; }, + })); const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), } as ReturnType); try { - const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); - expect(report.testExit).toBe(0); - // The test itself runs on the required pin, so its executable is the resolver's first choice. - expect(Bun.version).toBe(testRunnerBun); - expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ - process.execPath, "test", "--isolate", "tests/server", "tests/providers", + const verify = () => runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + if (refused) expect(verify).toThrow(`OCX_TEST_RUNNER_BUN reports ${overrideVersion}`); + else expect(verify()).toMatchObject({ ok: true, testExit: 0 }); + expect(select).toHaveBeenCalledTimes(1); + expect(probes).toEqual(currentVersion === testRunnerBun ? [] : [pinnedRunner]); + const testSpawns = spawn.mock.calls.filter(([command]) => Array.isArray(command) && command[1] === "test"); + expect(testSpawns.map(([command]) => command)).toEqual(refused ? [] : [ + [pinnedRunner, "test", "--isolate", "tests/server", "tests/providers"], ]); + if (!refused) expect(testSpawns[0]?.[1]).toMatchObject({ cwd: root, stdout: "inherit", stderr: "inherit" }); } finally { spawn.mockRestore(); + select.mockRestore(); } } finally { cleanup();