diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 2563cdc0de..015a3e3aaf 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 1337a76d0c..e788fa47bb 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 c766a1cdfa..4762c9a1fa 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 ebe43edfff..fa91fed772 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 3abe8fbd11..9b02fb11fc 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 c8bb7e5373..8fc893a989 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 77086aa31b..8bc2ef7316 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -152,16 +152,19 @@ 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` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 3c4316e5da..622c59d181 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,7 +1182,9 @@ 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. @@ -1190,10 +1192,13 @@ settings and `NODE_EXTRA_CA_CERTS` remain supported; cleanup is best-effort. No An applicable plan reports `applicable: true` and a deterministic `planId` binding platform, 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. A plan ID alone -is not installation authorization: apply requires separate explicit confirmation. +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 @@ -1238,7 +1243,7 @@ ocx system codex-cli-update attest --candidate --npm-prefix isAppBundledCodexPath(path, platform) || isCodexCliUpdateVersionManagerPath(path, platform) + || isWithinConfiguredVersionManagerRoot(path, managerRoots, platform)); } const configDir = deps.configDir ?? getConfigDir(); if (!isSafeLocalInspectionPath(configDir, deps)) { diff --git a/src/codex/cli-installation-identity.ts b/src/codex/cli-installation-identity.ts index da06ad9173..2c9dcd6ac0 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 0000000000..60b0868e3d --- /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 index dc6d46b789..f6497bace2 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -180,7 +180,7 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep 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_inspection_deferred"); + 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)); diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts index 7fae08b6e5..fcd2ddeace 100644 --- a/src/codex/cli-update-artifacts.ts +++ b/src/codex/cli-update-artifacts.ts @@ -52,7 +52,7 @@ export function backingReference(main: Metadata, installName: string): { name: s /** 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 || platform === "win32" || main.name !== "@openai/codex") throw new Error("artifact_platform_unverified"); + 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 }; diff --git a/src/codex/cli-update-job-execution.ts b/src/codex/cli-update-job-execution.ts index e3ae63ed76..7d3eac94d0 100644 --- a/src/codex/cli-update-job-execution.ts +++ b/src/codex/cli-update-job-execution.ts @@ -1,7 +1,7 @@ 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", "cancelled", "mutation_evidence_unverified", +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", diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 3ba99bcfe9..12e136f422 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -9,6 +9,7 @@ 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, @@ -50,6 +51,7 @@ export type CodexCliUpdateChannel = "latest"; export type CodexCliUpdateRefusal = | "windows_inspection_deferred" + | "windows_apply_disabled" | "not_managed" | "installed_version_unverified" | "install_content_unverified" @@ -82,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; @@ -526,12 +528,16 @@ export async 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): Promise { + 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 invocation = isolatedNpmTarget(["view", spec, "--json"], isolation!); + 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 }); @@ -599,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") { @@ -631,8 +636,11 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} // { PATH: "" } form, so this resolution cannot silently fall back to ambient. const resolveTarget = deps.resolveTarget ?? (ch => resolveCodexCliUpdateArtifactTarget(ch, deps.spawnProcess ?? spawnCodexCliUpdateMetadata, deps.inspectionDeps?.env, - platform, deps.inspectionDeps?.arch ?? process.arch)); - const target = await resolveTarget(channel); + 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); } @@ -673,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, diff --git a/src/codex/cli-windows-plan-provenance.ts b/src/codex/cli-windows-plan-provenance.ts new file mode 100644 index 0000000000..9071eccccd --- /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/windows-installation-files.ts b/src/codex/windows-installation-files.ts index e1f3cef569..ff66cf5a6d 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/structure/codex-cli-update.md b/structure/codex-cli-update.md index 20815f0dca..2ada58b5fe 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -42,7 +42,7 @@ no bound content is refused install_content_unverified before any registry query 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. @@ -62,6 +62,46 @@ Linux per-process `status`/`cmdline` read failures invalidate a complete observa 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. + ## Explicit POSIX apply `src/cli/codex-cli-update.ts` accepts `apply --plan-id <32 lowercase hex> --yes` with optional JSON output. diff --git a/structure/runtime.md b/structure/runtime.md index a0b0a91ce9..fb9b63a3e6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -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. @@ -313,12 +313,12 @@ package-registry request and reads bounded provenance evidence for the configure 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/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 0156df8cef..6268f23f1d 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -297,6 +297,11 @@ describe("Codex CLI job persistence failures", () => { 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 () => { diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index 6e1e761b41..3afc95cd9d 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -303,6 +303,6 @@ describe("Codex CLI apply consumer", () => { 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_inspection_deferred"); expect(calls).toBe(0); + expect(result.reason).toBe("windows_apply_disabled"); expect(calls).toBe(0); }); }); 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 2f8919591f..4182e0d9c9 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 0000000000..551d3bd085 --- /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 7c5f32ffd8..e479c218af 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1406,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",