From 98f1a2f3bbbb9ba54d4bf89178152c264124551d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 00:17:03 +0900 Subject: [PATCH 1/3] fix(codex): keep Unix autostart shims off package-manager paths On macOS and Linux, `ocx codex-shim install` no longer rewrites the codex launcher that brew, npm, or fnm own. It publishes a private wrapper at /bin/codex and a sourceable codex-shell-env.sh that puts that directory first on PATH, so package-manager upgrades and version rollbacks keep working without rewrapping. Shell startup files are never edited. - schema-2 shim state records the wrapper, native launcher, and the inode identities of the private files; state is the commit marker of a journaled publication, and failures roll the private files back - an older in-place Unix shim migrates on explicit install: the recorded backup is restored without replacing a newer native entry, and ambiguous layouts refuse and keep every artifact - automatic repair refreshes only owned private files under the existing opt-in; status and doctor no longer trigger repair; identity-less or unsafe (group/world-writable, replaced) artifacts are preserved and reported - diagnostics separate runnable from active; install succeeds when the wrapper is runnable and prints the activation command; connect and doctor report an inactive overlay with that guidance - PATH activation uses the shared scanner with #6493's executable-bit rule; runtime, catalog, and feature probes prefer the validated native launcher - Windows keeps its in-place wrapping unchanged - diagnostics move to shim-diagnostics.ts so shim.ts shrinks below its cap Carries #6301 onto current dev. The PATH-readiness test fixtures are taken verbatim from #6493. Co-authored-by: lilinxiong Co-authored-by: BigHulk --- .../docs/fr/reference/cli/lifecycle.md | 22 +- .../docs/ja/reference/cli/lifecycle.md | 22 +- .../docs/ko/reference/cli/lifecycle.md | 31 +- .../content/docs/reference/cli/lifecycle.md | 84 ++- .../docs/ru/reference/cli/lifecycle.md | 34 +- .../docs/tr/reference/cli/lifecycle.md | 36 +- .../docs/zh-cn/reference/cli/lifecycle.md | 24 +- .../docs/zh-tw/reference/cli/lifecycle.md | 20 +- scripts/test-layout/layout.json | 5 + src/cli/codex-shim-autorestore.ts | 3 +- src/cli/codex-shim-readiness.ts | 26 +- src/cli/dispatch.ts | 15 +- src/cli/doctor.ts | 9 +- src/codex/catalog/bundled.ts | 11 +- src/codex/features.ts | 9 +- src/codex/runtime.ts | 8 +- src/codex/shim-diagnostics.ts | 75 +++ src/codex/shim-fingerprint.ts | 9 +- src/codex/shim-migration.ts | 139 ++++ src/codex/shim-overlay.ts | 492 ++++++++++++++ src/codex/shim-path-resolution.ts | 16 +- src/codex/shim-probe.ts | 7 +- src/codex/shim-state-file.ts | 100 ++- src/codex/shim.ts | 83 +-- structure/catalog.md | 2 +- structure/runtime.md | 38 +- tests/cli/cli-connect-readiness.test.ts | 12 + .../codex-shim-autorestore.test.ts | 106 +-- .../codex-shim-destroyed-probe.test.ts | 4 +- .../codex-shim-ensure-failure.test.ts | 3 + .../codex-integration/codex-shim-fnm.test.ts | 17 +- .../codex-shim-migration.test.ts | 197 ++++++ .../codex-shim-overlay-candidates.test.ts | 140 ++++ .../codex-shim-overlay-readiness.test.ts | 151 +++++ .../codex-shim-overlay-transaction.test.ts | 339 ++++++++++ .../codex-shim-overlay.test.ts | 180 +++++ .../codex-shim-path-readiness.test.ts | 79 ++- .../codex-shim-readiness.test.ts | 60 +- .../codex-shim-standalone.test.ts | 6 +- tests/codex-integration/codex-shim.test.ts | 624 ++++++++---------- tests/fixtures/test-layout-expected.json | 5 + tests/helpers/codex-shim-install-fixture.ts | 51 +- .../doctor-codex-envkey-readiness.test.ts | 14 + 43 files changed, 2645 insertions(+), 663 deletions(-) create mode 100644 src/codex/shim-diagnostics.ts create mode 100644 src/codex/shim-migration.ts create mode 100644 src/codex/shim-overlay.ts create mode 100644 tests/codex-integration/codex-shim-migration.test.ts create mode 100644 tests/codex-integration/codex-shim-overlay-candidates.test.ts create mode 100644 tests/codex-integration/codex-shim-overlay-readiness.test.ts create mode 100644 tests/codex-integration/codex-shim-overlay-transaction.test.ts create mode 100644 tests/codex-integration/codex-shim-overlay.test.ts diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 59d53766063..bdb0773c9a0 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -298,22 +298,30 @@ Ainsi, l’annulation ou le refus de l’UAC, comme l’impossibilité de revend ### `ocx codex-shim ` -Entoure un lanceur `codex` basé sur un script et présent dans PATH avec un script léger de démarrage automatique. Les cibles réelles `codex.exe` restent intactes afin de ne pas casser les appels qui visent précisément cet exécutable. +Sur macOS et Linux, `ocx codex-shim install` crée un wrapper privé dans `/bin/codex` et le fichier à sourcer `/codex-shell-env.sh`, dans le répertoire OpenCodex résolu. Le lanceur natif reste à l’emplacement installé par brew, npm ou fnm : mises à niveau et retours à une version précédente fonctionnent sans réécrire ce lanceur. Sur Windows, les lanceurs à base de scripts restent enveloppés sur place ; les vrais `codex.exe` restent intacts. Pour une installation Windows ne proposant que `codex.exe`, utilisez `ocx service install`. + +Après la configuration PATH de brew/fnm, exécutez la commande d’activation affichée par l’installation. Avec le répertoire par défaut : + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +Pour un répertoire personnalisé, utilisez le chemin entre guillemets affiché. Le sourçage est idempotent : il retire les doublons du répertoire privé et le place en tête de PATH. Ajoutez vous-même cette ligne après la configuration PATH dans votre fichier de démarrage pour les futures sessions ; OpenCodex ne modifie jamais ces fichiers. Un wrapper exécutable suffit pour réussir l’installation, même s’il n’est pas encore actif dans le shell courant. Une installation refusée ou un wrapper inutilisable reste un échec. `ocx status`, `ocx codex-shim status`, `ocx doctor` et `ocx connect` signalent **not active** avec la commande d’activation si PATH ne sélectionne pas le wrapper ; les avertissements de connect ne changent pas son code de sortie. Les alias, fonctions et lancements depuis le bureau ou les services nécessitent leur propre configuration. Avant de valider une installation ou une réparation, OpenCodex exécute le lanceur enregistré avec `--version` en désactivant le démarrage du service. La modification est refusée et annulée si le lanceur résout `codex` vers le shim lui-même, renvoie un code non nul, dépasse cinq secondes, laisse des processus descendants actifs, ou ne peut pas être validé et nettoyé en toute sécurité. `codex-shim install` n’est donc pas inconditionnel. En cas de refus, réinstallez Codex afin que l’entrée PATH désigne un exécutable ou un lanceur concret, puis recommencez. Utilisez plutôt `ocx service install` lorsqu’un lanceur dynamique fourni par un gestionnaire de commandes ne peut pas satisfaire ces contrôles. -Pendant une mise à niveau, un shim Unix installé qui ne contient pas la garde de validation actuelle est régénéré et testé. Si son lanceur enregistré n’est pas sûr, OpenCodex supprime le shim obsolète et rétablit le lanceur d’origine au lieu de conserver l’enveloppe dangereuse. +Un ancien shim Unix installé sur place n’est migré que par un `ocx codex-shim install` explicite. Le lanceur natif enregistré est restauré sans remplacer un lanceur plus récent déjà présent, puis le wrapper privé est installé. Si cette installation échoue après la restauration, le lanceur natif reste restauré et vous pouvez recommencer. Si le lanceur enregistré manque ou ne fonctionne plus, réparez l’installation avec le gestionnaire de paquets ; OpenCodex ne choisit pas une autre installation et ne réenveloppe pas le chemin du gestionnaire. -L’installation du lanceur ne prouve pas à elle seule que les requêtes Codex passeront par OpenCodex. Après une installation saine, la commande examine le routage Codex actuel et affiche un avertissement plutôt qu’un résultat positif lorsque le routage est externe, appartient à l’utilisateur ou ne peut pas être vérifié. Elle avertit aussi lorsque des variables de proxy sortant n’existent que dans le processus actuel alors que `config.proxy` est absent ou non résolu, car les lanceurs Codex et les services d’arrière-plan peuvent ne pas hériter de cet environnement. Ces contrôles sont en lecture seule et n’affichent jamais la valeur du proxy. Corrigez le transfert signalé et exécutez `ocx doctor` avant de compter sur le démarrage automatique. +L’installation du lanceur ne prouve pas à elle seule que les requêtes Codex passeront par OpenCodex. Après une installation dont le wrapper est exécutable, la commande examine le routage Codex actuel et affiche un avertissement plutôt qu’un résultat positif lorsque le routage est externe, appartient à l’utilisateur ou ne peut pas être vérifié. Elle avertit aussi lorsque des variables de proxy sortant n’existent que dans le processus actuel alors que `config.proxy` est absent ou non résolu, car les lanceurs Codex et les services d’arrière-plan peuvent ne pas hériter de cet environnement. Ces contrôles sont en lecture seule et n’affichent jamais la valeur du proxy. Corrigez le transfert signalé et exécutez `ocx doctor` avant de compter sur le démarrage automatique. -Si une mise à jour externe achevée de Codex remplace un shim installé, la prochaine commande `ocx` ordinaire sauvegarde le nouveau lanceur stable et rétablit le shim avant de répartir la commande. La commande d’inspection sans effet `ocx system codex-cli-update check` et les invocations mal formées de son espace de noms réservé `ocx system codex-cli-update` n’effectuent jamais cette réparation. Un lanceur encore en cours de modification reste intact et sera réexaminé plus tard. Un échec de réparation produit un avertissement sans faire échouer la commande demandée. Repli manuel : `ocx codex-shim install`. Définissez `codexShimAutoRestore` sur `false`, ou `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` pour désactiver ce comportement au niveau du processus. +Sur Unix, la réparation automatique ne rafraîchit que le wrapper privé ; elle ne réécrit jamais les lanceurs du gestionnaire de paquets et ne migre pas les anciens shims installés sur place. Sur Windows, après une mise à jour externe achevée qui remplace le shim, la prochaine commande `ocx` ordinaire sauvegarde le nouveau lanceur stable et rétablit le shim. Un lanceur encore en cours de modification reste intact et sera réexaminé plus tard. `ocx status`, `ocx doctor`, `ocx codex-shim status`, `ocx system codex-cli-update check` et les invocations mal formées de son espace de noms réservé ne déclenchent pas cette réparation. Un échec produit un avertissement sans faire échouer la commande demandée. Repli manuel : `ocx codex-shim install`. Définissez `codexShimAutoRestore` sur `false`, ou `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` pour désactiver ce comportement au niveau du processus. | Sous-commande | Action | | --- | --- | | `install` | Installe le shim, ou le répare s’il est obsolète. | -| `uninstall` | Supprime le shim et rétablit le binaire Codex d’origine. | +| `uninstall` | Supprime les fichiers privés Unix sans modifier Codex natif ; sur Windows, restaure le lanceur d’origine. | | `remove` | Alias de `uninstall`. | -| `status` | Indique si le shim est installé, obsolète ou absent. | +| `status` | Indique l’état du shim et si le wrapper privé est actif dans PATH. | ```bash ocx codex-shim install @@ -321,6 +329,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +Après la désinstallation Unix, retirez la ligne de sourçage de votre fichier de démarrage et redémarrez le shell ou retirez le répertoire privé de PATH. Seuls le wrapper, le fichier d’environnement et l’état détenus par OpenCodex sont supprimés ; le lanceur du gestionnaire reste intact. Un ancien shim Unix installé sur place est libéré à l’aide de ses données de restauration enregistrées. + :::tip[Service ou shim] Utilisez `ocx service` pour maintenir un proxy d’arrière-plan toujours actif, ce qui est recommandé. Utilisez `ocx codex-shim` pour un démarrage léger à la demande, sans démon : le proxy ne démarre que lorsque `codex` est lancé. ::: diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 63e488efb9d..37a9ad6522c 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -232,20 +232,28 @@ Windows では、タスク スケジューラ エントリを作成するには ### `ocx codex-shim ` -軽量の自動起動スクリプトを使用して、スクリプトベースの `codex` ランチャーを PATH 上にラップします。実際の `codex.exe` ターゲットは、正確な実行可能呼び出しの破損を避けるため、変更されないまま残されます。 +macOS と Linux では、`ocx codex-shim install` は解決済みの OpenCodex ホームに専用 wrapper `/bin/codex` と、読み込むための `/codex-shell-env.sh` を作成します。ネイティブランチャーは brew、npm、fnm が配置した場所に残るため、パッケージマネージャーによる更新やバージョンの巻き戻しでも再ラップは不要です。Windows では従来どおりスクリプトランチャーをその場でラップし、実際の `codex.exe` は変更しません。`codex.exe` しかない Windows 環境では `ocx service install` を使用してください。 + +brew/fnm の PATH 設定後に、インストール時に表示された有効化コマンドを実行します。既定のホームの場合: + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +ホームを変更している場合は、表示された引用符付きのパスを使用してください。何度読み込んでも専用 bin の重複を除去して PATH の先頭に配置します。今後のシェルでも使うには、起動ファイルの PATH 設定後にこの行を自分で追加します。OpenCodex はシェル起動ファイルを編集せず、インストールは親シェルの PATH を変更しません。wrapper が実行可能なら、現在のシェルで未有効でもインストールは成功です。拒否された場合や実行できない場合は失敗します。`ocx status`、`ocx codex-shim status`、`ocx doctor`、`ocx connect` は未選択の wrapper を **not active** として有効化コマンドとともに報告します。connect の警告は終了コードを変更しません。エイリアス、関数、デスクトップやサービスからの起動には個別の設定が必要です。 インストールまたは修復を確定する前に、OpenCodex はサービス起動をバイパスした状態で、保存済みランチャーを `--version` 付きで実行します。ランチャーが `codex` を再び shim に解決する、0 以外で終了する、5 秒を超える、子プロセスを残す、または安全に検証・クリーンアップできない場合、変更を拒否してロールバックします。したがって `codex-shim install` は無条件のインストールではありません。拒否された場合は、PATH エントリが具体的な実行ファイルまたはランチャーを指すよう Codex を再インストールしてから再試行してください。動的コマンドマネージャーのランチャーがこれらの検証を満たせない場合は、代わりに `ocx service install` を使用してください。 -アップグレード時には、現在の検証ガードを持たない既存の Unix shim を再生成して検証します。保存済みランチャーが安全でない場合、OpenCodex は危険な wrapper を残さず、古い shim を削除して元のランチャーを復元します。 +古い Unix のその場でラップする shim は、明示的な `ocx codex-shim install` でのみ移行します。記録済みのネイティブランチャーを復元しますが、すでにある新しいランチャーは置き換えません。その後、専用 wrapper をインストールします。復元後に専用 wrapper のインストールが失敗してもネイティブランチャーは復元されたままで、再試行できます。記録済みランチャーが存在しないか使用できない場合は、パッケージマネージャーで修復してください。別のインストールを推測したり、管理対象パスを再ラップしたりしません。 -完了した外部 Codex アップデートがインストールされている shim を上書きした場合、次の通常の `ocx` コマンドは安定した新しいランチャーをバックアップし、ディスパッチ前に shim を復元します。副作用のない検査コマンド `ocx system codex-cli-update check` と、予約された `ocx system codex-cli-update` 名前空間の不正な呼び出しは、この修復を行いません。まだ変更中のランチャーは変更されず、後で再試行されます。修復の失敗は、要求されたコマンドを失敗させることなく警告します。手動フォールバック: `ocx codex-shim install`。 `codexShimAutoRestore` を `false` に設定するか、プロセス レベルのオプトアウトの場合は `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 +Unix の自動修復は専用 wrapper の更新だけを行い、パッケージマネージャーのランチャーを書き換えたり古いその場の shim を移行したりしません。Windows では、完了した外部更新で shim が上書きされると、次の通常の `ocx` コマンドが安定した新しいランチャーを保存して shim を復元します。変更中のランチャーはそのままにして後で再試行します。`ocx status`、`ocx doctor`、`ocx codex-shim status`、`ocx system codex-cli-update check` とその予約名前空間の不正な呼び出しは、この自動修復を実行しません。失敗しても要求されたコマンドは失敗せず、警告を表示します。手動の対処は `ocx codex-shim install` です。`codexShimAutoRestore` を `false` にするか、プロセス単位で `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定すると無効になります。 |サブコマンド |アクション | | --- | --- | | `install` |シムを取り付けます(または古い場合は修理します)。 | -| `uninstall` |シムを削除し、元の Codex バイナリを復元します。 | +| `uninstall` | Unix の専用ファイルを削除し、ネイティブ Codex は保持します。Windows では元のランチャーを復元します。 | | `remove` | `uninstall`の別名。 | -| `status` |シムの状態 (インストール済み、古い、または欠落) を報告します。 | +| `status` | shim の状態と、専用 wrapper が PATH で有効かどうかを報告します。 | ```bash ocx codex-shim install @@ -253,6 +261,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +Unix でアンインストールした後は、起動ファイルから読み込み行を削除し、シェルを再起動するか専用 bin を PATH から取り除いてください。所有する wrapper、環境ファイル、状態だけを削除し、パッケージマネージャーのランチャーは変更しません。古いその場の Unix shim は記録済みの復元情報を使って解除します。 + :::note[Windows のトークン環境] 新しく生成される Windows CMD と PowerShell のシムは、実行後に呼び出し元の `OPENCODEX_API_AUTH_TOKEN` を元の状態に戻します。Codex とその子プロセスには、引き続きトークンが継承される可能性があります。 @@ -273,7 +283,7 @@ OpenCodex の更新後、既存の Windows シムにこの動作を適用する `opencodex-proxy.service` の `EnvironmentFile=` または `OCX_API_TOKEN_FILE` は、プロキシプロセスだけを設定するものであり、独立して起動された `codex exec` に渡されることはありません。 -ランチャーを置き換える Codex のアップグレードによって、シムは削除されます。次に通常の `ocx` コマンドを実行すると復元されますが(上記参照)、その前に実行された `codex exec` は失敗します。`ocx doctor` は、この状態(env_key が設定済み、変数が未設定、シムが存在しないか正常でない、トークンファイルは存在する)を修復コマンドとともに "Codex env_key launch readiness" の項目で報告し、トークンを表示することはありません。トークンファイルの読み取りは、注入された `env_key` の契約には含まれません。起動元のプロセスがその変数を渡す必要があります。 +Unix のパッケージマネージャーによる更新では専用 wrapper は残ります。未有効なら表示されたファイルを読み込んでください。Windows または古いその場の shim では、ランチャーを置き換える更新によって shim が削除されます。Windows では、次に通常の `ocx` コマンドを実行すると復元されますが(上記参照)、その前に実行された `codex exec` は失敗します。`ocx doctor` は、この状態や未有効の wrapper(env_key が設定済み、変数が未設定、シムが存在しないか正常でない、トークンファイルは存在する)を修復または有効化の案内とともに "Codex env_key launch readiness" の項目で報告し、トークンを表示することはありません。トークンファイルの読み取りは、注入された `env_key` の契約には含まれません。起動元のプロセスがその変数を渡す必要があります。 ### `ocx tray [--json] [--no-start]` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index dc6d38b8ee3..06a99580575 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -353,10 +353,15 @@ Windows에서 Task Scheduler 항목을 만들려면 권한 상승이 필요합 ### `ocx codex-shim ` -PATH 위의 스크립트 기반 `codex` 런처를 가벼운 자동 시작 스크립트로 감쌉니다. 정확한 실행 파일 -호출을 깨지 않도록 실제 `codex.exe` 대상은 손대지 않습니다. -설치가 거부되거나 설치 후 shim이 비정상이면 명령은 실패 종료 코드를 반환하고 대시보드는 실패 사유를 표시합니다. -이미 설치된 정상 shim은 성공으로 처리합니다. Windows에서 `codex.exe`만 제공되는 설치는 자동 시작에 `ocx service install`을 사용하세요. +macOS와 Linux에서 `ocx codex-shim install`은 확정된 OpenCodex 홈에 전용 wrapper `/bin/codex`와 셸에서 읽을 `/codex-shell-env.sh`를 설치합니다. 원래 런처는 brew, npm, fnm이 설치한 위치에 그대로 있어 패키지 관리자 업데이트와 버전 롤백 뒤에도 다시 감쌀 필요가 없습니다. Windows는 기존처럼 스크립트 런처를 제자리에서 감싸며 실제 `codex.exe`는 건드리지 않습니다. Windows에서 `codex.exe`만 제공된다면 `ocx service install`을 사용하세요. + +brew/fnm 등의 PATH 설정이 끝난 뒤 설치 명령이 출력한 활성화 명령을 실행하세요. 기본 홈에서는 다음과 같습니다. + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +사용자 지정 홈에서는 출력된 따옴표 포함 경로를 사용하세요. 여러 번 읽어도 전용 bin의 중복 항목을 제거하고 PATH 맨 앞에 배치합니다. 이후 셸에서도 사용하려면 셸 시작 파일의 PATH 설정 뒤에 이 줄을 직접 추가하세요. OpenCodex는 셸 시작 파일을 수정하지 않으며 설치 명령은 부모 셸의 PATH를 바꿀 수 없습니다. wrapper가 실행 가능하면 현재 셸에서 아직 활성화되지 않아도 설치는 성공합니다. 설치가 거부되거나 wrapper를 실행할 수 없으면 실패 종료 코드를 반환합니다. `ocx status`, `ocx codex-shim status`, `ocx doctor`, `ocx connect`는 PATH에서 선택되지 않은 wrapper를 **not active**로 표시하고 활성화 명령을 안내합니다. connect 경고는 종료 상태를 바꾸지 않습니다. 별칭, 함수, 데스크톱과 서비스 실행 환경은 별도 설정이 필요합니다. 설치나 복구를 확정하기 전에 OpenCodex는 서비스 시작을 우회한 상태에서 저장된 런처를 `--version`으로 실행합니다. 런처가 `codex`를 shim으로 다시 해석해 재귀하거나, 0이 아닌 코드로 @@ -364,22 +369,16 @@ PATH 위의 스크립트 기반 `codex` 런처를 가벼운 자동 시작 스크 변경을 거부하고 롤백합니다. 따라서 `codex-shim install`은 무조건 성공하는 명령이 아닙니다. 거부되면 PATH 항목이 구체적인 실행 파일 또는 런처를 가리키도록 Codex를 다시 설치한 뒤 재시도하세요. 동적 명령 관리자의 런처가 이 검증을 충족할 수 없다면 대신 `ocx service install`을 사용하세요. -업그레이드할 때 현재 검증 가드가 없는 기존 Unix shim은 다시 생성하고 검증합니다. 저장된 런처가 -안전하지 않으면 OpenCodex는 위험한 wrapper를 그대로 두지 않고 구버전 shim을 제거한 뒤 원래 -런처를 복원합니다. +기존 Unix 제자리 shim은 명시적인 `ocx codex-shim install`로만 이전합니다. 기록된 원래 런처를 복원하되 이미 있는 더 최신 런처는 덮어쓰지 않고, 이후 전용 wrapper를 설치합니다. 원래 런처 복원 후 전용 설치가 실패해도 복원된 런처는 유지되며 재시도할 수 있습니다. 기록된 런처가 없거나 실행 불가능하면 패키지 관리자로 복구하세요. 다른 설치를 임의로 선택하거나 패키지 관리자 경로를 다시 감싸지 않습니다. -완료된 외부 Codex 업데이트가 설치된 shim을 덮어쓰면, 다음 일반 `ocx` 명령이 안정적인 새 런처를 -백업하고 명령을 처리하기 전에 shim을 복원합니다. 부작용 없는 검사 명령 `ocx system codex-cli-update check`와 예약된 `ocx system codex-cli-update` namespace의 잘못된 호출은 이 복구를 수행하지 않습니다. 아직 변경 중인 런처는 건드리지 않고 나중에 다시 시도합니다. -복구 실패는 요청한 명령을 실패시키지 않고 경고만 표시합니다. 수동 대체 수단은 `ocx codex-shim install` -입니다. `codexShimAutoRestore`를 `false`로 설정하거나, 프로세스 수준에서 제외하려면 -`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. +Unix 자동 복구는 전용 wrapper만 갱신하며 패키지 관리자 런처를 다시 작성하거나 기존 제자리 shim을 이전하지 않습니다. Windows에서는 완료된 외부 업데이트가 shim을 덮어쓰면 다음 일반 `ocx` 명령이 안정적인 새 런처를 백업하고 shim을 복원합니다. 변경 중인 런처는 건드리지 않고 나중에 재시도합니다. `ocx status`, `ocx doctor`, `ocx codex-shim status`, `ocx system codex-cli-update check`와 해당 예약 namespace의 잘못된 호출은 shim 자동 복구를 실행하지 않습니다. 복구 실패는 요청한 명령의 종료 상태를 바꾸지 않고 경고만 표시합니다. 수동 복구 명령은 `ocx codex-shim install`입니다. `codexShimAutoRestore`를 `false`로 설정하거나 프로세스별로 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정하면 자동 복구를 끕니다. | 하위 명령 | 동작 | | --- | --- | | `install` | shim을 설치합니다(오래된 경우 복구도 수행합니다). | -| `uninstall` | shim을 제거하고 원래 Codex 바이너리를 복원합니다. | +| `uninstall` | Unix 전용 파일만 제거하고 원래 Codex는 유지합니다. Windows에서는 원래 런처를 복원합니다. | | `remove` | `uninstall`의 별칭입니다. | -| `status` | shim 상태(설치됨, 오래됨, 누락)를 보고합니다. | +| `status` | shim 상태와 전용 wrapper의 PATH 활성화 여부를 보고합니다. | ```bash ocx codex-shim install @@ -387,6 +386,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +Unix에서 제거한 뒤 셸 시작 파일의 source 줄을 삭제하고 셸을 다시 시작하거나 PATH에서 전용 bin을 제거하세요. 소유한 wrapper, 셸 환경 파일, 상태만 삭제하며 패키지 관리자 런처는 그대로 둡니다. 기존 제자리 Unix shim은 기록된 복원 정보를 이용해 해제합니다. + :::note[Windows 토큰 환경] 새로 생성된 Windows CMD 및 PowerShell shim은 실행 후 호출자의 `OPENCODEX_API_AUTH_TOKEN`을 원래 상태로 복원합니다. Codex와 자식 프로세스는 여전히 토큰을 상속할 수 있습니다. @@ -408,7 +409,7 @@ OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하 `opencodex-proxy.service`의 `EnvironmentFile=` 또는 `OCX_API_TOKEN_FILE`은 프록시 프로세스만 구성하며, 별도로 실행된 `codex exec`에 전달되지 않습니다. -실행기를 교체하는 Codex 업그레이드는 shim을 제거합니다. 다음 일반 `ocx` 명령이 shim을 복원하지만(위 내용 참조), 그보다 먼저 실행되는 `codex exec`는 실패합니다. `ocx doctor`는 이 상태(env_key 구성됨, 변수 미설정, shim 누락 또는 비정상, 토큰 파일 존재)를 "Codex env_key launch readiness" 항목에서 복구 명령과 함께 보고하며, 토큰은 출력하지 않습니다. 토큰 파일 읽기는 주입된 `env_key`의 계약에 포함되지 않습니다. 실행을 시작하는 프로세스가 해당 변수를 제공해야 합니다. +Unix 패키지 관리자 업데이트는 전용 wrapper를 그대로 둡니다. 비활성 상태라면 출력된 활성화 파일을 읽으세요. Windows 또는 기존 제자리 shim에서는 런처를 교체하는 업데이트가 shim을 제거합니다. Windows에서는 다음 일반 `ocx` 명령이 shim을 복원하지만(위 내용 참조), 그보다 먼저 실행되는 `codex exec`는 실패합니다. `ocx doctor`는 이 상태와 비활성 wrapper(env_key 구성됨, 변수 미설정, shim 누락 또는 비정상, 토큰 파일 존재)를 "Codex env_key launch readiness" 항목에서 복구 또는 활성화 안내와 함께 보고하며, 토큰은 출력하지 않습니다. 토큰 파일 읽기는 주입된 `env_key`의 계약에 포함되지 않습니다. 실행을 시작하는 프로세스가 해당 변수를 제공해야 합니다. ### `ocx tray [--json] [--no-start]` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 028986705c6..e8f6a022175 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -719,20 +719,38 @@ deleted as an unsafe best-effort rollback. ### `ocx codex-shim ` -Wrap a script-based `codex` launcher on PATH with a lightweight autostart script. Real `codex.exe` -targets are left untouched to avoid breaking exact executable invocations. -If installation is refused or the resulting shim is unhealthy, the command exits nonzero and -the dashboard reports the failure reason. A healthy existing shim still counts as success. +On macOS and Linux, install a private autostart wrapper at `/bin/codex` and a +sourceable `/codex-shell-env.sh`, using the resolved OpenCodex home. The native +launcher stays where brew, npm, or fnm installed it; package-manager upgrades and version rollbacks +continue to work without rewrapping that launcher. On Windows, script-based launchers are still +wrapped in place. Real `codex.exe` targets are left untouched. +If installation is refused or the wrapper cannot run, the command exits nonzero and the dashboard +reports the failure reason. A runnable Unix wrapper counts as a successful install even before +activation in the current shell; an already-installed healthy shim also counts as success. For Windows installations that expose only `codex.exe`, use `ocx service install` for autostart. +After brew/fnm and other PATH setup, run the activation command printed by install. With the default +home, it is: + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +For a custom home, use the printed, quoted path. Sourcing is idempotent: it removes duplicate private +bin entries and puts that directory first on PATH. To activate future shells, add that source line +after your package-manager PATH setup yourself; OpenCodex never edits shell startup files. Install +cannot change its parent shell. `ocx status`, `ocx codex-shim status`, `ocx doctor`, and `ocx connect` +report a runnable but unselected wrapper as **not active**, with the activation command. Shell +aliases/functions and separately configured desktop or service launchers still need their own setup. + For fnm-managed Codex, installation resolves the temporary multishell directory to the durable Node installation while preserving the launcher filename. If that directory cannot be resolved, installation refuses instead of wrapping a temporary path. When Codex is selected, `ocx connect` also reports shim readiness. This readiness check skips special-file PATH entries while preserving -the order of regular launchers, including npm and fnm symlinks. If a different PATH wrapper hides a healthy shim, fix PATH order; +the order of executable regular launchers, including npm and fnm symlinks. If a different PATH wrapper hides a runnable shim, fix PATH order; reinstalling the same shim does not change which command your shell finds first. If the tracked shim -is healthy but no `codex` command is found, connect reports it as inactive and asks you to add its -directory to PATH. A failed PATH inspection reports activation as unverified instead of claiming +is runnable but no `codex` command is found, connect reports it as inactive and prints the activation +command. A failed PATH inspection reports activation as unverified instead of claiming that no command exists. These warnings do not change the connect command's exit status. Before an install or repair is committed, OpenCodex runs the saved launcher with `--version` while @@ -744,11 +762,13 @@ use `ocx service install` instead when a dynamic command-manager launcher cannot Cleanup refusals include a bounded diagnostic suffix identifying the probe phase, a recognized native error code or signal, and the exit status when known. It does not include launcher paths or raw child output, and does not relax the validation or rollback checks. -During upgrades, an installed Unix shim that lacks the current validation guard is regenerated and -probed. If its saved launcher is unsafe, OpenCodex removes the obsolete shim and restores the -original launcher instead of leaving the unsafe wrapper installed. +An older Unix shim installed in place is migrated only by an explicit `ocx codex-shim install`. +Migration restores the recorded native launcher without replacing a newer launcher already present, +then installs the private wrapper. If private installation fails after native restoration, the native +launcher stays restored and the operation can be retried. Missing or unusable native launchers require +package-manager repair; OpenCodex does not guess another installation or rewrap the manager's path. -Launcher installation alone does not prove that Codex requests will use OpenCodex. After a healthy +Launcher installation alone does not prove that Codex requests will use OpenCodex. After a runnable install, the command checks the current Codex routing and reports a warning instead of a green result when routing is external, user-owned, or unverifiable. It also warns when outbound proxy variables exist only in the current process while `config.proxy` is unset or unresolved, because Codex @@ -756,24 +776,24 @@ launchers and background services may not inherit that environment. These checks never print proxy values; resolve the reported handoff and run `ocx doctor` before relying on autostart. -If a completed external Codex update overwrites an installed shim, the next ordinary `ocx` command -backs up the stable new launcher and restores the shim before dispatch. The zero-effect +On Unix, automatic repair refreshes only the private wrapper and never rewrites package-manager +launchers or migrates an older in-place shim. On Windows, if a completed external Codex update +overwrites an installed shim, the next ordinary `ocx` command backs up the stable new launcher +and restores the shim before dispatch. The zero-effect `ocx system codex-cli-update check` inspection command and malformed invocations in its reserved -`ocx system codex-cli-update` namespace never perform that repair. -A launcher that is still -changing is left untouched and retried later. Repair failures warn without failing the requested +`ocx system codex-cli-update` namespace never perform that repair. `ocx status`, `ocx doctor`, and +`ocx codex-shim status` also skip shim auto-repair and report the observed state. +A launcher that is still changing is left untouched and retried later. Repair failures warn without failing the requested command; manual fallback: `ocx codex-shim install`. Set `codexShimAutoRestore` to `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. -That restore needs the original launcher OpenCodex saved next to the shim. A version manager — -mise, asdf, volta — rewrites its whole install tree on upgrade, which destroys the shim *and* that -backup, so there is nothing left to restore from. **A version-manager install tree is not a -supported shim target.** OpenCodex reports the condition and stops rather than wrapping the newly -installed binary as a replacement original: doing so would record a history that never happened, and -the next upgrade would overwrite it again, so the repair would silently undo itself on the version -manager's schedule. +The Unix wrapper retains its recorded native launcher path. Package-manager changes behind that +durable entry are followed automatically; removing that entry requires package-manager repair. +Dynamic command-manager launchers still have to pass the validation above. Windows in-place repair +needs the saved original beside the shim; if a version manager destroys the whole tree and backup, +OpenCodex reports the condition and stops instead of inventing a replacement original. -If your `codex` is owned by a version manager, route through Codex configuration instead of the +If your version-manager launcher cannot pass validation, route through Codex configuration instead of the launcher: `ocx start` writes `openai_base_url`, and `ocx service install` provides autostart. Run `ocx status` to confirm — it reports the active routing, and warns when a running proxy is not the one Codex is pointed at. @@ -781,9 +801,9 @@ one Codex is pointed at. | Subcommand | Action | | --- | --- | | `install` | Install the shim (or repair if stale). | -| `uninstall` | Remove the shim and restore the original Codex binary. | +| `uninstall` | Remove private Unix artifacts, leaving native Codex untouched; on Windows, restore the original launcher. | | `remove` | Alias of `uninstall`. | -| `status` | Report shim state (installed, stale, or missing). | +| `status` | Report shim state and whether the private wrapper is active on PATH. | ```bash ocx codex-shim install @@ -791,6 +811,11 @@ ocx codex-shim status ocx codex-shim uninstall ``` +After Unix uninstall, remove the source line from your shell startup file and restart the shell or +remove the private bin entry from its PATH. Uninstall removes the owned wrapper, shell environment +file, and state; it leaves the package-manager launcher untouched. An older in-place Unix shim is +released using its recorded restoration data. + :::note[Windows token environment] Newly generated Windows CMD and PowerShell shims restore the caller's `OPENCODEX_API_AUTH_TOKEN` after execution. Codex and its child processes can still inherit the token. @@ -823,10 +848,13 @@ directly as a systemd `EnvironmentFile=`. An `EnvironmentFile=` or `OCX_API_TOKEN_FILE` on `opencodex-proxy.service` configures the proxy process only and never flows into an independently launched `codex exec`. -A Codex upgrade that replaces the launcher removes the shim; the next ordinary `ocx` command restores +Unix package-manager upgrades leave the private wrapper intact. A runnable wrapper that is **not +active** does not inject the token: source the printed activation file in the launching shell. +On Windows or with an older in-place shim, a Codex upgrade that replaces the launcher removes the shim; +on Windows the next ordinary `ocx` command restores it (see above), but a `codex exec` that runs before that fails. `ocx doctor` reports this exact state under "Codex env_key launch readiness" (env_key configured, variable unset, shim missing or -unhealthy, token file present) with the repair command, and never prints the token. Reading the token +unhealthy, token file present) with repair or activation guidance, and never prints the token. Reading the token file is not part of the injected `env_key` contract; the launching process must supply that variable. ### `ocx tray [--json] [--no-start]` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 77b62ddc79b..068dbf7869c 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -354,8 +354,15 @@ scheduler-error. Чужие задачи и чужие операции нико ### `ocx codex-shim ` -Обернуть script-based launcher `codex` на `PATH` лёгким автозапусковым скриптом. Настоящие -target'ы `codex.exe` не трогаются, чтобы не ломать точные вызовы исполняемого файла. +На macOS и Linux `ocx codex-shim install` создаёт частную обёртку `/bin/codex` и подключаемый файл `/codex-shell-env.sh` в разрешённом каталоге OpenCodex. Нативный launcher остаётся там, где его установили brew, npm или fnm, поэтому обновления и возврат к предыдущей версии не требуют повторного оборачивания. В Windows скриптовые launcher по-прежнему оборачиваются на месте, а настоящий `codex.exe` не изменяется. Если доступен только `codex.exe`, используйте `ocx service install`. + +После настройки PATH для brew/fnm выполните команду активации, напечатанную при установке. Для каталога по умолчанию: + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +Для другого каталога используйте напечатанный путь в кавычках. Повторное подключение удаляет дубликаты частного bin и ставит его первым в PATH. Для будущих оболочек добавьте эту строку самостоятельно после настройки PATH в файле запуска; OpenCodex никогда не редактирует такие файлы. Установка не меняет PATH родительской оболочки. Если обёртка может выполняться, установка успешна даже до активации в текущей оболочке; отказ или неработоспособная обёртка остаются ошибкой. `ocx status`, `ocx codex-shim status`, `ocx doctor` и `ocx connect` сообщают **not active** и команду активации, если PATH не выбирает обёртку. Предупреждения connect не меняют код завершения. Псевдонимы, функции и запуск из настольной среды или служб требуют отдельной настройки. Перед фиксацией установки или repair OpenCodex запускает сохранённый launcher с `--version`, не запуская сервис. Изменение отклоняется и откатывается, если launcher снова разрешает `codex` @@ -364,25 +371,16 @@ target'ы `codex.exe` не трогаются, чтобы не ломать то безусловной установкой. После отказа переустановите Codex так, чтобы запись в `PATH` указывала на конкретный исполняемый файл или launcher, и повторите попытку. Если динамический launcher менеджера команд не проходит эти проверки, используйте вместо него `ocx service install`. -При обновлении установленный Unix shim без текущей validation-защиты пересоздаётся и проверяется. -Если сохранённый launcher небезопасен, OpenCodex удаляет устаревший shim и восстанавливает исходный -launcher, а не оставляет небезопасный wrapper установленным. - -Если завершённое внешнее обновление Codex перезаписало установленный shim, следующая обычная -команда `ocx` сохранит новый стабильный launcher и восстановит shim перед выполнением запроса. -Не имеющая побочных эффектов команда инспекции `ocx system codex-cli-update check` и некорректные -вызовы зарезервированного пространства `ocx system codex-cli-update` никогда не выполняют этот repair. -Launcher, который всё ещё меняется, не трогается, а попытка откладывается до следующего раза. -Сбои repair'а приводят только к warning и не ломают запрошенную команду; ручной запасной путь — -`ocx codex-shim install`. Чтобы отключить автоматику, задайте `codexShimAutoRestore: false` или -установите `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. +Старый Unix shim, установленный на месте, переносится только явной командой `ocx codex-shim install`. Записанный нативный launcher восстанавливается без замены уже имеющегося более нового launcher, затем устанавливается частная обёртка. При последующей ошибке установки нативный launcher остаётся восстановленным, а операцию можно повторить. Если записанный launcher отсутствует или не работает, исправьте установку через менеджер пакетов: OpenCodex не выбирает другую установку и не оборачивает заново путь менеджера. + +На Unix автоматическое исправление обновляет только частную обёртку: пути менеджера пакетов не перезаписываются, старые shim на месте не переносятся. На Windows после завершённого внешнего обновления, заменившего shim, следующая обычная команда `ocx` сохраняет новый стабильный launcher и восстанавливает shim. Меняющийся launcher остаётся нетронутым до повторной попытки. `ocx status`, `ocx doctor`, `ocx codex-shim status`, `ocx system codex-cli-update check` и некорректные вызовы его зарезервированного пространства не запускают это исправление. Ошибки приводят к предупреждению без изменения кода завершения запрошенной команды. Ручной вариант: `ocx codex-shim install`. Отключение: `codexShimAutoRestore: false` или `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` для процесса. | Подкоманда | Действие | | --- | --- | | `install` | Установить shim (или починить, если он устарел). | -| `uninstall` | Удалить shim и восстановить исходный бинарник Codex. | +| `uninstall` | Удалить частные файлы Unix, сохранив нативный Codex; на Windows восстановить исходный launcher. | | `remove` | Alias команды `uninstall`. | -| `status` | Показать состояние shim'а (installed, stale или missing). | +| `status` | Показать состояние shim и активность частной обёртки в PATH. | ```bash ocx codex-shim install @@ -390,6 +388,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +После удаления на Unix уберите строку подключения из файла запуска оболочки и перезапустите её либо удалите частный bin из PATH. Удаляются только принадлежащие OpenCodex обёртка, файл окружения и состояние; launcher менеджера пакетов остаётся нетронутым. Старый Unix shim на месте освобождается по записанным данным восстановления. + :::note[Окружение токена в Windows] Новые обёртки для Windows CMD и PowerShell восстанавливают исходное состояние `OPENCODEX_API_AUTH_TOKEN` в вызывающей оболочке после выполнения. Codex и его дочерние процессы по-прежнему могут унаследовать токен. @@ -412,7 +412,7 @@ ocx codex-shim uninstall `EnvironmentFile=` или `OCX_API_TOKEN_FILE` в `opencodex-proxy.service` настраивает только процесс прокси и никогда не передаётся в независимо запущенный `codex exec`. -Обновление Codex, заменяющее средство запуска, удаляет обёртку; следующая обычная команда `ocx` восстанавливает её (см. выше), но `codex exec`, запущенный до этого, завершается ошибкой. `ocx doctor` сообщает именно об этом состоянии в разделе "Codex env_key launch readiness" (env_key настроен, переменная не задана, обёртка отсутствует или неисправна, файл токена присутствует), приводит команду исправления и никогда не выводит токен. Чтение файла токена не входит в контракт внедрённого `env_key`; запускающий процесс должен передать эту переменную. +На Unix обновления менеджера пакетов сохраняют частную обёртку. Если она не активна, подключите напечатанный файл активации. На Windows или со старым shim на месте обновление, заменяющее launcher, удаляет обёртку; на Windows следующая обычная команда `ocx` восстанавливает её (см. выше), но `codex exec`, запущенный до этого, завершается ошибкой. `ocx doctor` сообщает об этом состоянии и неактивной обёртке в разделе "Codex env_key launch readiness" (env_key настроен, переменная не задана, обёртка отсутствует или неисправна, файл токена присутствует), приводит инструкции исправления или активации и никогда не выводит токен. Чтение файла токена не входит в контракт внедрённого `env_key`; запускающий процесс должен передать эту переменную. ### `ocx tray [--json] [--no-start]` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 700b7da51c4..367c009350d 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -432,9 +432,15 @@ olarak başarısız olmaya devam eder. ### `ocx codex-shim ` -PATH üzerindeki betik tabanlı bir `codex` başlatıcısını hafif bir otomatik -başlatma betiği ile sarın. Tam yürütülebilir çağrıları bozmaktan kaçınmak için -gerçek `codex.exe` hedefleri dokunulmadan bırakılır. +macOS ve Linux'ta `ocx codex-shim install`, çözümlenen OpenCodex dizininde özel `/bin/codex` sarmalayıcısını ve kabuğa yüklenecek `/codex-shell-env.sh` dosyasını oluşturur. Yerel başlatıcı brew, npm veya fnm'nin kurduğu yerde kalır; paket yöneticisi güncellemeleri ve önceki sürüme dönüşler yeniden sarmalama gerektirmez. Windows'ta betik başlatıcıları mevcut konumlarında sarılmaya devam eder, gerçek `codex.exe` değiştirilmez. Yalnızca `codex.exe` varsa `ocx service install` kullanın. + +brew/fnm PATH ayarlarından sonra kurulumun yazdırdığı etkinleştirme komutunu çalıştırın. Varsayılan dizinde: + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +Özel bir dizin için yazdırılan tırnaklı yolu kullanın. Dosyayı tekrar yüklemek özel bin tekrarlarını kaldırıp dizini PATH'in başına koyar. Gelecekteki kabuklar için bu satırı başlangıç dosyasındaki PATH ayarlarından sonra kendiniz ekleyin; OpenCodex kabuk başlangıç dosyalarını düzenlemez. Kurulum üst kabuğun PATH'ini değiştiremez. Sarmalayıcı çalıştırılabiliyorsa geçerli kabukta henüz etkin olmasa da kurulum başarılıdır; reddedilen kurulum veya çalışmayan sarmalayıcı hata verir. `ocx status`, `ocx codex-shim status`, `ocx doctor` ve `ocx connect`, PATH sarmalayıcıyı seçmiyorsa **not active** durumunu ve etkinleştirme komutunu gösterir. connect uyarıları çıkış kodunu değiştirmez. Takma adlar, işlevler, masaüstü ve servis başlatmaları ayrıca ayarlanmalıdır. Bir kurulum veya onarım uygulanmadan önce OpenCodex servis başlangıcı atlanırken kaydedilen başlatıcıyı `--version` ile çalıştırır. Başlatıcı `codex`'i tekrar @@ -444,13 +450,12 @@ temizlenemediğinde değişikliği reddeder ve geri alır. Bu nedenle `codex-shi install` koşulsuz değildir. Reddedilirse PATH girdisinin somut bir yürütülebilir dosya veya başlatıcı olması için Codex'i yeniden yükleyin ve yeniden deneyin; dinamik bir komut yöneticisi başlatıcısı bu denetimleri karşılayamadığında bunun -yerine `ocx service install` kullanın. Yükseltmeler sırasında geçerli doğrulama -korumasından yoksun kurulu bir Unix dolgusu yeniden oluşturulur ve araştırılır. -Kaydedilen başlatıcısı güvenli değilse OpenCodex güvensiz sarmalayıcıyı kurulu -bırakmak yerine eski dolguyu kaldırır ve orijinal başlatıcıyı geri yükler. +yerine `ocx service install` kullanın. + +Eski, yerinde kurulu Unix shim yalnızca açıkça çalıştırılan `ocx codex-shim install` ile taşınır. Kaydedilen yerel başlatıcı, mevcut daha yeni başlatıcının üzerine yazılmadan geri yüklenir; ardından özel sarmalayıcı kurulur. Geri yüklemeden sonra özel kurulum başarısız olsa da yerel başlatıcı geri yüklenmiş kalır ve işlem yeniden denenebilir. Kayıtlı başlatıcı yoksa veya çalışmıyorsa paket yöneticisiyle onarın; OpenCodex başka bir kurulumu tahmin etmez ve yöneticinin yolunu yeniden sarmalamaz. Yalnızca başlatıcı kurulumu Codex isteklerinin OpenCodex kullanacağını -kanıtlamaz. Sağlıklı bir kurulumdan sonra komut geçerli Codex yönlendirmesini +kanıtlamaz. Çalıştırılabilir bir sarmalayıcı kurulduktan sonra komut geçerli Codex yönlendirmesini kontrol eder ve yönlendirme harici, kullanıcıya ait veya doğrulanamaz olduğunda yeşil bir sonuç yerine bir uyarı bildirir. Ayrıca giden proxy değişkenleri yalnızca geçerli süreçte mevcutken `config.proxy` ayarlanmadığında veya @@ -459,21 +464,14 @@ ortamı devralmayabilir. Bu denetimler salt okunurdur ve asla proxy değerlerini yazdırmaz; otomatik başlatmaya güvenmeden önce bildirilen devri çözün ve `ocx doctor` çalıştırın. -Tamamlanan harici bir Codex güncellemesi kurulu bir dolgunun üzerine yazarsa -sonraki sıradan `ocx` komutu kararlı yeni başlatıcıyı yedekler ve dağıtımdan -önce dolguyu geri yükler. Sıfır etkili `ocx system codex-cli-update check` denetim -komutu ile ayrılmış `ocx system codex-cli-update` ad alanındaki hatalı çağrılar bu onarımı asla yapmaz. Hala değişmekte olan bir başlatıcı dokunulmadan -bırakılır ve daha sonra yeniden denenir. Onarım arızaları talep edilen komutu -başarısız kılmadan uyarır; manuel geri dönüş: `ocx codex-shim install`. Süreç -düzeyinde bir vazgeçme için `codexShimAutoRestore`'u `false` olarak ayarlayın -veya `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` ayarlayın. +Unix'te otomatik onarım yalnızca özel sarmalayıcıyı yeniler; paket yöneticisinin başlatıcılarını yeniden yazmaz veya eski yerinde shim'i taşımaz. Windows'ta tamamlanan harici güncelleme shim'i değiştirirse sonraki sıradan `ocx` komutu kararlı yeni başlatıcıyı yedekleyip shim'i geri yükler. Değişmekte olan başlatıcıya dokunulmaz ve daha sonra yeniden denenir. `ocx status`, `ocx doctor`, `ocx codex-shim status`, `ocx system codex-cli-update check` ve ayrılmış ad alanındaki hatalı çağrılar bu onarımı yapmaz. Onarım hataları istenen komutun çıkış kodunu değiştirmeden uyarı verir. Elle onarım: `ocx codex-shim install`. Devre dışı bırakmak için `codexShimAutoRestore` değerini `false` yapın veya süreç için `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` ayarlayın. | Alt komut | Eylem | | --- | --- | | `install` | Dolguyu kurun (veya eskiyse onarın). | -| `uninstall` | Dolguyu kaldırın ve orijinal Codex ikili dosyasını geri yükleyin. | +| `uninstall` | Unix özel dosyalarını kaldırır, yerel Codex kalır; Windows’ta özgün başlatıcıyı geri yükler. | | `remove` | `uninstall`'ın takma adıdır. | -| `status` | Dolgu durumunu bildirin (kurulu, eski veya eksik). | +| `status` | shim durumunu ve özel sarmalayıcının PATH üzerinde etkin olup olmadığını bildirir. | ```bash ocx codex-shim install @@ -481,6 +479,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +Unix'te kaldırdıktan sonra başlangıç dosyasındaki yükleme satırını silin ve kabuğu yeniden başlatın veya özel bin girdisini PATH'ten çıkarın. Yalnızca sahip olunan sarmalayıcı, kabuk ortam dosyası ve durum kaldırılır; paket yöneticisinin başlatıcısına dokunulmaz. Eski yerinde Unix shim kayıtlı geri yükleme bilgileriyle kaldırılır. + :::tip[Servis mi Dolgu mu?] Her zaman açık bir arka plan proxy'si için `ocx service` kullanın (önerilir). Bir arka plan programı olmadan hafif, isteğe bağlı başlatma için `ocx diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 3807ea6e227..ef7ec92a34a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -217,22 +217,30 @@ ocx service uninstall ### `ocx codex-shim ` -在 PATH 上把基于脚本的 `codex` 启动器包装为一个轻量自启动脚本。真实的 `codex.exe` 目标会保持不变,以避免破坏精确的可执行文件调用。 +在 macOS 和 Linux 上,`ocx codex-shim install` 会在解析后的 OpenCodex 主目录中安装私有 wrapper `/bin/codex` 和可由 shell 加载的 `/codex-shell-env.sh`。原生启动器保留在 brew、npm 或 fnm 安装的位置,包管理器升级和版本回滚无需重新包装。Windows 仍按原方式就地包装脚本启动器,真实的 `codex.exe` 保持不变。如果 Windows 安装只提供 `codex.exe`,请使用 `ocx service install`。 + +完成 brew/fnm 等 PATH 设置后,运行安装时输出的激活命令。默认主目录对应: + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +自定义主目录请使用输出的带引号路径。重复加载会去掉私有 bin 的重复项,并将其放到 PATH 最前面。要在之后的 shell 中激活,请自行把这一行加到 shell 启动文件的 PATH 设置之后;OpenCodex 从不编辑启动文件,安装命令也无法改变父 shell 的 PATH。只要 wrapper 可运行,即使当前 shell 尚未激活,安装仍算成功;安装被拒绝或 wrapper 无法运行则失败。`ocx status`、`ocx codex-shim status`、`ocx doctor` 和 `ocx connect` 会将未被 PATH 选中的 wrapper 报告为 **not active**,并给出激活命令。connect 的警告不改变退出状态。别名、函数以及桌面或服务启动环境需要单独设置。 提交安装或修复前,OpenCodex 会在跳过服务启动的情况下,用 `--version` 运行已保存的启动器。如果启动器把 `codex` 再次解析到 shim、以非零状态退出、运行超过五秒、留下仍在运行的子进程,或无法被安全验证和清理,OpenCodex 会拒绝并回滚更改。因此 `codex-shim install` 并不是无条件安装。若被拒绝,请重新安装 Codex,使 PATH 条目指向具体的可执行文件或启动器,然后重试;如果动态命令管理器的启动器无法满足这些检查,请改用 `ocx service install`。 -升级时,缺少当前验证保护的已安装 Unix shim 会被重新生成并接受探测。如果保存的启动器不安全,OpenCodex 会移除旧 shim 并恢复原始启动器,而不是保留不安全的 wrapper。 +旧的 Unix 就地 shim 仅通过显式运行 `ocx codex-shim install` 迁移。迁移会恢复记录的原生启动器,但不会覆盖已经存在的较新启动器,然后安装私有 wrapper。如果原生恢复后私有安装失败,原生启动器会保持已恢复状态,操作可重试。记录的启动器缺失或无法运行时,请用包管理器修复;OpenCodex 不会猜测其他安装位置,也不会重新包装包管理器路径。 -仅安装启动器并不能证明 Codex 请求会经过 OpenCodex。完成健康安装后,命令会检查当前 Codex 路由;当路由由外部配置、用户自有网关管理或无法验证时,会显示警告而不是绿色成功。若出站代理变量只存在于当前进程,而 `config.proxy` 未设置或无法解析,也会给出警告,因为 Codex 启动器和后台服务未必继承该环境。这些检查只读且绝不会打印代理值;在依赖自动启动前,请先处理提示的交接配置并运行 `ocx doctor`。 +仅安装启动器并不能证明 Codex 请求会经过 OpenCodex。安装可运行的 wrapper 后,命令会检查当前 Codex 路由;当路由由外部配置、用户自有网关管理或无法验证时,会显示警告而不是绿色成功。若出站代理变量只存在于当前进程,而 `config.proxy` 未设置或无法解析,也会给出警告,因为 Codex 启动器和后台服务未必继承该环境。这些检查只读且绝不会打印代理值;在依赖自动启动前,请先处理提示的交接配置并运行 `ocx doctor`。 -如果已完成的外部 Codex 更新覆盖了已安装的 shim,下一次普通的 `ocx` 命令会先备份稳定的新启动器,再在分发前恢复 shim。零副作用的检查命令 `ocx system codex-cli-update check` 和保留的 `ocx system codex-cli-update` 命名空间中的无效调用都不会执行这项修复。仍在变动中的启动器会保持不动,并在稍后重试。修复失败只会警告,不会让所请求的命令失败;手动回退:`ocx codex-shim install`。将 `codexShimAutoRestore` 设为 `false`,或设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可在进程级别关闭自动恢复。 +Unix 自动修复只刷新私有 wrapper,绝不重写包管理器启动器,也不迁移旧的就地 shim。Windows 上,完成的外部更新覆盖 shim 后,下一个普通 `ocx` 命令会备份稳定的新启动器并恢复 shim。仍在变化的启动器保持不动,稍后重试。`ocx status`、`ocx doctor`、`ocx codex-shim status`、`ocx system codex-cli-update check` 及其保留命名空间中的无效调用都不触发这项修复。修复失败只发出警告,不改变请求命令的退出状态;手动修复:`ocx codex-shim install`。将 `codexShimAutoRestore` 设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可关闭自动修复。 | 子命令 | 操作 | | --- | --- | | `install` | 安装 shim(或在过期时修复)。 | -| `uninstall` | 移除 shim 并恢复原始 Codex 二进制。 | +| `uninstall` | 删除 Unix 私有文件,保留原生 Codex;Windows 上恢复原始启动器。 | | `remove` | `uninstall` 的别名。 | -| `status` | 报告 shim 状态(已安装、过期或缺失)。 | +| `status` | 报告 shim 状态及私有 wrapper 是否在 PATH 中激活。 | ```bash ocx codex-shim install @@ -240,6 +248,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +Unix 卸载后,请从 shell 启动文件中删除加载行,并重启 shell 或从 PATH 中移除私有 bin。卸载仅删除归 OpenCodex 所有的 wrapper、shell 环境文件和状态,包管理器启动器保持不变。旧的 Unix 就地 shim 会利用已记录的恢复信息解除包装。 + :::note[Windows 令牌环境] 新生成的 Windows CMD 和 PowerShell shim 会在执行后恢复调用方原有的 `OPENCODEX_API_AUTH_TOKEN` 状态。Codex 及其子进程仍可能继承令牌。 @@ -260,7 +270,7 @@ ocx codex-shim uninstall `opencodex-proxy.service` 中的 `EnvironmentFile=` 或 `OCX_API_TOKEN_FILE` 仅配置代理进程,绝不会传入独立启动的 `codex exec`。 -替换启动器的 Codex 升级会移除 shim;下一次执行普通的 `ocx` 命令时会将其恢复(见上文),但在此之前运行的 `codex exec` 会失败。`ocx doctor` 会在 "Codex env_key launch readiness" 项下报告这一确切状态(env_key 已配置、变量未设置、shim 缺失或不正常、令牌文件存在),并给出修复命令,且绝不会输出令牌。读取令牌文件不属于注入的 `env_key` 的约定;启动进程必须提供该变量。 +Unix 包管理器升级保留私有 wrapper;未激活时,请加载输出的激活文件。Windows 或旧的就地 shim 在启动器被更新替换后会丢失 shim;Windows 上下一次执行普通的 `ocx` 命令时会将其恢复(见上文),但在此之前运行的 `codex exec` 会失败。`ocx doctor` 会在 "Codex env_key launch readiness" 项下报告这一状态及未激活的 wrapper(env_key 已配置、变量未设置、shim 缺失或不正常、令牌文件存在),并给出修复或激活指引,且绝不会输出令牌。读取令牌文件不属于注入的 `env_key` 的约定;启动进程必须提供该变量。 ### `ocx tray [--json] [--no-start]` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 667ca5019a3..fee5217a41a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -226,16 +226,26 @@ ocx service uninstall ### `ocx codex-shim ` -在 PATH 上以輕量自動啟動腳本包裝基於腳本的 `codex` 啟動器。真實的 `codex.exe` 目標保持不動,以避免破壞精確的可執行檔呼叫。 +在 macOS 與 Linux 上,`ocx codex-shim install` 會在解析後的 OpenCodex 主目錄安裝私有 wrapper `/bin/codex` 與可由 shell 載入的 `/codex-shell-env.sh`。原生啟動器保留在 brew、npm 或 fnm 安裝的位置,套件管理器升級及版本回復不需重新包裝。Windows 仍依原方式就地包裝指令碼啟動器,真實的 `codex.exe` 保持不動。若 Windows 安裝只提供 `codex.exe`,請使用 `ocx service install`。 -若已完成的外部 Codex 更新覆寫了已安裝的 shim,下一個普通 `ocx` 指令會備份穩定的新啟動器並在分派前還原 shim。零副作用的檢查指令 `ocx system codex-cli-update check` 與保留的 `ocx system codex-cli-update` 命名空間中的無效呼叫都不會執行此修復。仍在變動中的啟動器保持不動並稍後重試。修復失敗會發出警告但不會使請求的指令失敗;手動後備:`ocx codex-shim install`。將 `codexShimAutoRestore` 設為 `false`,或設定 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` 以進行行程層級的退出。 +完成 brew/fnm 等 PATH 設定後,執行安裝時顯示的啟用指令。預設主目錄對應: + +```sh +. "$HOME/.opencodex/codex-shell-env.sh" +``` + +自訂主目錄請使用顯示的含引號路徑。重複載入會移除私有 bin 的重複項目,並將其放在 PATH 最前面。要在之後的 shell 啟用,請自行把這一行加在啟動檔的 PATH 設定之後;OpenCodex 從不編輯 shell 啟動檔,安裝指令也無法變更父 shell 的 PATH。只要 wrapper 可以執行,即使目前 shell 尚未啟用,安裝仍算成功;遭拒或無法執行則失敗。`ocx status`、`ocx codex-shim status`、`ocx doctor` 與 `ocx connect` 會將未被 PATH 選取的 wrapper 回報為 **not active**,並提供啟用指令。connect 警告不會改變結束狀態。別名、函式、桌面或服務的啟動環境需要另外設定。 + +舊的 Unix 就地 shim 只會透過明確執行 `ocx codex-shim install` 移轉。移轉會還原記錄的原生啟動器,但不覆寫已存在的較新啟動器,之後才安裝私有 wrapper。若還原後私有安裝失敗,原生啟動器會維持已還原狀態,操作可以重試。記錄的啟動器遺失或無法執行時,請透過套件管理器修復;OpenCodex 不會猜測其他安裝位置,也不會重新包裝套件管理器路徑。 + +Unix 自動修復只更新私有 wrapper,絕不重寫套件管理器啟動器,也不移轉舊的就地 shim。Windows 上,完成的外部更新覆寫 shim 後,下一個普通 `ocx` 指令會備份穩定的新啟動器並還原 shim。仍在變動的啟動器保持不動,稍後重試。`ocx status`、`ocx doctor`、`ocx codex-shim status`、`ocx system codex-cli-update check` 與其保留命名空間中的無效呼叫都不觸發這項修復。修復失敗只發出警告,不改變請求指令的結束狀態;手動修復:`ocx codex-shim install`。將 `codexShimAutoRestore` 設為 `false`,或為行程設定 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可關閉自動修復。 | 子指令 | 動作 | | --- | --- | | `install` | 安裝 shim(若過時則修復)。 | -| `uninstall` | 移除 shim 並還原原始 Codex 二進位檔。 | +| `uninstall` | 移除 Unix 私有檔案,保留原生 Codex;Windows 上還原原始啟動器。 | | `remove` | `uninstall` 的別名。 | -| `status` | 回報 shim 狀態(已安裝、過時或缺失)。 | +| `status` | 回報 shim 狀態及私有 wrapper 是否在 PATH 中啟用。 | ```bash ocx codex-shim install @@ -243,6 +253,8 @@ ocx codex-shim status ocx codex-shim uninstall ``` +Unix 解除安裝後,請從 shell 啟動檔移除載入行,並重新啟動 shell 或從 PATH 移除私有 bin。只移除屬於 OpenCodex 的 wrapper、shell 環境檔與狀態,套件管理器啟動器保持不動。舊的 Unix 就地 shim 會利用已記錄的還原資訊解除包裝。 + :::tip[服務 vs Shim] 使用 `ocx service` 作為常駐背景代理(推薦)。使用 `ocx codex-shim` 作為輕量、按需啟動而無 daemon——代理僅在 `codex` 啟動時才啟動。 ::: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 49dbea99f4a..0e17ffc3697 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,11 @@ "version": 1, "root": "tests", "explicit": { + "codex-shim-overlay.test.ts": "codex-integration", + "codex-shim-migration.test.ts": "codex-integration", + "codex-shim-overlay-transaction.test.ts": "codex-integration", + "codex-shim-overlay-readiness.test.ts": "codex-integration", + "codex-shim-overlay-candidates.test.ts": "codex-integration", "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", "antigravity-discovered-families.test.ts": "adapters/google", diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index e650b8efcb2..740c0eb2e4b 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -17,6 +17,7 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = { export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean { if (command === "uninstall" || command === "remove") return true; + if (command === "status" || command === "doctor") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; // `resolve` is read-only inspection for embedding shells: a lookup made to populate @@ -29,7 +30,7 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str // or future actions. A later `apply` implementation must own its preflight. if (command === "system" && args[1] === "codex-cli-update") return true; if (command === "__update-badge") return true; - return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? ""); + return command === "codex-shim" && ["install", "status", "uninstall", "remove"].includes(args[1] ?? ""); } export function maybeAutoRestoreCodexShim( diff --git a/src/cli/codex-shim-readiness.ts b/src/cli/codex-shim-readiness.ts index 9a3828a2042..e1b25a6e96a 100644 --- a/src/cli/codex-shim-readiness.ts +++ b/src/cli/codex-shim-readiness.ts @@ -4,6 +4,7 @@ import { type CodexRoutingKind, } from "../codex/inject"; import { diagnoseCodexShim, type CodexShimDiagnostic } from "../codex/shim"; +import { overlayActivationHint } from "../codex/shim-overlay"; import { findFirstCodexOnPath, type CodexPathCandidate } from "../codex/shim-path-resolution"; import { loadConfig, resolveEnvValue } from "../config"; @@ -32,7 +33,7 @@ export interface CodexConnectShimReadiness { } export interface CodexConnectShimInspectionDeps { - diagnose?: () => Pick; + diagnose?: () => Pick; findOnPath?: () => CodexPathCandidate | null; } @@ -42,10 +43,29 @@ const CODEX_TOKEN_NOTE = "The connected Codex config uses OPENCODEX_API_AUTH_TOK const CODEX_TOKEN_ACTION = `${CODEX_TOKEN_NOTE} Run 'ocx codex-shim install' to repair it.`; export function codexConnectShimReadiness(inputs: { - diagnosis: Pick; + diagnosis: Pick; commandPath: string | null; commandIsShim?: boolean; }): CodexConnectShimReadiness { + if (inputs.diagnosis.runnable && inputs.diagnosis.active !== undefined) { + if (inputs.diagnosis.active === null) { + return { + status: "unverified", + message: `PATH activation could not be verified. Shim state: ${inputs.diagnosis.summary}. ` + + `Check PATH and retry 'ocx connect'. ${CODEX_TOKEN_NOTE}`, + }; + } + if (!inputs.diagnosis.active) { + return { + status: "missing", + message: `installed but not active; ${inputs.commandPath + ? `PATH resolves 'codex' to ${inputs.commandPath}` + : "no 'codex' executable was found on PATH"}. ` + + `Shim state: ${inputs.diagnosis.summary}. ${overlayActivationHint()} ${CODEX_TOKEN_NOTE}`, + }; + } + return { status: "ready", message: "installed and healthy" }; + } if (inputs.diagnosis.installed && !inputs.diagnosis.healthy) { return { status: "unhealthy", @@ -87,7 +107,7 @@ export function codexConnectShimReadiness(inputs: { export function inspectCodexShimForConnect( deps: CodexConnectShimInspectionDeps = {}, ): CodexConnectShimReadiness { - let diagnosis: Pick; + let diagnosis: Pick; try { diagnosis = (deps.diagnose ?? diagnoseCodexShim)(); } catch { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 0ede901fcb6..42e314b0867 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -702,15 +702,20 @@ const commandRunners: Record = { switch (deps.args[1]) { case "install": { const r = installCodexShim(); - const { healthy, summary } = diagnoseCodexShim(); + const { healthy, runnable, active, summary } = diagnoseCodexShim(); + const success = !r.refused && (runnable ?? healthy); const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness"); - const warnings = healthy + const warnings = success ? collectCodexShimReadinessWarnings() : []; - console.log(`${r.installed && warnings.length === 0 ? "✅ " : "⚠️ "}${r.message}`); + console.log(`${success && healthy && warnings.length === 0 ? "✅ " : "⚠️ "}${r.message}`); for (const warning of warnings) console.warn(` ${warning}`); - if (!healthy) console.error(`Codex shim installation is unhealthy: ${summary}`); - return healthy ? 0 : 1; + if (success && active !== undefined && active !== true) { + const { overlayActivationHint } = await import("../codex/shim-overlay"); + console.warn(` ${overlayActivationHint()}`); + } + if (!success) console.error(`${r.refused ? "Codex shim installation was refused" : "Codex shim installation is unhealthy"}: ${summary}`); + return success ? 0 : 1; } case "status": console.log(codexShimStatus()); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 0d7266da59c..9f20ca818ff 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -33,6 +33,7 @@ import { } from "../codex/subagent-model-fallback"; import { readCatalog, readCodexCatalogPath, readConfiguredDefaultModel } from "../codex/catalog/parsing"; import { diagnoseCodexShim, findCodexOnPath, isWindowsInteropDir, type CodexShimDiagnostic } from "../codex/shim"; +import { overlayActivationHint } from "../codex/shim-overlay"; import { providerTableString, rootTomlString } from "../codex/injected-marker"; import { countPendingOpencodexHistory } from "../codex/history-provider"; import { @@ -481,7 +482,7 @@ export function collectProviderApiKeyDiagnostics( export type CodexEnvKeyReadinessDiagnostic = { envName: string; - shimState: "missing" | "unhealthy"; + shimState: "missing" | "unhealthy" | "inactive"; detail: string; action: string; }; @@ -497,12 +498,14 @@ export function collectCodexEnvKeyReadiness( const envName = providerTableString(configText, "opencodex", "env_key")?.trim(); const envValue = envName ? ownEnvValue(env, envName) : undefined; if (!envName || envValue?.trim() || shim.healthy || !serviceTokenPresent) return null; - const shimState = shim.installed ? "unhealthy" : "missing"; + const shimState = shim.runnable && shim.active === false ? "inactive" : shim.installed ? "unhealthy" : "missing"; return { envName, shimState, detail: `Codex uses env_key ${envName}, but that variable is unset and the OpenCodex shim is ${shimState}; the service token file exists but plain Codex does not load it`, - action: `Run 'ocx codex-shim install' to repair launch-time token injection, or export ${envName} in the process that starts Codex`, + action: shimState === "inactive" + ? `${overlayActivationHint()} Or export ${envName} in the process that starts Codex` + : `Run 'ocx codex-shim install' to repair launch-time token injection, or export ${envName} in the process that starts Codex`, }; } diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index ff6e9098662..766c9d99fcd 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -29,6 +29,7 @@ import type { NormalizedComboConfig } from "../../combos/types"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { decodeOverlayState } from "../shim-state-file"; import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; @@ -196,14 +197,20 @@ export function codexCommandCandidates(): string[] { export function codexShimCommandCandidates(): string[] { try { - const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as { + const configDir = getConfigDir(); + const state = JSON.parse(readFileSync(join(configDir, "codex-shim.json"), "utf8")) as { + schema?: unknown; + mode?: unknown; wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown; wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>; }; + const overlay = decodeOverlayState(state, configDir); + if ((state.schema !== undefined || state.mode !== undefined) && !overlay) return []; const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state]; - const out: string[] = []; + const out: string[] = overlay && isSpawnableCodexCandidate(overlay.launcherPath) + ? [overlay.launcherPath] : []; for (const file of files) { for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) { if (typeof value !== "string" || value.length === 0) continue; diff --git a/src/codex/features.ts b/src/codex/features.ts index a52e9fc46e6..55de07fde31 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -38,6 +38,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl"; import { CODEX_CONFIG_PATH } from "./paths"; import { resolveAndPersistCodexRuntime } from "./runtime"; +import { decodeOverlayState } from "./shim-state-file"; import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy"; /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */ @@ -1232,13 +1233,19 @@ function codexNativeBinaryCandidates(command: string): string[] { /** Backing paths tied to this exact OCX shim entry in codex-shim.json. */ function selectedShimBackingPaths(commandPath: string): string[] { try { - const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as { + const configDir = getConfigDir(); + const state = JSON.parse(readFileSync(join(configDir, "codex-shim.json"), "utf8")) as { + schema?: unknown; + mode?: unknown; wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown; realPath?: unknown; wrappers?: Array>; }; + const overlay = decodeOverlayState(state, configDir); + if (overlay) return resolve(overlay.wrapperPath) === resolve(commandPath) ? [overlay.launcherPath] : []; + if (state.schema !== undefined || state.mode !== undefined) return []; const entries = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state]; const selected = resolve(commandPath); for (const entry of entries) { diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 54011e0514f..27ce62c5a6f 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -5,6 +5,7 @@ import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; import { resolveCodexHomeDir } from "./home"; +import { decodeOverlayState } from "./shim-state-file"; import { redactSecretString, redactUserPath } from "../lib/redact"; export type CodexRuntimeSource = @@ -516,13 +517,18 @@ function shimCandidates(deps: ResolveCodexRuntimeDeps): string[] { const platform = deps.platform ?? process.platform; try { const state = JSON.parse(read(join(configDir, "codex-shim.json"), "utf8")) as { + schema?: unknown; + mode?: unknown; wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown; wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>; }; + const overlay = decodeOverlayState(state, configDir); + if ((state.schema !== undefined || state.mode !== undefined) && !overlay) return []; const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state]; - const out: string[] = []; + const out: string[] = overlay && isSpawnableCodexCandidate(overlay.launcherPath, platform) + ? [overlay.launcherPath] : []; for (const file of files) { for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) { if (typeof value !== "string" || value.length === 0) continue; diff --git a/src/codex/shim-diagnostics.ts b/src/codex/shim-diagnostics.ts new file mode 100644 index 00000000000..37909fe211f --- /dev/null +++ b/src/codex/shim-diagnostics.ts @@ -0,0 +1,75 @@ +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates"; +import { readStateResult, stateFiles, statePath } from "./shim-state-file"; +import { overlayDiagnostic } from "./shim-overlay"; + +export function isShim(path: string): boolean { + try { + return readFileSync(path, "utf8").includes(SHIM_MARKER); + } catch { + return false; + } +} + +function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { + try { + const content = readFileSync(path, "utf8"); + if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false; + if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false; + if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false; + return true; + } catch { + return false; + } +} + +export interface CodexShimDiagnostic { + installed: boolean; + healthy: boolean; + summary: string; + runnable?: boolean; + active?: boolean | null; +} + +/** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */ +export function diagnoseCodexShim(): CodexShimDiagnostic { + const result = readStateResult(); + const state = result.state; + if (!state) { + if (result.present) { + return { + installed: true, + healthy: false, + runnable: false, + active: null, + summary: result.warning ?? `Codex autostart shim state is invalid or corrupt at ${statePath()}. Reinstall or remove the shim.`, + }; + } + return { + installed: false, + healthy: false, + summary: "Codex autostart shim is not installed.", + }; + } + if (state.mode === "path-overlay") return overlayDiagnostic(state); + const files = stateFiles(state); + const healthy = files.length > 0 && files.every(file => file.preserveOnly + ? existsSync(file.backupPath) && !existsSync(file.originalPath) + : existsSync(file.wrapperPath) + && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false)) + && isHealthyShim(file.wrapperPath, state.platform)); + const summary = files.map(file => { + const wrapper = existsSync(file.wrapperPath) + ? isShim(file.wrapperPath) + ? "shim present" + : "present but not an opencodex shim" + : "missing"; + const backup = existsSync(file.backupPath) ? "present" : "missing"; + return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`; + }).join("\n"); + return { installed: true, healthy, summary }; +} + +export function codexShimStatus(): string { + return diagnoseCodexShim().summary; +} diff --git a/src/codex/shim-fingerprint.ts b/src/codex/shim-fingerprint.ts index 619d95eb05e..aee6d4bfef3 100644 --- a/src/codex/shim-fingerprint.ts +++ b/src/codex/shim-fingerprint.ts @@ -1,5 +1,7 @@ import { closeSync, + constants, + fstatSync, existsSync, lstatSync, linkSync, @@ -33,8 +35,9 @@ interface StableShimPathProbe { } function readShimProbePrefix(path: string): string { - const fd = openSync(path, "r"); + const fd = openSync(path, process.platform === "win32" ? "r" : constants.O_RDONLY | constants.O_NONBLOCK); try { + if (process.platform !== "win32" && !fstatSync(fd).isFile()) throw new Error("Shim probe entry is not a regular file"); const buffer = Buffer.allocUnsafe(CODEX_SHIM_PROBE_BYTES); const bytesRead = readSync(fd, buffer, 0, buffer.length, 0); return buffer.toString("utf8", 0, bytesRead); @@ -162,14 +165,16 @@ function shimPathFingerprint(path: string): ShimPathFingerprint | null { * fails EEXIST on an occupied destination. Checking existence and then renaming * would reintroduce exactly the race this function exists to close. */ -function restoreWithoutReplacing(from: string, to: string): void { +function restoreWithoutReplacing(from: string, to: string, guard?: () => void): void { const source = lstatSync(from); if (source.isSymbolicLink()) { symlinkSync(readlinkSync(from), to); + guard?.(); unlinkSync(from); return; } linkSync(from, to); + guard?.(); unlinkSync(from); } diff --git a/src/codex/shim-migration.ts b/src/codex/shim-migration.ts new file mode 100644 index 00000000000..6b51a80977e --- /dev/null +++ b/src/codex/shim-migration.ts @@ -0,0 +1,139 @@ +import { closeSync, fstatSync, linkSync, lstatSync, openSync, readlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { getConfigDir } from "../config"; +import { restoreWithoutReplacing, stableShimPathProbe } from "./shim-fingerprint"; +import { isExecutableCodexCandidate } from "./shim-path-resolution"; +import { readBoundedRegularFile, stateFiles, type ShimState } from "./shim-state-file"; +import { SHIM_MARKER, shQuote } from "./shim-templates"; + +export type UnixShimMigrationResult = + | { status: "native-restored"; launcherPath: string } + | { status: "native-repair-required"; message: string } + | { status: "refused"; message: string }; + +type Identity = { dev: number; ino: number; mode: number; size: number; mtimeMs: number; link?: string }; +type MigrationRecord = { original: string; backup: string; wrapper: Identity; saved: Identity }; + +function identity(path: string): Identity | null { + try { + const stat = lstatSync(path); + if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("Unsupported migration entry"); + return { dev: stat.dev, ino: stat.ino, mode: stat.mode, size: stat.size, mtimeMs: stat.mtimeMs, + ...(stat.isSymbolicLink() ? { link: readlinkSync(path) } : {}) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} +function same(left: Identity | null, right: Identity | null): boolean { + return !!left && !!right && JSON.stringify(left) === JSON.stringify(right); +} +function binding(path: string, backup: string): boolean { + const probe = stableShimPathProbe(path); + return !!probe && probe.fingerprint.kind === "file" && probe.prefix.includes(SHIM_MARKER) + && probe.prefix.includes(`exec ${shQuote(backup)} "$@"`); +} + +/** Caller holds the shared lock; legacy state remains intact until overlay commit. */ +export function migrateLegacyUnixShim(state: ShimState): UnixShimMigrationResult { + const refused = (message: string): UnixShimMigrationResult => ({ status: "refused", message }); + const repair = (): UnixShimMigrationResult => ({ status: "native-repair-required", message: + "Native Codex entry is missing or unusable. Repair Codex with its package manager, then rerun ocx codex-shim install; the recovery state was retained." }); + const files = stateFiles(state); + if (state.platform !== process.platform || state.platform === "win32" || state.mode || files.length !== 1) { + return refused("Legacy Unix shim has ambiguous targets or platform; preserving all files."); + } + const file = files[0]!; + if (file.preserveOnly || file.realPath || file.wrapperPath !== file.originalPath + || !isAbsolute(file.originalPath) || /[\x00-\x1f\x7f]/.test(file.originalPath) + || file.backupPath !== `${file.originalPath}.opencodex-real`) { + return refused("Legacy Unix shim ownership is ambiguous; manual recovery is required."); + } + const original = file.originalPath; + const backup = file.backupPath; + const quarantine = `${original}.opencodex-migrating`; + const journal = join(getConfigDir(), "codex-shim.migration.json"); + let journalIdentity: Identity | null = null; + try { + let record: MigrationRecord | null = null; + const journalBefore = identity(journal); + if (journalBefore) { + const entry = lstatSync(journal); + if (!entry.isFile() || entry.uid !== process.getuid?.() || (entry.mode & 0o022)) return refused("Migration journal is not privately owned."); + } + const stored = readBoundedRegularFile(journal, 16 * 1024); + const journalAfter = identity(journal); + if (journalBefore || journalAfter) { + if (!same(journalBefore, journalAfter)) return refused("Migration journal changed during inspection."); + } + if (stored) { + if ("warning" in stored) return refused("Migration recovery record is unreadable; preserving all artifacts."); + record = JSON.parse(stored.content) as MigrationRecord; + if (!record || record.original !== original || record.backup !== backup || !record.wrapper || !record.saved) { + return refused("Migration recovery record does not match the legacy state."); + } + journalIdentity = journalBefore; + } + let current = identity(original); + const saved = identity(backup); + const evacuated = identity(quarantine); + const probe = current && isExecutableCodexCandidate(original) ? stableShimPathProbe(original) : null; + if (current && (binding(original, backup) || probe?.prefix.includes(SHIM_MARKER))) { + if (!binding(original, backup)) return refused("Legacy wrapper binding changed; preserving it."); + if (!saved) return repair(); + if (!record) { + if (evacuated) return refused("Unrecorded migration quarantine requires manual inspection."); + record = { original, backup, wrapper: current, saved }; + const fd = openSync(journal, "wx", 0o600); + const created = fstatSync(fd); + try { + writeFileSync(fd, JSON.stringify(record) + "\n"); + const written = fstatSync(fd); + journalIdentity = identity(journal); + if (journalIdentity?.ino !== created.ino || journalIdentity.dev !== created.dev + || journalIdentity.size !== written.size || journalIdentity.mtimeMs !== written.mtimeMs) throw new Error("Journal replaced"); + } finally { closeSync(fd); } + } + if (!same(current, record.wrapper) || !same(saved, record.saved)) return refused("Migration inputs changed; preserving all generations."); + if (evacuated) { + if (!same(evacuated, record.wrapper)) return refused("Migration quarantine changed; preserving it."); + } else { + // A hard link is exclusive publication: unlike rename it never overwrites quarantine. + linkSync(original, quarantine); + } + if (!same(identity(original), record.wrapper) || !same(identity(quarantine), record.wrapper) + || !same(identity(backup), record.saved)) return refused("Migration inputs changed before evacuation."); + unlinkSync(original); + current = null; + } else if (evacuated && (!record || !same(evacuated, record.wrapper) || !binding(quarantine, backup))) { + return refused("Migration quarantine ownership is uncertain; preserving the recovery record."); + } + if (!current) { + if (!saved) return repair(); + if (!same(identity(backup), record?.saved ?? saved)) return refused("Legacy backup changed; retry after the package manager finishes."); + try { + restoreWithoutReplacing(backup, original, () => { + if (!same(identity(backup), record?.saved ?? saved)) throw new Error("Legacy backup replaced during publication"); + const restored = identity(original); + if (saved.link !== undefined ? restored?.link !== saved.link : !same(restored, saved)) { + throw new Error("Native entry changed during restoration"); + } + }); + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + // A concurrent package-manager publication wins over the saved generation. + } + } + const native = isExecutableCodexCandidate(original) ? stableShimPathProbe(original) : null; + if (!native || native.prefix.includes(SHIM_MARKER)) return repair(); + if (record && identity(quarantine)) { + if (!same(identity(quarantine), record.wrapper) || !binding(quarantine, backup)) return refused("Migration quarantine changed during restoration."); + unlinkSync(quarantine); + } + if (journalIdentity && same(identity(journal), journalIdentity)) unlinkSync(journal); + return { status: "native-restored", launcherPath: original }; + } catch { + return refused("Legacy migration could not complete safely; native and recovery artifacts were preserved. Retry after the package manager finishes."); + } +} diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts new file mode 100644 index 00000000000..5095ea818b6 --- /dev/null +++ b/src/codex/shim-overlay.ts @@ -0,0 +1,492 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, closeSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, + realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, type Stats, +} from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { getConfigDir } from "../config"; +import { durableBunRuntime } from "../lib/bun-runtime"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { truncateRetainedUtf8 } from "../lib/admission"; +import type { CodexShimDiagnostic } from "./shim-diagnostics"; +import type { CodexShimAutoRestoreResult } from "./shim"; +import { sameFingerprint, shimPathFingerprint, stableShimPathProbe } from "./shim-fingerprint"; +import { migrateLegacyUnixShim } from "./shim-migration"; +import { findFirstCodexOnPath, isExecutableCodexCandidate } from "./shim-path-resolution"; +import { CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS, MAX_DIAGNOSTIC_VALUE_BYTES, probeUnixShimFiles } from "./shim-probe"; +import { tryAcquireShimRestoreLock } from "./shim-restore-lock"; +import { + decodeOverlayState, fileErrorCode, readBoundedRegularFile, readStateResult, statePath, + type OverlayIdentity, type OverlayState, type ShimState, +} from "./shim-state-file"; +import { buildUnixCodexShim, SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, shQuote } from "./shim-templates"; + +type Snapshot = { identity: OverlayIdentity; mode: number; content: string }; +type Publication = { path: string; staged: string; created: Snapshot; prior: Snapshot | null; rollback: string }; +type Journal = { version: 1; token: string; launcher: string; files: Publication[] }; +type InstallResult = { installed: boolean; message: string; refused?: boolean; runnable?: boolean }; +type RestoreOptions = { + enabled: () => boolean; + stabilitySleep?: (ms: number) => void; + afterRestoreLockAcquired?: () => void; + beforeStaleRestoreLockDelete?: () => void; +}; +const JOURNAL_NAME = "codex-shim.transaction.json"; +const ENV_HEADER = "# opencodex managed Codex PATH\n"; +const PRIVATE_FILE_MAX_BYTES = 64 * 1024; + +export function overlayPaths(configDir = getConfigDir()): { wrapper: string; env: string } { + return { wrapper: join(resolve(configDir), "bin", "codex"), env: join(resolve(configDir), "codex-shell-env.sh") }; +} +export function overlayActivationHint(configDir = getConfigDir()): string { + return `For sh/bash/zsh, run . ${shQuote(overlayPaths(configDir).env)} and add that line after other PATH setup in your shell startup file. Absolute Codex paths and GUI launchers bypass this shim.`; +} +function sameId(left: OverlayIdentity | null | undefined, right: OverlayIdentity | null | undefined): boolean { + return !!left && !!right && left.dev === right.dev && left.ino === right.ino; +} +function lexical(path: string): Stats | null { + try { return lstatSync(path); } + catch (error) { if (fileErrorCode(error) === "ENOENT") return null; throw error; } +} +function snapshot(path: string): Snapshot | null { + const before = lexical(path); + if (!before) return null; + if (!before.isFile() || before.isSymbolicLink() || before.uid !== process.getuid?.() || (before.mode & 0o022)) { + throw new Error("Private Codex artifact is not an owned regular file"); + } + const bounded = readBoundedRegularFile(path, PRIVATE_FILE_MAX_BYTES); + if (!bounded || "warning" in bounded) throw new Error("Private Codex artifact is unreadable or changed"); + const after = lstatSync(path); + if (!sameId(before, after) || before.mode !== after.mode || before.uid !== after.uid + || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + || !sameId(bounded.stat, after) || bounded.stat.mode !== after.mode || bounded.stat.uid !== after.uid + || bounded.stat.size !== after.size || bounded.stat.mtimeMs !== after.mtimeMs || bounded.stat.ctimeMs !== after.ctimeMs) { + throw new Error("Private Codex artifact changed"); + } + return { identity: { dev: after.dev, ino: after.ino }, mode: after.mode & 0o777, content: bounded.content }; +} +function unchanged(path: string, expected: Snapshot | null): boolean { + const current = snapshot(path); + return expected ? !!current && sameId(current.identity, expected.identity) + && current.content === expected.content && current.mode === expected.mode : current === null; +} + +/** Validate the physical configured home alias; managed bin may never be a symlink. */ +function namespace(create: boolean): () => void { + const home = resolve(getConfigDir()); + if (home.includes(":") || /[\x00-\x1f\x7f]/.test(home)) throw new Error("Private Codex home cannot contain colon or control characters"); + if (!lexical(home)) { + if (!create) throw new Error("Private Codex home is missing"); + mkdirSync(home, { recursive: true, mode: 0o700 }); + } + const physical = realpathSync.native(home); + const root = statSync(home); + if (!root.isDirectory() || root.uid !== process.getuid?.() || (root.mode & 0o022)) { + throw new Error("Private Codex home must be owned by the current user and not group/world writable"); + } + const bin = join(home, "bin"); + if (!lexical(bin) && create) mkdirSync(bin, { mode: 0o700 }); + const child = lexical(bin); + const trusted = (value: Stats): boolean => value.isDirectory() + && value.uid === process.getuid?.() && (value.mode & 0o022) === 0; + if (!trusted(root) || (child && (!trusted(child) || child.isSymbolicLink()))) { + throw new Error("Private Codex home/bin must be owned by the current user and not group/world writable"); + } + return () => { + if (realpathSync.native(home) !== physical || !sameId(root, statSync(home)) || !trusted(statSync(home))) { + throw new Error("Private Codex home changed during operation"); + } + const current = lexical(bin); + if (child ? !current || !sameId(child, current) || !trusted(current) || current.isSymbolicLink() : current !== null) { + throw new Error("Private Codex bin changed during operation"); + } + }; +} +function scriptFor(launcher: string): string { + const runtime = durableBunRuntime(); + return buildUnixCodexShim(launcher, runtime.path, join(import.meta.dir, "..", "cli", "index.ts"), runtime.source); +} +function shellEnvironment(): string { + return [ENV_HEADER.trimEnd(), `ocx_shim_dir=${shQuote(dirname(overlayPaths().wrapper))}`, + 'ocx_shim_rest="${PATH-}"', 'ocx_shim_path="$ocx_shim_dir"', 'ocx_shim_more=1', + 'while [ "$ocx_shim_more" = 1 ]; do', ' case "$ocx_shim_rest" in', + ' *:*) ocx_shim_part="${ocx_shim_rest%%:*}"; ocx_shim_rest="${ocx_shim_rest#*:}" ;;', + ' *) ocx_shim_part="$ocx_shim_rest"; ocx_shim_more=0 ;;', ' esac', + ' if [ "$ocx_shim_part" != "$ocx_shim_dir" ]; then ocx_shim_path="$ocx_shim_path:$ocx_shim_part"; fi', + 'done', 'export PATH="$ocx_shim_path"', + 'unset ocx_shim_dir ocx_shim_rest ocx_shim_path ocx_shim_more ocx_shim_part', ""].join("\n"); +} +function ownedWrapper(value: Snapshot, state: OverlayState): boolean { + return sameId(value.identity, state.wrapperIdentity) + && value.content.includes(SHIM_MARKER) && value.content.includes(`exec ${shQuote(state.launcherPath)} "$@"`); +} +function requireOverlayIdentities(state: OverlayState): void { + if (!state.wrapperIdentity || !state.envIdentity) { + throw new Error("Private Codex shim ownership identities are missing; preserving all files. Remove the private files manually, then reinstall with ocx codex-shim install."); + } +} +function ownedEnvironment(value: Snapshot, state: OverlayState): boolean { + return sameId(value.identity, state.envIdentity) && value.content === shellEnvironment(); +} +/** Validate the whole installation before endorsing either execution or activation. */ +function inspectOverlay(state: OverlayState): { wrapper: Snapshot | null; environmentPresent: boolean; complete: boolean } { + requireOverlayIdentities(state); + const saved = snapshot(statePath()); + const recorded = saved ? decodeOverlayState(JSON.parse(saved.content), getConfigDir()) : null; + if (!saved || !recorded || recorded.platform !== state.platform || recorded.wrapperPath !== state.wrapperPath + || recorded.launcherPath !== state.launcherPath || recorded.transactionId !== state.transactionId + || !sameId(recorded.wrapperIdentity, state.wrapperIdentity) || !sameId(recorded.envIdentity, state.envIdentity)) { + throw new Error("Private Codex shim state changed; preserving artifacts"); + } + const wrapper = snapshot(state.wrapperPath); + const env = snapshot(overlayPaths().env); + if (wrapper && !ownedWrapper(wrapper, state)) throw new Error("Private Codex launcher was replaced; preserving it"); + if (env && !ownedEnvironment(env, state)) throw new Error("Codex shell environment file was replaced; preserving it"); + if (!unchanged(statePath(), saved) || !unchanged(state.wrapperPath, wrapper) || !unchanged(overlayPaths().env, env)) { + throw new Error("Private Codex installation changed during inspection; preserving artifacts"); + } + return { wrapper, environmentPresent: !!env, complete: saved.mode === 0o600 && !!env && env.mode === 0o600 }; +} +function nativeProbe(path: string) { + if (!isExecutableCodexCandidate(path)) return null; + const probe = stableShimPathProbe(path); + return probe && !probe.prefix.includes(SHIM_MARKER) ? probe : null; +} +function overlayRunnable(state: OverlayState, wrapper: Snapshot | null): boolean { + return state.platform === process.platform && !!wrapper && ownedWrapper(wrapper, state) + && wrapper.content.includes(UNIX_SHIM_REVISION_MARKER) && wrapper.content === scriptFor(state.launcherPath) + && isExecutableCodexCandidate(state.wrapperPath) && !!nativeProbe(state.launcherPath); +} +export function overlayDiagnostic(state: ShimState): CodexShimDiagnostic { + const overlay = decodeOverlayState(state, getConfigDir()); + let runnable = false; + let active: boolean | null = false; + let integrityMessage = ""; + let complete = false; + try { + const check = namespace(false); + const inspection = overlay ? inspectOverlay(overlay) : null; + const wrapper = inspection?.wrapper; + complete = !!inspection?.complete; + runnable = !!overlay && !!inspection?.environmentPresent && overlayRunnable(overlay, wrapper ?? null); + if (inspection && !inspection.complete) integrityMessage = "Private Codex state and shell environment must be present with mode 0600; run ocx codex-shim install to repair them."; + if (!complete) active = null; + if (complete && overlay && wrapper) { + try { + const selected = findFirstCodexOnPath(); + if (selected) { + const identity = shimPathFingerprint(selected.path); + active = identity ? sameId(identity.target ?? identity, wrapper.identity) : null; + } + } catch { active = null; } + } + check(); + } catch (error) { active = null; runnable = false; integrityMessage = failure(error).message; } + return { installed: true, healthy: complete && runnable && active === true, runnable, active, + summary: `Codex PATH shim: ${complete && runnable ? "ready" : "unhealthy"} at ${state.wrapperPath}; launcher ${state.launcherPath}; PATH ${active === null ? "unverified" : active ? "active" : "inactive"}. ${complete && runnable ? overlayActivationHint() : integrityMessage || "Run ocx codex-shim install to repair the shim."}` }; +} + +/** Capture the created inode from its descriptor, never by trusting a later pathname read. */ +function stage(path: string, content: string, mode: number): Snapshot { + const fd = openSync(path, "wx", 0o600); + const created = fstatSync(fd); + try { + writeFileSync(fd, content, "utf8"); + if (!sameId(created, lexical(path))) throw new Error("Staged Codex file replaced"); + chmodSync(path, mode); + const pinned = snapshot(path); + if (!pinned || !sameId(created, pinned.identity) || pinned.content !== content) throw new Error("Staged Codex file changed"); + return pinned; + } catch (error) { + if (sameId(created, lexical(path))) unlinkSync(path); + throw error; + } finally { closeSync(fd); } +} + +function removePinned(path: string, expected: Snapshot): boolean { + if (!lexical(path)) return true; + if (!unchanged(path, expected)) return false; + unlinkSync(path); + return true; +} +function journalPath(): string { return join(getConfigDir(), JOURNAL_NAME); } +function validSnapshot(value: unknown): value is Snapshot { + if (!value || typeof value !== "object") return false; + const item = value as Snapshot; + return !!item.identity && Number.isSafeInteger(item.identity.dev) && Number.isSafeInteger(item.identity.ino) + && Number.isInteger(item.mode) && item.mode >= 0 && item.mode <= 0o777 && typeof item.content === "string"; +} +function readJournal(): { journal: Journal; saved: Snapshot } | null { + const saved = snapshot(journalPath()); + if (!saved) return null; + const value = JSON.parse(saved.content) as Journal; + if (value.version !== 1 || !/^[a-f0-9-]{36}$/.test(value.token) || typeof value.launcher !== "string" + || !Array.isArray(value.files) || value.files.length !== 3) throw new Error("Invalid Codex publication journal"); + const targets = [overlayPaths().wrapper, overlayPaths().env, statePath()]; + for (const [index, file] of value.files.entries()) { + if (file.path !== targets[index] || file.staged !== `${file.path}.${value.token}.stage` + || file.rollback !== `${file.path}.${value.token}.rollback` || !validSnapshot(file.created) + || (file.prior !== null && !validSnapshot(file.prior))) throw new Error("Invalid Codex publication paths"); + } + const state = decodeOverlayState(JSON.parse(value.files[2]!.created.content), getConfigDir()); + if (!state || state.transactionId !== value.token || state.launcherPath !== value.launcher + || !sameId(state.wrapperIdentity, value.files[0]!.created.identity) + || !sameId(state.envIdentity, value.files[1]!.created.identity) + || !ownedWrapper(value.files[0]!.created, state) || value.files[1]!.created.content !== shellEnvironment()) { + throw new Error("Invalid Codex publication binding"); + } + return { journal: value, saved }; +} +/** State is the commit marker; uncommitted private publications are rolled back by identity. */ +function recoverJournal(check: () => void): void { + const pending = readJournal(); + if (!pending) return; + const { journal, saved } = pending; + const state = readStateResult().state; + const committed = state?.transactionId === journal.token && unchanged(statePath(), journal.files[2]!.created); + let complete = true; + for (const file of [...journal.files].reverse()) { + check(); + try { + let discardPrior = committed; + if (!committed && unchanged(file.path, file.created)) { + if (file.prior) { + if (!unchanged(file.rollback, file.prior)) throw new Error("Prior Codex artifact changed"); + renameSync(file.rollback, file.path); + } else if (!removePinned(file.path, file.created)) throw new Error("Concurrent Codex publication"); + discardPrior = true; + } else if (!committed && !lexical(file.path) && file.prior) { + if (!unchanged(file.rollback, file.prior)) throw new Error("Prior Codex artifact unavailable"); + linkSync(file.rollback, file.path); + discardPrior = true; + } else if (!committed) { + discardPrior = unchanged(file.path, file.prior); + if (!discardPrior) complete = false; + } + if (!removePinned(file.staged, file.created)) complete = false; + if (file.prior && discardPrior && !removePinned(file.rollback, file.prior)) complete = false; + } catch { complete = false; } // Keep uncertain targets and prior generations for manual recovery. + } + check(); + if (!complete || !removePinned(journalPath(), saved)) throw new Error("Concurrent Codex artifacts preserved; inspect the publication journal before retrying"); +} + +function publishOverlay(launcher: string, state: OverlayState | null, check: () => void, currentState: Snapshot | null): InstallResult { + const paths = overlayPaths(); + if (state) requireOverlayIdentities(state); + if (!isAbsolute(launcher) || /[\x00-\x1f\x7f]/.test(launcher)) throw new Error("Unsupported native Codex launcher path"); + const initialNative = nativeProbe(launcher); + if (!initialNative || resolve(launcher) === paths.wrapper) throw new Error("Native Codex launcher is unusable; repair it with its package manager"); + const wrapper = snapshot(paths.wrapper); + const env = snapshot(paths.env); + if (wrapper && (!state || !ownedWrapper(wrapper, state))) throw new Error("Refusing an unowned private Codex launcher"); + if (env && (!state || !ownedEnvironment(env, state))) { + throw new Error("Refusing an unowned Codex shell environment file"); + } + if (!unchanged(statePath(), currentState)) throw new Error("Codex shim state changed before publication"); + const script = scriptFor(launcher); + if (state && wrapper?.content === script && isExecutableCodexCandidate(paths.wrapper) && env?.content === shellEnvironment() + && env.mode === 0o600 && currentState?.mode === 0o600) { + return { installed: false, runnable: true, message: `Codex PATH shim already installed. ${overlayActivationHint()}` }; + } + const token = randomUUID(); + const files: Publication[] = []; + let persisted = false; + let journalSnapshot: Snapshot | null = null; + const add = (path: string, content: string, mode: number, prior: Snapshot | null): Publication => { + const staged = `${path}.${token}.stage`; + const file = { path, staged, created: stage(staged, content, mode), prior, rollback: `${path}.${token}.rollback` }; + files.push(file); + return file; + }; + try { + const wrapperFile = add(paths.wrapper, script, 0o755, wrapper); + const envFile = add(paths.env, shellEnvironment(), 0o600, env); + const nextState: OverlayState = { schema: 2, mode: "path-overlay", platform: process.platform, + wrapperPath: paths.wrapper, launcherPath: launcher, wrapperIdentity: wrapperFile.created.identity, + envIdentity: envFile.created.identity, transactionId: token }; + if (!decodeOverlayState(nextState, getConfigDir())) throw new Error("Invalid staged Codex overlay state"); + add(statePath(), JSON.stringify(nextState, null, 2) + "\n", 0o600, currentState); + const probeDir = join(dirname(paths.wrapper), `.probe-${token}`); + mkdirSync(probeDir, { mode: 0o700 }); + const probeIdentity = lstatSync(probeDir); + const probeWrapper = join(probeDir, "codex"); + let unsafe: ReturnType; + try { + linkSync(wrapperFile.staged, probeWrapper); + unsafe = probeUnixShimFiles([{ wrapperPath: probeWrapper, originalPath: launcher, backupPath: launcher, realPath: launcher }], + { PATH: `${probeDir}:${process.env.PATH ?? ""}` }); + } finally { + check(); + if (sameId(probeIdentity, lexical(probeDir))) { + removePinned(probeWrapper, wrapperFile.created); + try { rmdirSync(probeDir); } catch { /* preserve uncertain probe contents */ } + } + } + if (unsafe) { + const reason = unsafe === "recursive" ? "the saved launcher resolved back to the generated shim" + : unsafe === "timeout" ? `the saved launcher did not finish --version within ${CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS}ms` + : unsafe === "descendants" ? "the saved launcher left background descendants running after --version" + : typeof unsafe === "object" ? `the saved launcher's probe process group could not be terminated cleanly [phase=${unsafe.phase}; code=${unsafe.code}; status=${unsafe.status ?? "none"}; signal=${unsafe.signal}]` + : "the saved launcher failed its --version probe"; + throw new Error(`Refusing Codex PATH shim because ${reason}. Native launcher was preserved.`); + } + const nativeAfter = nativeProbe(launcher); + if (!nativeAfter || !sameFingerprint(initialNative.fingerprint, nativeAfter.fingerprint) || initialNative.prefix !== nativeAfter.prefix) { + throw new Error("Native launcher changed during validation; retry after the package manager finishes"); + } + check(); + for (const file of files) { + if (!unchanged(file.path, file.prior) || !unchanged(file.staged, file.created)) throw new Error("Private Codex inputs changed during validation"); + recordOwnedConfigPath(getConfigDir(), file.path); + if (file.prior) { + linkSync(file.path, file.rollback); + if (!unchanged(file.rollback, file.prior)) throw new Error("Private Codex rollback artifact changed"); + } + } + check(); + journalSnapshot = stage(journalPath(), JSON.stringify({ version: 1, token, launcher, files } satisfies Journal) + "\n", 0o600); + persisted = true; + for (const file of files) { + const native = nativeProbe(launcher); + if (!native || !sameFingerprint(initialNative.fingerprint, native.fingerprint) || native.prefix !== initialNative.prefix) { + throw new Error("Native launcher changed before publication; retry after the package manager finishes"); + } + check(); + if (!unchanged(file.path, file.prior) || !unchanged(file.staged, file.created)) throw new Error("Private Codex publication changed"); + if (file.prior) renameSync(file.staged, file.path); + else { linkSync(file.staged, file.path); removePinned(file.staged, file.created); } + } + recoverJournal(check); + return { installed: true, runnable: true, message: `Codex PATH shim installed at ${paths.wrapper}; native launcher ${launcher}. ${overlayActivationHint()}` }; + } catch (error) { + if (persisted) { + try { recoverJournal(check); } + catch { throw new Error("Codex publication could not finish recovery; concurrent artifacts and the recovery journal were preserved"); } + } + throw error; + } finally { + if (!persisted) { + check(); + for (const file of files) { + removePinned(file.staged, file.created); + if (file.prior) removePinned(file.rollback, file.prior); + } + if (journalSnapshot) removePinned(journalPath(), journalSnapshot); + } + } +} + +function failure(error: unknown): InstallResult { + const message = error instanceof Error ? error.message : "Codex overlay operation failed"; + return { installed: false, refused: true, message: truncateRetainedUtf8(message.replace(/[\r\n]/g, " "), MAX_DIAGNOSTIC_VALUE_BYTES) }; +} +function installLocked(discover: () => string | null, automatic: boolean, check: () => void): InstallResult { + recoverJournal(check); + const pinnedState = snapshot(statePath()); + const result = readStateResult(); + const state = result.state; + if (!unchanged(statePath(), pinnedState)) throw new Error("Codex shim state changed during inspection"); + if (result.present && !state) throw new Error(result.warning ?? "Invalid Codex shim state; refusing installation"); + if (state && state.platform !== process.platform) throw new Error("Codex shim platform mismatch"); + let launcher: string | null; + let overlay: OverlayState | null = null; + if (state?.mode === "path-overlay") { + overlay = decodeOverlayState(state, getConfigDir()); + if (!overlay) throw new Error("Invalid Codex overlay state"); + requireOverlayIdentities(overlay); + launcher = overlay.launcherPath; + } else if (state) { + if (automatic) throw new Error("Legacy Unix shim requires explicit migration: run ocx codex-shim install"); + const migration = migrateLegacyUnixShim(state); + if (migration.status !== "native-restored") throw new Error(migration.message); + launcher = migration.launcherPath; + } else { + const discovered = automatic ? null : discover(); + launcher = discovered ? resolve(discovered) : null; + } + if (!launcher) throw new Error("Native Codex launcher not found; install or repair Codex with its package manager"); + check(); + return publishOverlay(launcher, overlay, check, pinnedState); +} +export function installUnixOverlay(discover: () => string | null): InstallResult { + let lock: ReturnType = null; + try { + const check = namespace(true); + lock = tryAcquireShimRestoreLock(); + if (!lock) throw new Error("Codex shim operation is already in progress; retry later"); + check(); + return installLocked(discover, false, check); + } catch (error) { return failure(error); } + finally { lock?.release(); } +} +export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRestoreResult { + const result = readStateResult(); + if (!result.state) return { status: result.present ? "ineligible" : "not-installed", ...(result.warning ? { message: result.warning } : {}) }; + const state = decodeOverlayState(result.state, getConfigDir()); + if (!state || state.platform !== process.platform) return { status: "ineligible", message: "Legacy Unix shim requires explicit migration: run ocx codex-shim install" }; + try { + const check = namespace(false); + const inspection = inspectOverlay(state); + const runnable = overlayRunnable(state, inspection.wrapper); + check(); + if (inspection.complete && runnable && !lexical(journalPath())) return { status: "healthy" }; + } 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" }; + let lock: ReturnType = null; + try { + const check = namespace(true); + lock = tryAcquireShimRestoreLock(options.beforeStaleRestoreLockDelete); + if (!lock) return { status: "deferred" }; + options.afterRestoreLockAcquired?.(); + check(); + const native = nativeProbe(state.launcherPath); + (options.stabilitySleep ?? Bun.sleepSync)(100); + const observed = nativeProbe(state.launcherPath); + if (!native || !observed || !sameFingerprint(native.fingerprint, observed.fingerprint) || native.prefix !== observed.prefix) { + return { status: "deferred", message: "Native launcher changed during observation; retry after the package manager finishes" }; + } + 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(); } +} +export function uninstallUnixCodexShim(): { removed: boolean; message: string } { + let lock: ReturnType = null; + try { + const check = namespace(false); + lock = tryAcquireShimRestoreLock(); + if (!lock) throw new Error("Codex shim operation is already in progress"); + check(); + recoverJournal(check); + const stateFile = snapshot(statePath()); + const result = readStateResult(); + const state = result.state; + if (!unchanged(statePath(), stateFile)) throw new Error("Codex shim state changed during removal"); + if (!state) return { removed: false, message: result.present ? "Invalid Codex shim state; preserving artifacts" : "Codex autostart shim is not installed." }; + if (!stateFile) throw new Error("Codex state changed during removal"); + if (state.mode !== "path-overlay") { + const migration = migrateLegacyUnixShim(state); + if (migration.status !== "native-restored") return { removed: false, message: migration.message }; + } else { + const overlay = decodeOverlayState(state, getConfigDir()); + if (!overlay || overlay.platform !== process.platform) throw new Error("Invalid private Codex shim state"); + requireOverlayIdentities(overlay); + const wrapper = snapshot(overlay.wrapperPath); + const env = snapshot(overlayPaths().env); + if (wrapper && !ownedWrapper(wrapper, overlay)) throw new Error("Private Codex launcher is no longer owned; preserving it and its state"); + if (env && !ownedEnvironment(env, overlay)) { + throw new Error("Codex shell environment file is no longer owned; preserving it and its state"); + } + for (const [path, pinned] of [[overlay.wrapperPath, wrapper], [overlayPaths().env, env]] as const) { + check(); + if (pinned && !removePinned(path, pinned)) throw new Error("Concurrent private Codex replacement preserved"); + } + } + check(); + 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(); } +} diff --git a/src/codex/shim-path-resolution.ts b/src/codex/shim-path-resolution.ts index 56fe11c6858..ce16c9abd07 100644 --- a/src/codex/shim-path-resolution.ts +++ b/src/codex/shim-path-resolution.ts @@ -1,4 +1,4 @@ -import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; +import { accessSync, closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; import { posix, win32 } from "node:path"; import { isWslRuntime, wslAutomountRoot } from "./home"; import { SHIM_MARKER } from "./shim-templates"; @@ -24,6 +24,7 @@ export type CodexPathScanDeps = { isShimFile?: (path: string) => boolean; isDirectory?: (path: string) => boolean; realpath?: (path: string) => string; + executableEligible?: (path: string) => boolean; }; export type CodexPathCandidate = { @@ -41,9 +42,20 @@ export function realIsDirectory(path: string): boolean { const SHIM_HEADER_MAX_BYTES = 16 * 1024; -function inspectShimFile(path: string): boolean | null { +/** Shared POSIX eligibility, following launcher symlinks without opening special files. */ +export function isExecutableCodexCandidate(path: string): boolean { + try { + if (process.platform !== "win32") accessSync(path, constants.X_OK); + return statSync(path).isFile(); + } catch { return false; } +} + +export function inspectShimFile(path: string): boolean | null { let fd: number | undefined; try { + if (process.platform !== "win32") { + try { accessSync(path, constants.X_OK); } catch { return null; } + } // Follow npm launcher symlinks, but never read a known special file. if (!statSync(path).isFile()) return null; const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK); diff --git a/src/codex/shim-probe.ts b/src/codex/shim-probe.ts index 03f5cc95821..7452bf5ef04 100644 --- a/src/codex/shim-probe.ts +++ b/src/codex/shim-probe.ts @@ -246,7 +246,7 @@ function readProbeMetadata(path: string, maxBytes: number): string | null { } } -function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { +function probeUnixShimInstall(wrapperPath: string, childEnv?: NodeJS.ProcessEnv): UnixShimProbeResult { if (process.platform === "win32") return null; const probeDir = mkdtempSync(join(tmpdir(), "opencodex-shim-probe-")); const markerPath = join(probeDir, "result"); @@ -255,6 +255,7 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { const stderrPath = join(probeDir, "stderr"); const env: NodeJS.ProcessEnv = { ...process.env, + ...childEnv, BUN_BE_BUN: "1", OCX_SHIM_BYPASS: "1", OCX_SHIM_PROBE: "1", @@ -329,12 +330,12 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { } } -function probeUnixShimFiles(files: readonly ShimFileState[]): UnixShimProbeResult { +function probeUnixShimFiles(files: readonly ShimFileState[], childEnv?: NodeJS.ProcessEnv): UnixShimProbeResult { if (process.platform === "win32") return null; codexShimProbeHookForTests?.(); return files .filter(file => !file.preserveOnly) - .map(file => probeUnixShimInstall(file.wrapperPath)) + .map(file => probeUnixShimInstall(file.wrapperPath, childEnv)) .find(result => result !== null) ?? null; } diff --git a/src/codex/shim-state-file.ts b/src/codex/shim-state-file.ts index d5c790cdcff..ba2753297e5 100644 --- a/src/codex/shim-state-file.ts +++ b/src/codex/shim-state-file.ts @@ -1,21 +1,31 @@ import { closeSync, + constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, + renameSync, + unlinkSync, writeFileSync, type Stats, } from "node:fs"; -import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; import { recordOwnedConfigPath } from "../lib/config-ownership"; export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024; interface ShimState { + schema?: 2; + mode?: "path-overlay"; + launcherPath?: string; + wrapperIdentity?: OverlayIdentity; + envIdentity?: OverlayIdentity; + transactionId?: string; platform: NodeJS.Platform; wrapperPath: string; originalPath: string; @@ -23,6 +33,51 @@ interface ShimState { wrappers?: ShimFileState[]; } +export interface OverlayIdentity { dev: number; ino: number } +export interface OverlayState { + schema: 2; + mode: "path-overlay"; + platform: NodeJS.Platform; + wrapperPath: string; + launcherPath: string; + // Older schema-2 records remain readable, but absent identities confer no mutation authority. + wrapperIdentity?: OverlayIdentity; + envIdentity?: OverlayIdentity; + transactionId?: string; +} + +export function decodeOverlayState(value: unknown, configDir: string): OverlayState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const state = value as Record; + const platforms = ["aix", "darwin", "freebsd", "linux", "openbsd", "sunos", "android", "netbsd", "haiku", "cygwin"]; + const validPath = (path: unknown): path is string => typeof path === "string" && isAbsolute(path) && !/[\x00-\x1f\x7f]/.test(path); + if (state.schema !== 2 || state.mode !== "path-overlay" || !platforms.includes(String(state.platform)) + || !validPath(configDir) || !validPath(state.wrapperPath) || !validPath(state.launcherPath) + || state.wrapperPath !== join(resolve(configDir), "bin", "codex") + || dirname(state.wrapperPath).includes(":") || resolve(state.launcherPath) === state.wrapperPath) return null; + for (const field of ["originalPath", "backupPath", "realPath"]) { + if (state[field] !== undefined && state[field] !== state.launcherPath) return null; + } + if (state.preserveOnly !== undefined && state.preserveOnly !== false) return null; + if (state.wrappers !== undefined) { + if (!Array.isArray(state.wrappers) || state.wrappers.length !== 1) return null; + const file = state.wrappers[0]; + if (!file || typeof file !== "object" || file.wrapperPath !== state.wrapperPath + || file.originalPath !== state.launcherPath || file.backupPath !== state.launcherPath + || (file.realPath !== undefined && file.realPath !== state.launcherPath) || file.preserveOnly) return null; + } + for (const key of ["wrapperIdentity", "envIdentity"]) { + const id = state[key] as Partial | undefined; + if (id !== undefined && (!id || !Number.isSafeInteger(id.dev) || !Number.isSafeInteger(id.ino))) return null; + } + if (state.transactionId !== undefined && (typeof state.transactionId !== "string" || !/^[a-f0-9-]{36}$/.test(state.transactionId))) return null; + return state as unknown as OverlayState; +} + +export function readOverlayState(configDir = getConfigDir()): OverlayState | null { + return decodeOverlayState(readStateResult(join(configDir, "codex-shim.json")).state, configDir); +} + interface ShimFileState { wrapperPath: string; originalPath: string; @@ -43,7 +98,7 @@ function fileErrorCode(error: unknown): string | undefined { : undefined; } -function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string } | { warning: string } | null { +function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string; stat: Stats } | { warning: string } | null { let lexicalBefore: Stats; try { lexicalBefore = lstatSync(path); @@ -56,7 +111,7 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer } let fd: number; try { - fd = openSync(path, "r"); + fd = openSync(path, process.platform === "win32" ? "r" : constants.O_RDONLY | constants.O_NONBLOCK); } catch (error) { if (fileErrorCode(error) === "ENOENT") return null; return { warning: `Codex shim state could not be opened as a regular file at ${path}.` }; @@ -87,11 +142,16 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer } if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + || before.mode !== after.mode || before.uid !== after.uid || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino - || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino) { + || lexicalBefore.mode !== before.mode || lexicalBefore.uid !== before.uid + || lexicalBefore.size !== before.size || lexicalBefore.mtimeMs !== before.mtimeMs || lexicalBefore.ctimeMs !== before.ctimeMs + || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino + || lexicalAfter.mode !== after.mode || lexicalAfter.uid !== after.uid + || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs || lexicalAfter.ctimeMs !== after.ctimeMs) { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - return { bytes: buffer, content: buffer.toString("utf8") }; + return { bytes: buffer, content: buffer.toString("utf8"), stat: after }; } finally { closeSync(fd); } @@ -105,6 +165,15 @@ function readStateResult(path = statePath()): ShimStateReadResult { const value = JSON.parse(bounded.content) as unknown; if (!value || typeof value !== "object") return { state: null, present: true }; const state = value as Record; + if (state.mode !== undefined || state.schema !== undefined) { + // Legacy records stay readable; private overlay authority requires safe ownership. + // Non-writable mode drift is readable for opt-in repair, never a healthy installation. + if (process.platform !== "win32" && (bounded.stat.uid !== process.getuid?.() || (bounded.stat.mode & 0o022))) { + return { state: null, present: true, warning: "Private Codex shim state is not owned by the current user or is group/world writable; preserving artifacts. Remove the private files manually before reinstalling." }; + } + const overlay = decodeOverlayState(value, dirname(path)); + return { state: overlay ? { ...overlay, originalPath: overlay.launcherPath, backupPath: overlay.launcherPath } : null, present: true }; + } if (typeof state.platform !== "string") return { state: null, present: true }; const validFile = (item: unknown): item is ShimFileState => { if (!item || typeof item !== "object") return false; @@ -134,18 +203,35 @@ function statePath(): string { return join(getConfigDir(), "codex-shim.json"); } -function writeState(state: ShimState): void { +function writeState(state: ShimState | OverlayState): void { const path = statePath(); recordOwnedConfigPath(getConfigDir(), path); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); + if (state.mode === "path-overlay") { + if (!decodeOverlayState(state, getConfigDir()) || !state.wrapperIdentity || !state.envIdentity) { + throw new Error("Invalid Codex overlay state: recorded ownership identities are required"); + } + const staged = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(staged, JSON.stringify(state, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + renameSync(staged, path); + } finally { + try { unlinkSync(staged); } catch { /* committed or absent */ } + } + return; + } writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8"); } function stateFiles(state: ShimState): ShimFileState[] { + if (state.mode === "path-overlay" && state.launcherPath) return [{ + wrapperPath: state.wrapperPath, originalPath: state.launcherPath, + backupPath: state.launcherPath, realPath: state.launcherPath, + }]; return state.wrappers?.length ? state.wrappers : [{ wrapperPath: state.wrapperPath, originalPath: state.originalPath, backupPath: state.backupPath }]; } export type { ShimState, ShimFileState }; -export { fileErrorCode, readStateResult, readState, statePath, writeState, stateFiles }; +export { readBoundedRegularFile, fileErrorCode, readStateResult, readState, statePath, writeState, stateFiles }; diff --git a/src/codex/shim.ts b/src/codex/shim.ts index e218fa500b8..25638f231a9 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -55,6 +55,8 @@ import { import { tryAcquireShimRestoreLock } from "./shim-restore-lock"; import { isWindowsInteropDir, + isExecutableCodexCandidate, + inspectShimFile, realIsDirectory, resolveStableFnmCodexPath, type CodexPathScanDeps, @@ -69,6 +71,10 @@ export type { CodexPathScanDeps } from "./shim-path-resolution"; export { isWindowsInteropDir } from "./shim-path-resolution"; export { isLocalAbsoluteInspectionPath, inspectCodexShimBackingForCommand } from "./shim-inspect"; +import { isShim, diagnoseCodexShim } from "./shim-diagnostics"; +import { installUnixOverlay, autoRestoreUnixOverlay, uninstallUnixCodexShim, overlayPaths } from "./shim-overlay"; +export { diagnoseCodexShim, codexShimStatus, type CodexShimDiagnostic } from "./shim-diagnostics"; + export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100; let lastShimDiscoveryError: string | null = null; @@ -114,31 +120,12 @@ function commandNames(name: string): string[] { return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])]; } -function isShim(path: string): boolean { - try { - return readFileSync(path, "utf8").includes(SHIM_MARKER); - } catch { - return false; - } -} - -function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { - try { - const content = readFileSync(path, "utf8"); - if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false; - if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false; - if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false; - return true; - } catch { - return false; - } -} - export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null { lastShimDiscoveryError = null; const exists = deps.exists ?? existsSync; - const shimFile = deps.isShimFile ?? isShim; + const shimFile = deps.isShimFile ?? (process.platform === "win32" ? isShim : (path: string) => inspectShimFile(path) !== false); const isDir = deps.isDirectory ?? realIsDirectory; + const eligible = deps.executableEligible ?? (deps.exists ? () => true : isExecutableCodexCandidate); const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime()); const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32"); // The injected path flavour governs join and separator alike; host-native `join` and @@ -166,7 +153,9 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null { const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex"); for (const name of names) { const path = joinPath(dir, name); - if (!exists(path) || shimFile(path)) continue; + if (!exists(path)) continue; + if (usePosix && (path === overlayPaths().wrapper || !eligible(path))) continue; + if (shimFile(path)) continue; if (!isDir(path)) { const resolved = discoveredCodexPath(path, usePosix, deps.realpath); if (resolved !== null) return resolved; @@ -1072,7 +1061,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i }; } -export function installCodexShim(): { installed: boolean; message: string } { +export function installCodexShim(): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } { + if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath); return installCodexShimInternal({ allowFreshInstall: true }); } @@ -1086,6 +1076,7 @@ export function autoRestoreCodexShim(options: { /** Narrow deterministic race seam for the guarded transaction tests. */ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; }): CodexShimAutoRestoreResult { + if (process.platform !== "win32") return autoRestoreUnixOverlay(options); const stateRead = readStateResult(); const state = stateRead.state; if (!state) { @@ -1181,6 +1172,7 @@ export function autoRestoreCodexShim(options: { } export function uninstallCodexShim(): { removed: boolean; message: string } { + if (process.platform !== "win32") return uninstallUnixCodexShim(); const state = readState(); if (!state) return { removed: false, message: "Codex autostart shim is not installed." }; const files = stateFiles(state); @@ -1199,48 +1191,3 @@ export function uninstallCodexShim(): { removed: boolean; message: string } { export function isCodexShimInstalled(): boolean { return diagnoseCodexShim().installed; } - -export interface CodexShimDiagnostic { - installed: boolean; - healthy: boolean; - summary: string; -} - -/** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */ -export function diagnoseCodexShim(): CodexShimDiagnostic { - const state = readState(); - if (!state) { - if (existsSync(statePath())) { - return { - installed: true, - healthy: false, - summary: `Codex autostart shim state is invalid or corrupt at ${statePath()}. Reinstall or remove the shim.`, - }; - } - return { - installed: false, - healthy: false, - summary: "Codex autostart shim is not installed.", - }; - } - const files = stateFiles(state); - const healthy = files.length > 0 && files.every(file => file.preserveOnly - ? existsSync(file.backupPath) && !existsSync(file.originalPath) - : existsSync(file.wrapperPath) - && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false)) - && isHealthyShim(file.wrapperPath, state.platform)); - const summary = files.map(file => { - const wrapper = existsSync(file.wrapperPath) - ? isShim(file.wrapperPath) - ? "shim present" - : "present but not an opencodex shim" - : "missing"; - const backup = existsSync(file.backupPath) ? "present" : "missing"; - return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`; - }).join("\n"); - return { installed: true, healthy, summary }; -} - -export function codexShimStatus(): string { - return diagnoseCodexShim().summary; -} diff --git a/structure/catalog.md b/structure/catalog.md index 922bfec104d..c6625c08ba8 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -104,7 +104,7 @@ explicit observed-state merge policy and restore native priorities from the once backup rather than from a catalog whose priorities may already have been rewritten. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability -evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and the loader's bounded synchronous probes. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. +evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and the loader's bounded synchronous probes. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. `src/codex/catalog/bundled.ts` and `src/codex/runtime.ts` prefer the recorded native `launcherPath` from validated schema-2 PATH-overlay state within their shim candidates, retaining legacy candidates and other priorities. `src/codex/features.ts` follows that backing only when the selected wrapper matches validated state; it never probes unrelated installations. Overlay state and activation follow [the runtime lifecycle contract](runtime.md#lifecycle). Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context windows, display names -- only through the canonical `openai` forward destination and explicit diff --git a/structure/runtime.md b/structure/runtime.md index eaaef86bb72..9293e7f87a3 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -123,7 +123,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` suppresses it for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | | `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` | The pure Bun-head parser retains command argv and derives transient help paths before shim preflight or command dispatch. Bare `help` is recognized only at root or immediately after the root command; nested paths use `--help`/`-h`, and exact `--` ends head help scanning. Later operand values such as `alias set demo help` stay commands. `runCli` exits help and rejects unknown roots before shim/stateful preflight, while preserving `parseCliHead` and its kinds. Registry names/aliases/hidden entries and the unregistered `internal` runner remain valid dispatch targets; hidden/internal names stay outside discovery and suggestions. `printUsage` renders the same compact index for bare `ocx`, `help`, `--help` and `-h`; static navigation groups reference registry names/summaries and add no configuration reads, width probes or color dependence. `printFullUsage` preserves the full reference behind `ocx help --all` and `ocx --help --all`. This contract concerns the Bun head; the Node launcher's updater interception has its own recognition rules. | | `src/cli/help-catalog.ts`, `src/cli/help-models-context.ts`, `src/cli/help-recovery.ts` | Pure help resolution uses `src/cli/registry.ts` and `src/cli/capabilities.ts`, preserving exact alias usage/details before canonical family navigation. Entry results carry transient canonical identity and declared descendants; family help lists partial child coverage and links the curated models-context topic separately. Hidden entries remain explicitly callable but absent from discovery. Declared capabilities render summaries, known flags/details and parent pointers without inventing operand grammar; declared-prefix groups mark incomplete coverage. Unknown roots and unavailable explicit `ocx help ` detail use concise stderr-only diagnostics/navigation, empty stdout and exit 1, without a full banner. Missing detail never establishes unsupported runtime grammar. Successful appended flag-help parent fallback runs before recovery and preserves its write sink; unresolved explicit help uses stderr regardless of sink. The resolver does not validate runtime command execution. The curated models-context topic shares `MODELS_CONTEXT_USAGE` with `src/cli/models-runtime.ts`, preserving its status/value/provider/all grammar and read-only status example. The catalog projects pure transient recovery candidates from visible registry names/aliases, immediate declared children and curated topics, deduplicated by canonical destination. The pure recovery formatter bounds input/depth/work, matches conservative edit distances including adjacent transpositions and sorts by distance/name; at most three metadata-derived destinations are suggested, never executed or extended with trailing argv. Distant/control/oversized input gets no guess. Root diagnostics echo only a bounded command-shaped first token accepted by `src/lib/redact.ts`; nested diagnostics never echo arbitrary operands. `src/cli/dispatch.ts` reuses the formatter for direct unknown-root dispatch. Capability JSON and provider-specific error semantics remain unchanged. Provider syntax/examples live in registry details; `src/cli/provider.ts` uses the shared renderer for bare/help success (stdout, exit 0) and unknown-action diagnostics/help (stderr, exit 1, no stdout). The transient optional `write` sink defaults to `console.log`, accepts `console.error` for that error path and propagates through parent fallback. Bare provider retains ordinary preflight; only head-help forms bypass it. | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | @@ -143,7 +143,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination. Replacement re-observation captures one clock value for both its remaining budget and absolute end, so the observation window never extends the caller deadline. | -| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint. Discovery resolves fnm's temporary `fnm_multishells` directory to the durable installation directory (`src/codex/shim-path-resolution.ts` takes the realpath of the directory only and keeps the command basename, so npm's command link is never followed into the package file) and refuses to shim when it cannot; the scan's injected path flavour governs join and separator alike. Connect readiness inspects only the first 16 KiB of regular launcher files through a descriptor, with nonblocking POSIX opens and a post-open file-type check. It follows npm/fnm symlinks, skips special files, and retains unreadable regular candidates as non-shims so a later launcher is not falsely reported active. `src/cli/codex-shim-readiness.ts` reports a healthy tracked shim as inactive when a completed scan finds no PATH command, and reports activation unverified when the scan throws; installation health alone never proves PATH activation. These diagnostics do not change the connect exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers FIFO replacement, symlinks and bounded reads. | +| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration; `src/codex/shim-diagnostics.ts` owns diagnosis/status and distinguishes runnable from active overlays. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers FIFO replacement, symlinks and bounded reads. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects @@ -303,22 +303,22 @@ atomic OS guarantee against unobserved PID reuse. > Decision record: [ADR-0003](decisions/ADR-0003-lifecycle.md) -An installed Codex shim is checked on ordinary CLI startup with a regular-file/1 MiB state bound plus -bounded metadata and prefix reads. A complete replacement must produce identical fingerprints and -prefixes across a 100 ms observation interval; changing launchers are silently deferred, while mixed -sibling sets warn and defer as a unit. Guarded repair holds a self-identifying atomic-mkdir -interprocess lock across its final revalidation, rename, shim write, and state commit. Its owner record -uses the unique token as the filename, so stale-owner deletion cannot name a successor's record. An -aged lock is reclaimed only when its owner PID is no longer alive and the same token, lock-directory -identity, and owner fingerprint are still present immediately before deletion. Repair preflights every -tracked sibling before mutation and rolls back earlier siblings in reverse order on a later race. -Failures warn without changing the requested command's exit behavior. The probe uses read-only config -diagnostics only for a confirmed candidate and never reads adjacent auth state. - -Unix install-probe cleanup refusals retain their fail-closed behavior and report a bounded -diagnostic suffix: a fixed probe phase, allowlisted native error/signal, and bounded exit status. The probe supervisor re-enters its executable with `BUN_BE_BUN=1` to run the inline script even from compiled ocx; the saved launcher receives an environment without that interpreter flag. -Metadata contents, launcher paths and raw child errors never enter that suffix. Diagnostic -classification does not grant process ownership or change rollback/termination policy. An explicit `codex-shim install` (`src/cli/dispatch.ts`) exits nonzero when installation is refused or the resulting shim is unhealthy, printing the diagnostic summary; an already-installed healthy shim succeeds. +An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. Changing launchers defer; failures warn without changing the requested command’s exit behavior. +All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor. +Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder. +Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. +Unix installation owns only the private home/bin/codex wrapper and codex-shell-env.sh. The native manager-owned launcher stays in place; its recorded durable entry follows package upgrades and version rollback. +`src/codex/shim-state-file.ts` decodes schema 2, mode `path-overlay`, supported Unix platform, exact private `wrapperPath`, and distinct absolute `launcherPath`; contradictory compatibility fields are invalid. State can record wrapper/env inode identities and a transaction id. +Its compatibility view exposes one read-only backing tuple with original/backup/real paths all equal to `launcherPath`; mutation dispatches by mode/platform before interpreting that tuple as legacy backup data. State writes use exclusive same-directory 0600 staging and atomic publication. +Private publication stages and probes the wrapper, environment file and state under the lock, revalidates the native launcher, and journals prior and created file identities. The state commit marks completion; failures restore owned prior private files without overwriting concurrent replacements. +Explicit install alone migrates an unambiguous recorded legacy Unix shim: native restoration never replaces a newer entry, and recovery remains retryable. Failure after restoration never reinstalls a wrapper at the package-manager path. Missing/unusable native entries require manager repair. +Unix automatic repair refreshes only the recorded private wrapper, never native entries or a legacy in-place shim, and never discovers another installation. Current runnable content avoids loading config; otherwise repair lazily respects `codexShimAutoRestore` and its process opt-out. +Uninstall rereads state under the lock and removes only confirmed private artifacts, preserving the native launcher and concurrent replacements; older Unix shims use explicit recorded restoration. Users remove their own shell source line. +Activation is explicit and idempotent: sourcing codex-shell-env.sh puts the private bin first after manager PATH setup, without editing shell startup files or changing the installer’s parent shell. The file contains no service token. +Unix install-probe cleanup refusals retain their fail-closed behavior and bounded suffix: fixed phase, allowlisted native error/signal, and bounded exit status. Launcher paths, metadata and raw child errors never enter that suffix. +The probe uses a child-specific PATH and startup bypass; read-only config diagnostics are used only for confirmed candidates, never adjacent auth state. Its supervisor re-enters the executable with `BUN_BE_BUN=1` even from compiled ocx; the saved launcher receives no interpreter flag. +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 package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, @@ -356,7 +356,7 @@ The server exposes `POST /api/stop` which restores native Codex config (a siblin `src/cli/connect.ts` resolves only through the first valid local Codex runtime for catalog readiness, then reads that runtime's effort ladder without persisting its selection. Rejected preferred candidates still fall back in priority order. General `ocx status` retains full runtime -discovery and passes its resolved command into readiness, avoiding a second version probe without adding cache state. `ocx connect` also prints a secret-free Codex shim readiness line from `src/cli/codex-shim-readiness.ts`: ready when the healthy shim is active on PATH, unhealthy when its tracked files fail diagnosis, missing when no shim is active, or unverified when the PATH scan throws. +discovery and passes its resolved command into readiness, avoiding a second version probe without adding cache state. `ocx connect` also prints a secret-free Codex shim readiness line from `src/cli/codex-shim-readiness.ts`: ready when the healthy shim is active on PATH, unhealthy when wrapper/native validation fails, missing/not active with the source command for a runnable inactive overlay, or unverified when the PATH scan throws. `ocx status`, `ocx codex-shim status`, and `ocx doctor` skip shim auto-repair; doctor retains unset-token warnings and recommends activation for inactive overlays rather than reinstalling them. `ocx config show` stays outside that lifecycle path. `src/cli/config-command.ts` reads the validated config snapshot and the bounded service-token observation needed for its `_remoteHub` annotation; diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index 97bf3e021b0..c46913c9576 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -203,8 +203,20 @@ function runStatusProbe(options: { (async () => { let exitCode, catalogUnchanged, commandCode; if (process.env.FIXTURE_SURFACE === "status") { + // General status also probes the hub. Keep this readiness fixture offline, + // and assert the request so swallowed transport failures cannot hide drift. + const requests = []; + globalThis.fetch = async input => { + const url = input instanceof Request ? input.url : String(input); + requests.push(url); + if (url !== "https://hub.example.test/v1/hub-state") throw new Error("unexpected fixture request: " + url); + return Response.json({ error: "offline fixture" }, { status: 503 }); + }; const { collectStatus } = require("./src/cli/status"); const view = await collectStatus(); + if (JSON.stringify(requests) !== JSON.stringify(["https://hub.example.test/v1/hub-state"])) { + throw new Error("unexpected status fixture requests: " + JSON.stringify(requests)); + } const observed = calls(); console.log(JSON.stringify({ lines: [], commandCode: 0, status: view.json.connection, diff --git a/tests/codex-integration/codex-shim-autorestore.test.ts b/tests/codex-integration/codex-shim-autorestore.test.ts index 5f942c3f5dc..7f11b97ff02 100644 --- a/tests/codex-integration/codex-shim-autorestore.test.ts +++ b/tests/codex-integration/codex-shim-autorestore.test.ts @@ -1,6 +1,7 @@ +import { isolateCodexShimEnvironment, withInstalledShim } from "../helpers/codex-shim-install-fixture"; import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CodexShimAutoRestoreResult } from "../../src/codex/shim"; @@ -14,6 +15,8 @@ import { autoRestoreCodexShim, CODEX_SHIM_STATE_MAX_BYTES, installCodexShim } fr import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +isolateCodexShimEnvironment(); + const SHIM_MARKER = "opencodex codex autostart shim"; function cliDeps( @@ -36,13 +39,13 @@ function cliDeps( } describe("Codex shim CLI auto-restore policy", () => { - test("skips destructive and explicit repair commands but keeps status and ordinary commands eligible", () => { + test("skips inspection, destructive, and explicit repair commands but keeps start and ensure eligible", () => { expect(skipsCodexShimAutoRestore("uninstall", ["uninstall"])).toBe(true); expect(skipsCodexShimAutoRestore("remove", ["remove"])).toBe(true); - for (const subcommand of ["install", "uninstall", "remove"]) { + for (const subcommand of ["install", "uninstall", "remove", "status"]) { expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", subcommand])).toBe(true); } - expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", "status"])).toBe(false); + expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", "status"])).toBe(true); for (const action of ["check", "future-action", "bad", undefined]) { const args = ["system", "codex-cli-update", ...(action ? [action] : [])]; expect(skipsCodexShimAutoRestore("system", args)).toBe(true); @@ -52,7 +55,17 @@ describe("Codex shim CLI auto-restore policy", () => { // consent surface must not trigger a shim repair side effect first. expect(skipsCodexShimAutoRestore("resolve", ["resolve"])).toBe(true); expect(skipsCodexShimAutoRestore("resolve", ["resolve", "--json"])).toBe(true); - expect(skipsCodexShimAutoRestore("status", ["status"])).toBe(false); + for (const command of ["status", "doctor"]) { + expect(skipsCodexShimAutoRestore(command, [command])).toBe(true); + const { deps, warnings, readConfigCalls } = cliDeps({ status: "healthy" }, { + restore: () => { throw new Error("inspection must not restore"); }, + }); + maybeAutoRestoreCodexShim(command, [command], deps); + expect(warnings).toEqual([]); + expect(readConfigCalls()).toBe(0); + } + expect(skipsCodexShimAutoRestore("start", ["start"])).toBe(false); + expect(skipsCodexShimAutoRestore("ensure", ["ensure"])).toBe(false); }); test("restore failure -> warning only, command succeeds", () => { @@ -60,7 +73,7 @@ describe("Codex shim CLI auto-restore policy", () => { restore: () => { throw new Error("permission denied"); }, }); - expect(maybeAutoRestoreCodexShim("status", ["status"], deps)).toBeUndefined(); + expect(maybeAutoRestoreCodexShim("ensure", ["ensure"], deps)).toBeUndefined(); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("continuing without it"); expect(warnings[0]).toContain("ocx codex-shim install"); @@ -68,7 +81,7 @@ describe("Codex shim CLI auto-restore policy", () => { test("successful automatic repair warns exactly once", () => { const { deps, warnings } = cliDeps({ status: "restored", message: "restored shim" }); - maybeAutoRestoreCodexShim("status", ["status"], deps); + maybeAutoRestoreCodexShim("ensure", ["ensure"], deps); expect(warnings).toEqual([expect.stringContaining("automatic repair after Codex update")]); }); @@ -77,14 +90,14 @@ describe("Codex shim CLI auto-restore policy", () => { status: "deferred", message: "tracked launcher siblings are in a mixed shim/replacement state", }); - maybeAutoRestoreCodexShim("status", ["status"], deps); + maybeAutoRestoreCodexShim("ensure", ["ensure"], deps); expect(warnings).toEqual([expect.stringContaining("mixed shim/replacement state")]); }); test("healthy, not-installed, disabled, and deferred outcomes stay silent and lazy", () => { for (const status of ["healthy", "not-installed", "disabled", "deferred"] as const) { const { deps, warnings, readConfigCalls } = cliDeps({ status }); - maybeAutoRestoreCodexShim("status", ["status"], deps); + maybeAutoRestoreCodexShim("ensure", ["ensure"], deps); expect(warnings).toEqual([]); expect(readConfigCalls()).toBe(0); } @@ -105,7 +118,7 @@ describe("Codex shim CLI auto-restore policy", () => { error: null, }), }); - maybeAutoRestoreCodexShim("status", ["status"], deps); + maybeAutoRestoreCodexShim("ensure", ["ensure"], deps); expect(enabledValue).toBe(false); expect(warnings).toEqual([]); } @@ -123,7 +136,7 @@ describe("Codex shim CLI auto-restore policy", () => { restore: autoRestoreCodexShim, }); - maybeAutoRestoreCodexShim("status", ["status"], deps); + maybeAutoRestoreCodexShim("ensure", ["ensure"], deps); expect(warnings).toEqual([expect.stringContaining("exceeds the 1 MiB startup limit")]); expect(readConfigCalls()).toBe(0); @@ -135,12 +148,12 @@ describe("Codex shim CLI auto-restore policy", () => { } }); - test("an actionable shim replacement stays byte-identical for the updater inspection namespace", async () => { + test("a native package-manager upgrade leaves the private overlay intact during updater inspection", async () => { if (process.platform === "win32") return; const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-update-inspection-bin-")); const home = mkdtempSync(join(tmpdir(), "ocx-shim-update-inspection-home-")); const wrapper = join(binDir, "codex"); - const backup = join(binDir, "codex.opencodex-real"); + const overlay = join(home, "bin", "codex"); const statePath = join(home, "codex-shim.json"); const replacement = "#!/bin/sh\necho externally updated codex\n"; const oldPath = process.env.PATH; @@ -153,9 +166,9 @@ describe("Codex shim CLI auto-restore policy", () => { expect(installCodexShim().installed).toBe(true); writeFileSync(wrapper, replacement, "utf8"); chmodSync(wrapper, 0o755); - await Bun.sleep(120); + const beforeWrapper = readFileSync(wrapper); - const beforeBackup = readFileSync(backup); + const beforeOverlay = readFileSync(overlay); const beforeState = readFileSync(statePath); const result = spawnSync(process.execPath, [ @@ -170,7 +183,7 @@ describe("Codex shim CLI auto-restore policy", () => { expect(() => JSON.parse(result.stdout)).not.toThrow(); expect(result.stderr).not.toContain("automatic repair after Codex update"); expect(readFileSync(wrapper)).toEqual(beforeWrapper); - expect(readFileSync(backup)).toEqual(beforeBackup); + expect(readFileSync(overlay)).toEqual(beforeOverlay); expect(readFileSync(statePath)).toEqual(beforeState); } finally { if (oldPath === undefined) delete process.env.PATH; @@ -182,42 +195,29 @@ describe("Codex shim CLI auto-restore policy", () => { } }, 20_000); - test("shim replaced -> next ocx command auto-restores and warns", async () => { + test("status and doctor preserve a missing overlay; start and ensure still repair it", () => { if (process.platform === "win32") return; - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-activation-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-activation-home-")); - const wrapper = join(binDir, "codex"); - const backup = join(binDir, "codex.opencodex-real"); - const replacement = "#!/bin/sh\necho externally updated codex\n"; - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - try { - process.env.PATH = binDir; - process.env.OPENCODEX_HOME = home; - writeFileSync(wrapper, "#!/bin/sh\necho original codex\n", "utf8"); - chmodSync(wrapper, 0o755); - expect(installCodexShim().installed).toBe(true); - writeFileSync(wrapper, replacement, "utf8"); - chmodSync(wrapper, 0o755); - await Bun.sleep(120); - - const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex-shim", "status"], { - encoding: "utf8", - env: { ...process.env, PATH: binDir, OPENCODEX_HOME: home }, - }); - - expect(result.status).toBe(0); - expect(result.stderr).toContain("automatic repair after Codex update"); - expect(result.stdout).toContain("wrapper shim present"); - expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER); - expect(readFileSync(backup, "utf8")).toBe(replacement); - } finally { - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } - }, 20_000); + withInstalledShim(({ wrappers, launchers, statePath }) => { + const nativeBefore = readFileSync(launchers[0]); + const stateBefore = readFileSync(statePath); + rmSync(wrappers[0]); + for (const command of ["status", "doctor"]) { + const { deps, warnings } = cliDeps({ status: "healthy" }, { restore: autoRestoreCodexShim }); + maybeAutoRestoreCodexShim(command, [command], deps); + expect(warnings).toEqual([]); + expect(existsSync(wrappers[0])).toBe(false); + expect(readFileSync(statePath)).toEqual(stateBefore); + } + for (const command of ["start", "ensure"]) { + const { deps, warnings } = cliDeps({ status: "healthy" }, { + restore: options => autoRestoreCodexShim({ ...options, stabilitySleep: () => {} }), + }); + maybeAutoRestoreCodexShim(command, [command], deps); + expect(warnings).toEqual([expect.stringContaining("automatic repair after Codex update")]); + expect(readFileSync(wrappers[0], "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(launchers[0])).toEqual(nativeBefore); + rmSync(wrappers[0]); + } + }); + }, 30_000); }); diff --git a/tests/codex-integration/codex-shim-destroyed-probe.test.ts b/tests/codex-integration/codex-shim-destroyed-probe.test.ts index a46ab5c74c4..0ddd987156a 100644 --- a/tests/codex-integration/codex-shim-destroyed-probe.test.ts +++ b/tests/codex-integration/codex-shim-destroyed-probe.test.ts @@ -11,10 +11,10 @@ describe("version-manager shim destruction (#2412)", () => { test("a destroyed shim diagnostic does not open a non-file launcher", () => { if (process.platform === "win32") return; setCodexShimProbeObservationMsForTests(20); - withInstalledShim(({ home, wrappers, backups }) => { + withInstalledShim(({ home, wrappers, launchers }) => { rmSync(wrappers[0]); expect(spawnSync("mkfifo", [wrappers[0]]).status).toBe(0); - rmSync(backups[0]); + rmSync(launchers[0]); const shimModule = repoPath("src", "codex", "shim.ts"); const script = ` const { autoRestoreCodexShim } = await import(${JSON.stringify(shimModule)}); diff --git a/tests/codex-integration/codex-shim-ensure-failure.test.ts b/tests/codex-integration/codex-shim-ensure-failure.test.ts index ab652d64305..302fce3a604 100644 --- a/tests/codex-integration/codex-shim-ensure-failure.test.ts +++ b/tests/codex-integration/codex-shim-ensure-failure.test.ts @@ -18,9 +18,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildUnixCodexShim } from "../../src/codex/shim"; import { CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../../src/codex/shim-templates"; +import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +isolateCodexShimEnvironment(); + /** The shim refuses to re-enter itself, so a nested test run must not inherit its guard state. */ function cleanShimEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, OCX_SHIM_BYPASS: "" }; diff --git a/tests/codex-integration/codex-shim-fnm.test.ts b/tests/codex-integration/codex-shim-fnm.test.ts index bee58d3c3e3..c637059a2d2 100644 --- a/tests/codex-integration/codex-shim-fnm.test.ts +++ b/tests/codex-integration/codex-shim-fnm.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { findCodexOnPath, installCodexShim, lastCodexDiscoveryError, setCodexShimProbeObservationMsForTests } from "../../src/codex/shim"; import { SHIM_MARKER } from "../../src/codex/shim-templates"; -import { prependPath } from "../helpers/codex-shim-install-fixture"; +import { isolateCodexShimEnvironment, prependPath } from "../helpers/codex-shim-install-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +isolateCodexShimEnvironment(); + describe("fnm Codex shim discovery", () => { test.skipIf(process.platform === "win32")( "Unix fnm multishell install records only the durable node installation target", @@ -45,10 +47,15 @@ describe("fnm Codex shim discovery", () => { expect(state).toContain(stableCodex); expect(state).not.toContain("fnm_multishells"); expect(state).not.toContain(packageCodex); - expect(readFileSync(stableCodex, "utf8")).toContain(SHIM_MARKER); - expect(readFileSync(multishellCodex, "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(join(home, "bin", "codex"), "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(stableCodex, "utf8")).toContain("fnm-stable-codex"); + expect(readFileSync(multishellCodex, "utf8")).toContain("fnm-stable-codex"); expect(readFileSync(packageCodex, "utf8")).toContain("fnm-stable-codex"); - expect(lstatSync(`${stableCodex}.opencodex-real`).isSymbolicLink()).toBe(true); + expect(lstatSync(stableCodex).isSymbolicLink()).toBe(true); + expect(existsSync(`${stableCodex}.opencodex-real`)).toBe(false); + removeTreeWithRetry(join(root, "fnm_multishells")); + expect(JSON.parse(state)).toMatchObject({ schema: 2, launcherPath: join(realpathSync(stableBin), "codex") }); + expect(readFileSync(stableCodex, "utf8")).toContain("fnm-stable-codex"); } finally { setCodexShimProbeObservationMsForTests(null); if (oldPath === undefined) delete process.env.PATH; diff --git a/tests/codex-integration/codex-shim-migration.test.ts b/tests/codex-integration/codex-shim-migration.test.ts new file mode 100644 index 00000000000..3c96b2ec1bd --- /dev/null +++ b/tests/codex-integration/codex-shim-migration.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { autoRestoreCodexShim, buildUnixCodexShim, installCodexShim, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, uninstallCodexShim } from "../../src/codex/shim"; +import { readState, type ShimState } from "../../src/codex/shim-state-file"; + +function fixture(run: (f: { root: string; home: string; native: string; backup: string; quarantine: string; wrapper: string; statePath: string; state: ShimState; legacyBytes: string; version: (name: string) => string }) => void): void { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-overlay-migration-")); + const home = join(root, "home"); + const bin = join(root, "manager"); + fs.mkdirSync(home, { mode: 0o700 }); + fs.mkdirSync(bin); + const previous = { ...process.env }; + const native = join(bin, "codex"); + const backup = `${native}.opencodex-real`; + const quarantine = `${native}.opencodex-migrating`; + const statePath = join(home, "codex-shim.json"); + const state: ShimState = { platform: process.platform, wrapperPath: native, originalPath: native, backupPath: backup }; + const version = (name: string) => { + const path = join(root, name); + fs.writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' '${name}'\n`, { mode: 0o755 }); + return path; + }; + try { + process.env.HOME = root; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(root, "codex-home"); + process.env.PATH = `${bin}:/usr/bin:/bin`; + for (const key of ["CODEX_CLI_PATH", "OCX_SHIM_ACTIVE_PID", "OCX_SHIM_ACTIVE_DEPTH", "OCX_SHIM_PROBE", "OCX_SHIM_PROBE_ACTIVE"]) delete process.env[key]; + setCodexShimProbeObservationMsForTests(20); + fs.symlinkSync(version("v1"), backup); + const legacyBytes = buildUnixCodexShim(backup, process.execPath, "unused", "system"); + fs.writeFileSync(native, legacyBytes, { mode: 0o755 }); + fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 }); + run({ root, home, native, backup, quarantine, wrapper: join(home, "bin", "codex"), statePath, state, legacyBytes, version }); + } finally { + mock.restore(); + setCodexShimProbeHookForTests(null); + setCodexShimProbeObservationMsForTests(null); + process.env = previous; + fs.rmSync(root, { recursive: true, force: true }); + } +} + +const lexical = (path: string) => { try { return fs.lstatSync(path); } catch { return null; } }; + +describe.skipIf(process.platform === "win32")("legacy Unix shim migration", () => { + test("explicit install restores backup symlink and commits schema 2 without in-place wrapping", () => fixture(f => { + const target = fs.readlinkSync(f.backup); + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("ineligible"); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(fs.readlinkSync(f.native)).toBe(target); + expect(lexical(f.backup)).toBeNull(); + expect(lexical(f.quarantine)).toBeNull(); + expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); + expect(installCodexShim()).toMatchObject({ installed: false, runnable: true }); + })); + + test("regular backup restoration retains its inode", () => fixture(f => { + fs.unlinkSync(f.backup); + fs.writeFileSync(f.backup, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const inode = fs.lstatSync(f.backup).ino; + expect(installCodexShim().installed).toBe(true); + expect(fs.lstatSync(f.native).ino).toBe(inode); + expect(lexical(f.backup)).toBeNull(); + })); + + test("newer native entry wins over a surviving older backup", () => fixture(f => { + const oldTarget = fs.readlinkSync(f.backup); + fs.unlinkSync(f.native); + fs.symlinkSync(f.version("newer"), f.native); + const inode = fs.lstatSync(f.native).ino; + expect(installCodexShim().installed).toBe(true); + expect(fs.lstatSync(f.native).ino).toBe(inode); + expect(fs.readlinkSync(f.native)).toContain("newer"); + expect(fs.readlinkSync(f.backup)).toBe(oldTarget); + })); + + test("broken backup is restored lexically, retains recovery state and resumes after manager repair", () => fixture(f => { + const target = fs.readlinkSync(f.backup); + fs.unlinkSync(target); + const before = fs.readFileSync(f.statePath, "utf8"); + expect(installCodexShim().installed).toBe(false); + expect(fs.lstatSync(f.native).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(f.native)).toBe(target); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(before); + fs.writeFileSync(target, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(lexical(f.quarantine)).toBeNull(); + })); + + test("missing backup refuses without destroying confirmed legacy wrapper or state", () => fixture(f => { + fs.unlinkSync(f.backup); + const raw = fs.readFileSync(f.statePath, "utf8"); + expect(installCodexShim().installed).toBe(false); + expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + expect(lexical(f.wrapper)).toBeNull(); + })); + + test.each(["multiple", "preserve-only", "alternate-real", "wrong-backup", "wrong-wrapper"])("ambiguous %s layout refuses and preserves all artifacts", kind => fixture(f => { + const file = { wrapperPath: f.native, originalPath: f.native, backupPath: f.backup }; + const state = kind === "multiple" ? { ...f.state, wrappers: [file, { ...file }] } + : kind === "preserve-only" ? { ...f.state, wrappers: [{ ...file, preserveOnly: true }] } + : kind === "alternate-real" ? { ...f.state, wrappers: [{ ...file, realPath: f.backup }] } + : kind === "wrong-backup" ? { ...f.state, backupPath: join(f.root, "other") } + : { ...f.state, wrapperPath: join(f.root, "other") }; + const raw = JSON.stringify(state); + fs.writeFileSync(f.statePath, raw); + const backup = fs.readlinkSync(f.backup); + const result = installCodexShim(); + expect(result.installed).toBe(false); + expect(result.message).toContain("ambiguous"); + expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + expect(fs.readlinkSync(f.backup)).toBe(backup); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + })); + + test("unrecorded quarantine refuses rather than guessing ownership", () => fixture(f => { + fs.renameSync(f.native, f.quarantine); + expect(installCodexShim().installed).toBe(false); + expect(fs.readFileSync(f.quarantine, "utf8")).toBe(f.legacyBytes); + expect(lexical(f.native)).toBeNull(); + expect(lexical(f.backup)?.isSymbolicLink()).toBe(true); + })); + + test("retry after journaled evacuation restores native without resurrecting old wrapper", () => fixture(f => { + const unlink = fs.unlinkSync; + let injected = false; + const hook = spyOn(fs, "unlinkSync").mockImplementation(path => { + if (String(path) === f.backup && !injected) { injected = true; throw new Error("interrupted migration cleanup"); } + return unlink(path); + }); + expect(installCodexShim().installed).toBe(false); + hook.mockRestore(); + expect(injected).toBe(true); + expect(fs.lstatSync(f.native).isSymbolicLink()).toBe(true); + expect(lexical(f.quarantine)).not.toBeNull(); + const retry = installCodexShim(); + expect(retry.installed, retry.message).toBe(true); + expect(fs.lstatSync(f.native).isSymbolicLink()).toBe(true); + expect(lexical(f.quarantine)).toBeNull(); + })); + + test("concurrent manager publication is preserved during no-replace backup restoration", () => fixture(f => { + const publish = fs.symlinkSync; + let injected = false; + const newTarget = f.version("concurrent"); + const hook = spyOn(fs, "symlinkSync").mockImplementation((target, path, type) => { + if (String(path) === f.native && !injected) { + injected = true; + publish(newTarget, path, type); + } + return publish(target, path, type); + }); + const result = installCodexShim(); + hook.mockRestore(); + expect(injected).toBe(true); + expect(result.installed, result.message).toBe(true); + expect(fs.readlinkSync(f.native)).toBe(newTarget); + expect(lexical(f.backup)?.isSymbolicLink()).toBe(true); + })); + + test("probe failure after migration keeps native restored and legacy state for retry", () => fixture(f => { + const raw = fs.readFileSync(f.statePath, "utf8"); + setCodexShimProbeHookForTests(() => { throw new Error("overlay probe interrupted"); }); + const failed = installCodexShim(); + expect(failed).toMatchObject({ installed: false, refused: true }); + expect(failed.message).toContain("overlay probe interrupted"); + expect(fs.lstatSync(f.native).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + expect(lexical(f.quarantine)).toBeNull(); + setCodexShimProbeHookForTests(null); + expect(installCodexShim().installed).toBe(true); + })); + + test("legacy uninstall restores native and removes only the recorded state", () => fixture(f => { + const removed = uninstallCodexShim(); + expect(removed.removed, removed.message).toBe(true); + expect(fs.lstatSync(f.native).isSymbolicLink()).toBe(true); + expect(lexical(f.statePath)).toBeNull(); + expect(lexical(f.backup)).toBeNull(); + })); + + test.each(["missing", "broken"])("legacy uninstall with %s backup retains recovery state and reports failure", kind => fixture(f => { + if (kind === "missing") fs.unlinkSync(f.backup); + else fs.unlinkSync(fs.readlinkSync(f.backup)); + const raw = fs.readFileSync(f.statePath, "utf8"); + expect(uninstallCodexShim().removed).toBe(false); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + if (kind === "missing") expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + else expect(lexical(f.native)?.isSymbolicLink()).toBe(true); + })); +}); diff --git a/tests/codex-integration/codex-shim-overlay-candidates.test.ts b/tests/codex-integration/codex-shim-overlay-candidates.test.ts new file mode 100644 index 00000000000..1b3d663981e --- /dev/null +++ b/tests/codex-integration/codex-shim-overlay-candidates.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { codexShimCommandCandidates } from "../../src/codex/catalog/bundled"; +import { probeCodexSupportsModeHint } from "../../src/codex/features"; +import { resetCodexRuntimeResolveCacheForTests, resolveCodexRuntime, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { decodeOverlayState, readStateResult } from "../../src/codex/shim-state-file"; + +function fixture(run: (f: { root: string; home: string; native: string; wrapper: string; statePath: string; state: Record }) => void): void { + const root = mkdtempSync(join(tmpdir(), "ocx-overlay-candidates-")); + const home = join(root, "home"); + const wrapper = join(home, "bin", "codex"); + const native = join(root, "manager", "codex"); + const statePath = join(home, "codex-shim.json"); + mkdirSync(dirname(wrapper), { recursive: true }); + mkdirSync(dirname(native)); + const previous = { ...process.env }; + const state = { schema: 2, mode: "path-overlay", platform: process.platform, wrapperPath: wrapper, launcherPath: native }; + try { + process.env.HOME = root; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(root, "codex-home"); + process.env.PATH = ""; + delete process.env.CODEX_CLI_PATH; + resetCodexRuntimeResolveCacheForTests(); + writeFileSync(wrapper, `#!/bin/sh\n# opencodex codex autostart shim\nexec '${native}' "$@"\n`, { mode: 0o755 }); + writeFileSync(native, "#!/bin/sh\nprintf 'codex-cli 0.159.2\\n'\n", { mode: 0o755 }); + writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 }); + run({ root, home, native, wrapper, statePath, state }); + } finally { + resetCodexRuntimeResolveCacheForTests(); + process.env = previous; + rmSync(root, { recursive: true, force: true }); + } +} + +function selected(command: string): void { + resetCodexRuntimeResolveCacheForTests(); + setCodexRuntimeResolveCacheForTests({ runtime: { command, version: "test", source: "fallback" }, failures: [] }); +} +function native(supported: boolean): Buffer { + return Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from(supported ? "multi_agent_mode_hint_text" : "older_codex_schema")]); +} + +describe.skipIf(process.platform === "win32")("overlay native launcher candidates", () => { + test("bundled catalog and runtime prefer the validated v2 durable launcher", () => fixture(f => { + const raw = readFileSync(f.statePath, "utf8"); + expect(codexShimCommandCandidates()[0]).toBe(f.native); + const attempts: string[] = []; + const runtime = resolveCodexRuntime({ + configDir: f.home, env: { PATH: "", HOME: f.root, CODEX_HOME: join(f.root, "codex-home") }, discoverAlternatives: false, + existsSync: path => String(path) === f.native || String(path) === f.wrapper, + execFileSync: command => { attempts.push(command); return "codex-cli 0.159.2"; }, + }); + expect(runtime.runtime).toMatchObject({ command: f.native, version: "0.159.2", source: "shim" }); + expect(attempts).toEqual([f.native]); + expect(readFileSync(f.statePath, "utf8")).toBe(raw); + })); + + test.each(["schema", "mode", "relative", "foreign-wrapper", "self-launcher", "legacy-conflict", "tuple-conflict", "windows", "control-path", "identity"])("rejects invalid or mismatched %s overlay state without using its launcher", kind => fixture(f => { + const state = { ...f.state }; + if (kind === "schema") state.schema = 1; + else if (kind === "mode") state.mode = "in-place"; + else if (kind === "relative") state.launcherPath = "manager/codex"; + else if (kind === "foreign-wrapper") state.wrapperPath = join(f.root, "foreign", "codex"); + else if (kind === "self-launcher") state.launcherPath = f.wrapper; + else if (kind === "legacy-conflict") state.backupPath = join(f.root, "conflicting", "codex"); + else if (kind === "tuple-conflict") state.wrappers = [{ wrapperPath: f.wrapper, originalPath: f.native, backupPath: join(f.root, "other", "codex") }]; + else if (kind === "windows") state.platform = "win32"; + else if (kind === "control-path") state.launcherPath = `${f.native}\nother`; + else state.wrapperIdentity = { dev: 1, ino: "invalid" }; + const raw = JSON.stringify(state); + writeFileSync(f.statePath, raw); + expect(decodeOverlayState(state, f.home)).toBeNull(); + expect(readStateResult(f.statePath)).toMatchObject({ present: true, state: null }); + expect(codexShimCommandCandidates()).toEqual([]); + const attempts: string[] = []; + resolveCodexRuntime({ configDir: f.home, env: { PATH: "", HOME: f.root, CODEX_HOME: join(f.root, "codex-home") }, discoverAlternatives: false, + existsSync: path => String(path) === f.native, + execFileSync: command => { attempts.push(command); return "codex-cli 0.159.2"; }, + }); + expect(attempts).not.toContain(f.native); + expect(readFileSync(f.statePath, "utf8")).toBe(raw); + })); + + test("legacy catalog and runtime still prefer recorded backup before original and wrapper", () => fixture(f => { + const backup = `${f.native}.opencodex-real`; + writeFileSync(backup, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync(f.statePath, JSON.stringify({ platform: process.platform, wrapperPath: f.native, originalPath: f.native, backupPath: backup })); + expect(codexShimCommandCandidates().slice(0, 2)).toEqual([backup, f.native]); + const result = resolveCodexRuntime({ configDir: f.home, env: { PATH: "", HOME: f.root, CODEX_HOME: join(f.root, "codex-home") }, discoverAlternatives: false, + existsSync: path => String(path) === backup || String(path) === f.native, + execFileSync: () => "codex-cli 0.159.2", + }); + expect(result.runtime).toMatchObject({ command: backup, source: "shim" }); + })); + + test("feature probing follows v2 launcher only for the selected recorded wrapper", () => fixture(f => { + writeFileSync(f.native, native(true)); + const decoy = join(f.root, "decoy-codex"); + writeFileSync(decoy, native(false)); + selected(f.wrapper); + expect(probeCodexSupportsModeHint()).toBe(true); + selected(decoy); + expect(probeCodexSupportsModeHint()).toBe(false); + const otherWrapper = join(f.root, "untracked-codex"); + writeFileSync(otherWrapper, "#!/bin/sh\n# opencodex codex autostart shim\nexit 0\n"); + selected(otherWrapper); + expect(probeCodexSupportsModeHint()).toBeNull(); + })); + + test("feature probing refuses a mismatched overlay binding and a foreign recorded wrapper", () => fixture(f => { + writeFileSync(f.native, native(true)); + writeFileSync(f.statePath, JSON.stringify({ ...f.state, backupPath: join(f.root, "other", "codex") })); + selected(f.wrapper); + expect(probeCodexSupportsModeHint()).toBeNull(); + const foreign = join(f.root, "foreign-codex"); + writeFileSync(foreign, "#!/bin/sh\nexit 0\n"); + writeFileSync(f.statePath, JSON.stringify({ ...f.state, wrapperPath: foreign })); + selected(foreign); + expect(probeCodexSupportsModeHint()).toBeNull(); + })); + + test("selected wrapper's npm launcher resolves only its own platform package", () => fixture(f => { + const launcher = join(f.root, "node_modules", "@openai", "codex", "bin", "codex.js"); + const pkg = join(f.root, "node_modules", "@openai", "codex-darwin-arm64"); + const binary = join(pkg, "vendor", "aarch64-apple-darwin", "bin", "codex"); + mkdirSync(dirname(launcher), { recursive: true }); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(launcher, "#!/usr/bin/env node\n"); + writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "@openai/codex-darwin-arm64", version: "test" })); + writeFileSync(binary, native(true)); + writeFileSync(f.statePath, JSON.stringify({ ...f.state, launcherPath: launcher })); + selected(f.wrapper); + expect(probeCodexSupportsModeHint()).toBe(true); + selected(f.native); + expect(probeCodexSupportsModeHint()).toBeNull(); + })); +}); diff --git a/tests/codex-integration/codex-shim-overlay-readiness.test.ts b/tests/codex-integration/codex-shim-overlay-readiness.test.ts new file mode 100644 index 00000000000..1af3ff5e7e9 --- /dev/null +++ b/tests/codex-integration/codex-shim-overlay-readiness.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { diagnoseCodexShim, installCodexShim, setCodexShimProbeObservationMsForTests } from "../../src/codex/shim"; +import { findFirstCodexOnPath } from "../../src/codex/shim-path-resolution"; +import { inspectCodexShimForConnect } from "../../src/cli/codex-shim-readiness"; + +function fixture(run: (f: { root: string; home: string; native: string; wrapper: string; envFile: string; earlier: string }) => void): void { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-overlay-activation-")); + const home = join(root, "private space's home"); + const bin = join(root, "manager"); + const earlier = join(root, "earlier"); + fs.mkdirSync(home, { mode: 0o700 }); + fs.mkdirSync(bin); + fs.mkdirSync(earlier); + const previous = { ...process.env }; + const native = join(bin, "codex"); + fs.writeFileSync(native, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + try { + process.env.HOME = root; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(root, "codex-home"); + process.env.PATH = `${bin}:/usr/bin:/bin`; + for (const key of ["CODEX_CLI_PATH", "OCX_SHIM_ACTIVE_PID", "OCX_SHIM_ACTIVE_DEPTH", "OCX_SHIM_PROBE", "OCX_SHIM_PROBE_ACTIVE"]) delete process.env[key]; + setCodexShimProbeObservationMsForTests(20); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + run({ root, home, native, wrapper: join(home, "bin", "codex"), envFile: join(home, "codex-shell-env.sh"), earlier }); + } finally { + mock.restore(); + setCodexShimProbeObservationMsForTests(null); + process.env = previous; + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function shellCommand() { + return spawnSync("/bin/sh", ["-c", "command -v codex"], { env: process.env, encoding: "utf8", timeout: 3000 }); +} + +describe.skipIf(process.platform === "win32")("Unix overlay PATH activation", () => { + test("inactive installation succeeds with source guidance and never changes calling PATH", () => fixture(f => { + const path = process.env.PATH; + const result = installCodexShim(); + expect(result).toMatchObject({ installed: false, runnable: true }); + expect(result.message).toContain("codex-shell-env.sh"); + expect(result.message).toContain("after other PATH setup"); + expect(process.env.PATH).toBe(path); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, runnable: true, active: false, healthy: false }); + expect(findFirstCodexOnPath()).toEqual({ path: f.native, isShim: false }); + expect(shellCommand().stdout.trim()).toBe(f.native); + const readiness = inspectCodexShimForConnect(); + expect(readiness.status).toBe("missing"); + expect(readiness.message).toContain("installed but not active"); + expect(readiness.message).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(readiness.message).toContain("codex-shell-env.sh"); + })); + + test("sourcing quoted environment twice is idempotent and moves private bin ahead of later PATH setup", () => fixture(f => { + const shell = spawnSync("/bin/sh", ["-c", '. "$1"; PATH=/usr/bin:$PATH; . "$1"; . "$1"; command -v codex; printf "%s\\n" "$PATH"', "test", f.envFile], { env: process.env, encoding: "utf8", timeout: 3000 }); + expect(shell.error).toBeUndefined(); + expect(shell.status).toBe(0); + const [command, path] = shell.stdout.trimEnd().split("\n"); + expect(command).toBe(f.wrapper); + expect(path!.split(":")[0]).toBe(dirname(f.wrapper)); + expect(path!.split(":").filter(item => item === dirname(f.wrapper))).toHaveLength(1); + process.env.PATH = path; + expect(findFirstCodexOnPath()).toEqual({ path: f.wrapper, isShim: true }); + expect(diagnoseCodexShim()).toMatchObject({ runnable: true, active: true, healthy: true }); + expect(inspectCodexShimForConnect().status).toBe("ready"); + })); + + test.each(["file", "symlink", "directory", "fifo", "fifo-symlink"])("earlier non-command %s does not shadow the executable overlay", kind => fixture(f => { + const command = join(f.earlier, "codex"); + if (kind === "file") fs.writeFileSync(command, "#!/bin/sh\nexit 0\n", { mode: 0o644 }); + else if (kind === "symlink") { + const target = join(f.root, "non-executable"); + fs.writeFileSync(target, "#!/bin/sh\nexit 0\n", { mode: 0o644 }); + fs.symlinkSync(target, command); + } else if (kind === "directory") fs.mkdirSync(command, { mode: 0o755 }); + else { + const fifo = kind === "fifo" ? command : join(f.root, "pipe"); + expect(spawnSync("/usr/bin/mkfifo", [fifo], { timeout: 3000 }).status).toBe(0); + if (kind === "fifo-symlink") fs.symlinkSync(fifo, command); + } + process.env.PATH = `${f.earlier}:${dirname(f.wrapper)}:${dirname(f.native)}:/usr/bin:/bin`; + expect(shellCommand().stdout.trim()).toBe(f.wrapper); + expect(findFirstCodexOnPath()).toEqual({ path: f.wrapper, isShim: true }); + expect(diagnoseCodexShim()).toMatchObject({ active: true, healthy: true }); + expect(inspectCodexShimForConnect().status).toBe("ready"); + })); + + test("executable native command ahead makes the runnable overlay inactive", () => fixture(f => { + process.env.PATH = `${dirname(f.native)}:${dirname(f.wrapper)}:/usr/bin:/bin`; + expect(shellCommand().stdout.trim()).toBe(f.native); + expect(diagnoseCodexShim()).toMatchObject({ runnable: true, active: false, healthy: false }); + expect(inspectCodexShimForConnect().status).toBe("missing"); + })); + + test("a different marker-bearing shim ahead does not activate this tracked overlay", () => fixture(f => { + const other = join(f.earlier, "codex"); + fs.writeFileSync(other, "#!/bin/sh\n# opencodex codex autostart shim\nexit 0\n", { mode: 0o755 }); + process.env.PATH = `${f.earlier}:${dirname(f.wrapper)}:/usr/bin:/bin`; + expect(findFirstCodexOnPath()).toEqual({ path: other, isShim: true }); + expect(shellCommand().stdout.trim()).toBe(other); + expect(diagnoseCodexShim()).toMatchObject({ runnable: true, active: false, healthy: false }); + expect(inspectCodexShimForConnect().status).toBe("missing"); + })); + + test("a symlink alias to this wrapper is active by effective inode identity", () => fixture(f => { + const alias = join(f.earlier, "codex"); + fs.symlinkSync(f.wrapper, alias); + process.env.PATH = `${f.earlier}:/usr/bin:/bin`; + expect(findFirstCodexOnPath()).toEqual({ path: alias, isShim: true }); + expect(shellCommand().stdout.trim()).toBe(alias); + expect(diagnoseCodexShim()).toMatchObject({ runnable: true, active: true, healthy: true }); + expect(inspectCodexShimForConnect().status).toBe("ready"); + })); + + test("absence of any PATH command reports inactive rather than damaged", () => fixture(f => { + process.env.PATH = f.earlier; + expect(findFirstCodexOnPath()).toBeNull(); + expect(shellCommand().status).not.toBe(0); + expect(diagnoseCodexShim()).toMatchObject({ runnable: true, active: false, healthy: false }); + expect(inspectCodexShimForConnect().status).toBe("missing"); + })); + + test("executable but unreadable earlier command continues to shadow the overlay", () => fixture(f => { + const command = join(f.earlier, "codex"); + fs.writeFileSync(command, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + process.env.PATH = `${f.earlier}:${dirname(f.wrapper)}:/usr/bin:/bin`; + const open = fs.openSync; + spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { + if (String(path) === command) throw Object.assign(new Error("denied"), { code: "EACCES" }); + return open(path, flags, mode); + }); + expect(findFirstCodexOnPath()).toEqual({ path: command, isShim: false }); + expect(diagnoseCodexShim()).toMatchObject({ runnable: true, active: false, healthy: false }); + expect(inspectCodexShimForConnect().status).toBe("missing"); + })); + + test("failed PATH inspection stays unverified and does not claim activation", () => fixture(f => { + process.env.PATH = `${dirname(f.wrapper)}:/usr/bin:/bin`; + const result = inspectCodexShimForConnect({ findOnPath: () => { throw new Error("scan interrupted"); } }); + expect(result.status).toBe("unverified"); + expect(result.message).toContain("could not be verified"); + expect(result.message).toContain("OPENCODEX_API_AUTH_TOKEN"); + })); +}); diff --git a/tests/codex-integration/codex-shim-overlay-transaction.test.ts b/tests/codex-integration/codex-shim-overlay-transaction.test.ts new file mode 100644 index 00000000000..d67deba5df8 --- /dev/null +++ b/tests/codex-integration/codex-shim-overlay-transaction.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { autoRestoreCodexShim, diagnoseCodexShim, installCodexShim, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, uninstallCodexShim } from "../../src/codex/shim"; +import { tryAcquireShimRestoreLock } from "../../src/codex/shim-restore-lock"; + +function fixture(run: (f: { root: string; home: string; native: string; wrapper: string; envFile: string; statePath: string }) => void): void { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-overlay-transaction-")); + const home = join(root, "home"); + const bin = join(root, "manager"); + fs.mkdirSync(home, { mode: 0o700 }); + fs.mkdirSync(bin); + const previous = { ...process.env }; + const native = join(bin, "codex"); + fs.writeFileSync(native, "#!/bin/sh\nprintf 'native\\n'\n", { mode: 0o755 }); + try { + process.env.HOME = root; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(root, "codex-home"); + process.env.PATH = `${bin}:/usr/bin:/bin`; + for (const key of ["CODEX_CLI_PATH", "OCX_SHIM_ACTIVE_PID", "OCX_SHIM_ACTIVE_DEPTH", "OCX_SHIM_PROBE", "OCX_SHIM_PROBE_ACTIVE"]) delete process.env[key]; + setCodexShimProbeObservationMsForTests(20); + run({ root, home, native, wrapper: join(home, "bin", "codex"), envFile: join(home, "codex-shell-env.sh"), statePath: join(home, "codex-shim.json") }); + } finally { + mock.restore(); + setCodexShimProbeHookForTests(null); + setCodexShimProbeObservationMsForTests(null); + process.env = previous; + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function saved(path: string) { + const stat = fs.lstatSync(path); + return { bytes: fs.readFileSync(path, "utf8"), ino: stat.ino, mode: stat.mode & 0o777 }; +} +function privateDebris(home: string): string[] { + return [home, join(home, "bin")].flatMap(dir => fs.existsSync(dir) ? fs.readdirSync(dir) : []) + .filter(name => /\.(stage|rollback)$|^\.probe-|^codex-shim.transaction.json$/.test(name)); +} + +describe.skipIf(process.platform === "win32")("Codex overlay publication transactions", () => { + for (const installedBefore of [false, true]) { + test.each(["stage", "probe", "publish", "env-write", "env-publish", "state-commit"])(`${installedBefore ? "refresh" : "fresh install"} rolls back on %s failure`, phase => fixture(f => { + if (installedBefore) { + const result = installCodexShim(); + expect(result.installed, result.message).toBe(true); + // Mode drift forces refresh while the prior wrapper still executes native Codex. + fs.chmodSync(f.envFile, 0o644); + } + const priorRunnable = installedBefore ? diagnoseCodexShim().runnable : undefined; + const targets = [f.wrapper, f.envFile, f.statePath]; + const prior = targets.map(path => fs.existsSync(path) ? saved(path) : null); + const native = saved(f.native); + const open = fs.openSync; + const write = fs.writeFileSync; + const link = fs.linkSync; + const rename = fs.renameSync; + let envFd: number | undefined; + let hit = false; + const fail = () => { hit = true; throw new Error(`injected ${phase}`); }; + spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { + const text = String(path); + if (!hit && phase === "stage" && text.startsWith(`${f.wrapper}.`) && text.endsWith(".stage")) return fail(); + const fd = open(path, flags, mode); + if (text.startsWith(`${f.envFile}.`) && text.endsWith(".stage")) envFd = fd; + return fd; + }); + spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { + if (!hit && phase === "env-write" && envFd !== undefined && file === envFd) return fail(); + return write(file, data, options); + }); + const target = phase === "publish" ? f.wrapper : phase === "env-publish" ? f.envFile : phase === "state-commit" ? f.statePath : null; + spyOn(fs, "linkSync").mockImplementation((from, to) => { + if (!hit && !installedBefore && String(to) === target && String(from).endsWith(".stage")) return fail(); + return link(from, to); + }); + spyOn(fs, "renameSync").mockImplementation((from, to) => { + if (!hit && installedBefore && String(to) === target && String(from).endsWith(".stage")) return fail(); + return rename(from, to); + }); + if (phase === "probe") setCodexShimProbeHookForTests(fail); + const result = installCodexShim(); + mock.restore(); + setCodexShimProbeHookForTests(null); + expect(hit).toBe(true); + expect(result).toMatchObject({ installed: false, refused: true }); + expect(result.message).toContain(`injected ${phase}`); + targets.forEach((path, index) => { + if (prior[index]) expect(saved(path)).toEqual(prior[index]); + else expect(fs.existsSync(path)).toBe(false); + }); + expect(saved(f.native)).toEqual(native); + expect(privateDebris(f.home)).toEqual([]); + if (installedBefore) { + expect(diagnoseCodexShim().runnable).toBe(priorRunnable); + expect(spawnSync(f.wrapper, ["--version"], { env: process.env, encoding: "utf8", timeout: 5000 }).stdout).toBe("native\n"); + } + expect(installCodexShim().installed).toBe(true); + })); + } + + test("chmod-lost owned wrapper is unhealthy, respects opt-out, and repairs with opt-in", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const native = saved(f.native); + fs.chmodSync(f.wrapper, 0o644); + expect(diagnoseCodexShim()).toMatchObject({ runnable: false, healthy: false }); + expect(autoRestoreCodexShim({ enabled: () => false }).status).toBe("disabled"); + expect(fs.lstatSync(f.wrapper).mode & 0o777).toBe(0o644); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("restored"); + expect(diagnoseCodexShim().runnable).toBe(true); + expect(saved(f.native)).toEqual(native); + })); + + test("foreign replacement with identical marker-bearing bytes is preserved by install, repair and uninstall", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const original = saved(f.wrapper); + fs.renameSync(f.wrapper, `${f.wrapper}.previous`); + fs.writeFileSync(f.wrapper, original.bytes, { mode: 0o755 }); + const foreign = saved(f.wrapper); + expect(foreign.ino).not.toBe(original.ino); + const rawState = fs.readFileSync(f.statePath, "utf8"); + expect(diagnoseCodexShim().runnable).toBe(false); + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("ineligible"); + expect(installCodexShim().installed).toBe(false); + expect(uninstallCodexShim().removed).toBe(false); + expect(saved(f.wrapper)).toEqual(foreign); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(rawState); + })); + + test.each(["uninstall", "install", "auto-repair"])("identity-less schema 2 does not authorize %s of a foreign marker-bearing wrapper", operation => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const state = JSON.parse(fs.readFileSync(f.statePath, "utf8")); + delete state.wrapperIdentity; + delete state.envIdentity; + fs.writeFileSync(f.statePath, JSON.stringify(state)); + const original = saved(f.wrapper); + fs.renameSync(f.wrapper, `${f.wrapper}.previous`); + fs.writeFileSync(f.wrapper, `${original.bytes}\n# FOREIGN FILE: not owned by OpenCodex\n`, { mode: 0o755 }); + const foreign = saved(f.wrapper); + const stateBefore = saved(f.statePath); + const envBefore = saved(f.envFile); + const nativeBefore = saved(f.native); + expect(foreign.ino).not.toBe(original.ino); + const result = operation === "uninstall" ? uninstallCodexShim() + : operation === "install" ? installCodexShim() + : autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }); + expect(saved(f.wrapper)).toEqual(foreign); + expect(saved(f.statePath)).toEqual(stateBefore); + expect(saved(f.envFile)).toEqual(envBefore); + expect(saved(f.native)).toEqual(nativeBefore); + if (operation === "uninstall") expect(result).toMatchObject({ removed: false }); + else if (operation === "install") expect(result).toMatchObject({ installed: false, refused: true }); + else expect(result).toMatchObject({ status: "ineligible" }); + expect(result.message).toMatch(/not.*owned|unowned|replaced|identity|ownership/i); + })); + + for (const artifact of ["environment", "state"] as const) { + test(`${artifact} chmod 0666 is not diagnosed healthy`, () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + process.env.PATH = `${dirname(f.wrapper)}:${process.env.PATH}`; + expect(diagnoseCodexShim().healthy).toBe(true); + const path = artifact === "environment" ? f.envFile : f.statePath; + fs.chmodSync(path, 0o666); + const before = saved(path); + expect(diagnoseCodexShim().healthy).toBe(false); + expect(saved(path)).toEqual(before); + })); + + test(`${artifact} chmod 0666 bypasses no ownership checks on automatic healthy fast path`, () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + process.env.PATH = `${dirname(f.wrapper)}:${process.env.PATH}`; + const path = artifact === "environment" ? f.envFile : f.statePath; + fs.chmodSync(path, 0o666); + const before = saved(path); + const wrapper = saved(f.wrapper); + const native = saved(f.native); + const result = autoRestoreCodexShim({ enabled: () => { throw new Error("unsafe artifact must not consult opt-in"); } }); + expect(result.status).toBe("ineligible"); + expect(result.message).toMatch(/owned|permission|writable|private|unsafe/i); + expect(saved(path)).toEqual(before); + expect(saved(f.wrapper)).toEqual(wrapper); + expect(saved(f.native)).toEqual(native); + })); + } + + test.each(["identical", "foreign-content"])("a replaced %s activation file is unhealthy and preserved by auto-repair", kind => fixture(f => { + expect(installCodexShim().installed).toBe(true); + process.env.PATH = `${dirname(f.wrapper)}:${process.env.PATH}`; + const original = saved(f.envFile); + fs.renameSync(f.envFile, `${f.envFile}.previous`); + fs.writeFileSync(f.envFile, kind === "identical" ? original.bytes : "# foreign shell environment\nexport FOREIGN_ENV=1\n", { mode: 0o600 }); + const foreign = saved(f.envFile); + expect(foreign.ino).not.toBe(original.ino); + expect(diagnoseCodexShim().healthy).toBe(false); + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }); + expect(result.status).toBe("ineligible"); + expect(result.message).toMatch(/not.*owned|unowned|replaced|identity|ownership/i); + expect(saved(f.envFile)).toEqual(foreign); + })); + + test("missing owned activation file is unhealthy, respects opt-out and repairs on opt-in", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + process.env.PATH = `${dirname(f.wrapper)}:${process.env.PATH}`; + const env = saved(f.envFile); + const native = saved(f.native); + fs.unlinkSync(f.envFile); + expect(diagnoseCodexShim().healthy).toBe(false); + expect(autoRestoreCodexShim({ enabled: () => false }).status).toBe("disabled"); + expect(fs.existsSync(f.envFile)).toBe(false); + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }); + expect(result.status).toBe("restored"); + expect(fs.readFileSync(f.envFile, "utf8")).toBe(env.bytes); + expect(fs.lstatSync(f.envFile).mode & 0o777).toBe(0o600); + expect(diagnoseCodexShim().healthy).toBe(true); + expect(saved(f.native)).toEqual(native); + })); + + test("concurrent marker-bearing publication during probe is never removed", () => fixture(f => { + let foreign: ReturnType | undefined; + setCodexShimProbeHookForTests(() => { + fs.writeFileSync(f.wrapper, "#!/bin/sh\n# opencodex codex autostart shim\nprintf 'foreign\\n'\n", { mode: 0o755 }); + foreign = saved(f.wrapper); + }); + expect(installCodexShim().installed).toBe(false); + expect(saved(f.wrapper)).toEqual(foreign); + expect(fs.existsSync(f.statePath)).toBe(false); + expect(privateDebris(f.home)).toEqual([]); + })); + + test("a staged-path replacement cannot be adopted as the inode created by OpenCodex", () => fixture(f => { + const open = fs.openSync; + const write = fs.writeFileSync; + let stagedFd: number | undefined; + let stagedPath: string | undefined; + let foreign: ReturnType | undefined; + spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { + const fd = open(path, flags, mode); + if (String(path).startsWith(`${f.wrapper}.`) && String(path).endsWith(".stage")) { + stagedFd = fd; + stagedPath = String(path); + } + return fd; + }); + spyOn(fs, "writeFileSync").mockImplementation((file, bytes, options) => { + write(file, bytes, options); + if (file === stagedFd && stagedPath && !foreign) { + fs.renameSync(stagedPath, `${stagedPath}.original`); + write(stagedPath, "# foreign staged replacement\n", { mode: 0o600 }); + foreign = saved(stagedPath); + } + }); + const result = installCodexShim(); + mock.restore(); + expect(foreign).toBeDefined(); + expect(result).toMatchObject({ installed: false, refused: true }); + expect(result.message).toContain("Staged Codex file replaced"); + expect(saved(stagedPath!)).toEqual(foreign); + expect(fs.existsSync(f.wrapper)).toBe(false); + expect(fs.existsSync(f.statePath)).toBe(false); + })); + + test("incomplete rollback retains its journal and retry recovers before fresh installation", () => fixture(f => { + const link = fs.linkSync; + const unlink = fs.unlinkSync; + let publicationFailed = false; + let recoveryFailed = false; + spyOn(fs, "linkSync").mockImplementation((from, to) => { + if (String(to) === f.statePath && String(from).endsWith(".stage")) { + publicationFailed = true; + throw new Error("interrupted state commit"); + } + return link(from, to); + }); + spyOn(fs, "unlinkSync").mockImplementation(path => { + if (publicationFailed && String(path) === f.wrapper) { + recoveryFailed = true; + throw new Error("interrupted rollback"); + } + return unlink(path); + }); + const result = installCodexShim(); + mock.restore(); + expect(publicationFailed).toBe(true); + expect(recoveryFailed).toBe(true); + expect(result).toMatchObject({ installed: false, refused: true }); + expect(result.message).toContain("recovery"); + expect(fs.existsSync(join(f.home, "codex-shim.transaction.json"))).toBe(true); + expect(fs.existsSync(f.wrapper)).toBe(true); + expect(fs.existsSync(f.statePath)).toBe(false); + const retry = installCodexShim(); + expect(retry.installed, retry.message).toBe(true); + expect(privateDebris(f.home)).toEqual([]); + expect(diagnoseCodexShim().runnable).toBe(true); + })); + + test.each([0o770, 0o707, 0o777])("private bin mode %d is refused without changing it", mode => fixture(f => { + const bin = dirname(f.wrapper); + fs.mkdirSync(bin); + fs.chmodSync(bin, mode); + const native = saved(f.native); + const result = installCodexShim(); + expect(result.installed).toBe(false); + expect(result.message).toContain("group/world writable"); + expect(fs.lstatSync(bin).mode & 0o777).toBe(mode); + expect(saved(f.native)).toEqual(native); + expect(fs.existsSync(f.wrapper)).toBe(false); + })); + + test("symlinked private bin is refused and does not touch its target", () => fixture(f => { + const native = saved(f.native); + fs.symlinkSync(dirname(f.native), dirname(f.wrapper)); + expect(installCodexShim().installed).toBe(false); + expect(fs.lstatSync(dirname(f.wrapper)).isSymbolicLink()).toBe(true); + expect(saved(f.native)).toEqual(native); + expect(fs.existsSync(f.statePath)).toBe(false); + })); + + test("group-writable private home is refused", () => fixture(f => { + fs.chmodSync(f.home, 0o770); + expect(installCodexShim().installed).toBe(false); + expect(fs.lstatSync(f.home).mode & 0o777).toBe(0o770); + expect(fs.existsSync(f.wrapper)).toBe(false); + })); + + test("shared operation lock defers repair and allows retry after release", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + fs.unlinkSync(f.wrapper); + const lock = tryAcquireShimRestoreLock(); + expect(lock).not.toBeNull(); + try { + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("deferred"); + expect(fs.existsSync(f.wrapper)).toBe(false); + } finally { lock?.release(); } + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("restored"); + })); +}); diff --git a/tests/codex-integration/codex-shim-overlay.test.ts b/tests/codex-integration/codex-shim-overlay.test.ts new file mode 100644 index 00000000000..fe99c2f85ac --- /dev/null +++ b/tests/codex-integration/codex-shim-overlay.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { autoRestoreCodexShim, inspectCodexShimBackingForCommand, installCodexShim, setCodexShimProbeObservationMsForTests, uninstallCodexShim } from "../../src/codex/shim"; +import { readState, stateFiles } from "../../src/codex/shim-state-file"; + +function fixture(run: (f: { root: string; home: string; native: string; wrapper: string; envFile: string; statePath: string; version: (name: string) => string }) => void): void { + const root = mkdtempSync(join(tmpdir(), "ocx-overlay-contract-")); + const home = join(root, "private space's home"); + const bin = join(root, "brew", "bin"); + mkdirSync(home, { mode: 0o700 }); + mkdirSync(bin, { recursive: true }); + const previous = { ...process.env }; + const native = join(bin, "codex"); + const wrapper = join(home, "bin", "codex"); + const version = (name: string) => { + const path = join(root, "Caskroom", name, "codex"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' '${name}' "$@"\n`, { mode: 0o755 }); + return path; + }; + try { + process.env.HOME = root; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(root, "codex-home"); + process.env.PATH = `${bin}:/usr/bin:/bin`; + for (const key of ["CODEX_CLI_PATH", "OCX_SHIM_ACTIVE_PID", "OCX_SHIM_ACTIVE_DEPTH", "OCX_SHIM_PROBE", "OCX_SHIM_PROBE_ACTIVE", "OPENCODEX_API_AUTH_TOKEN"]) delete process.env[key]; + setCodexShimProbeObservationMsForTests(20); + symlinkSync(version("v1"), native); + run({ root, home, native, wrapper, envFile: join(home, "codex-shell-env.sh"), statePath: join(home, "codex-shim.json"), version }); + } finally { + setCodexShimProbeObservationMsForTests(null); + process.env = previous; + rmSync(root, { recursive: true, force: true }); + } +} + +function invoke(wrapper: string) { + return spawnSync(wrapper, ["--version"], { encoding: "utf8", env: process.env, timeout: 5000 }); +} + +describe.skipIf(process.platform === "win32")("Unix private Codex overlay", () => { + test("fresh install preserves the native symlink inode and creates no manager backup", () => fixture(f => { + const native = lstatSync(f.native); + const target = readlinkSync(f.native); + const path = process.env.PATH; + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(lstatSync(f.native).ino).toBe(native.ino); + expect(readlinkSync(f.native)).toBe(target); + expect(existsSync(`${f.native}.opencodex-real`)).toBe(false); + expect(process.env.PATH).toBe(path); + expect(invoke(f.wrapper)).toMatchObject({ status: 0, stdout: "v1\n--version\n" }); + })); + + test("fresh install also leaves a regular native launcher inode and bytes unchanged", () => fixture(f => { + unlinkSync(f.native); + const bytes = "#!/bin/sh\nprintf 'regular\\n'\n"; + writeFileSync(f.native, bytes, { mode: 0o755 }); + const inode = lstatSync(f.native).ino; + expect(installCodexShim().installed).toBe(true); + expect(lstatSync(f.native).ino).toBe(inode); + expect(readFileSync(f.native, "utf8")).toBe(bytes); + expect(existsSync(`${f.native}.opencodex-real`)).toBe(false); + })); + + test("native symlink upgrades, old-version cleanup and rollback work without rewriting overlay", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const wrapper = readFileSync(f.wrapper, "utf8"); + const inode = lstatSync(f.wrapper).ino; + const upgraded = f.version("v2"); + unlinkSync(f.native); + symlinkSync(upgraded, f.native); + rmSync(join(f.root, "Caskroom", "v1"), { recursive: true }); + expect(invoke(f.wrapper).stdout).toBe("v2\n--version\n"); + expect(autoRestoreCodexShim({ enabled: () => { throw new Error("healthy overlay must not load config"); } }).status).toBe("healthy"); + unlinkSync(f.native); + symlinkSync(f.version("rollback"), f.native); + expect(invoke(f.wrapper).stdout).toBe("rollback\n--version\n"); + expect(readFileSync(f.wrapper, "utf8")).toBe(wrapper); + expect(lstatSync(f.wrapper).ino).toBe(inode); + })); + + test("fnm multishell removal leaves the recorded durable launcher and wrapper usable", () => fixture(f => { + const installation = join(f.root, "node-versions", "v24", "installation"); + const stableBin = join(installation, "bin"); + const temporaryRoot = join(f.root, "fnm_multishells"); + const temporaryInstallation = join(temporaryRoot, "session"); + mkdirSync(stableBin, { recursive: true }); + mkdirSync(temporaryRoot); + const launcher = join(stableBin, "codex"); + symlinkSync(f.version("fnm"), launcher); + symlinkSync(installation, temporaryInstallation); + process.env.PATH = `${join(temporaryInstallation, "bin")}:/usr/bin:/bin`; + const inode = lstatSync(launcher).ino; + const result = installCodexShim(); + expect(result.installed, result.message).toBe(true); + expect(JSON.parse(readFileSync(f.statePath, "utf8"))).toMatchObject({ launcherPath: join(realpathSync(stableBin), "codex") }); + expect(readFileSync(f.statePath, "utf8")).not.toContain("fnm_multishells"); + rmSync(temporaryRoot, { recursive: true }); + expect(lstatSync(launcher).ino).toBe(inode); + expect(invoke(f.wrapper)).toMatchObject({ status: 0, stdout: "fnm\n--version\n" }); + expect(autoRestoreCodexShim({ enabled: () => { throw new Error("durable launcher needs no repair"); } }).status).toBe("healthy"); + })); + + test("schema 2 exposes the durable launcher as read-only backing", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const raw = readFileSync(f.statePath, "utf8"); + expect(JSON.parse(raw)).toMatchObject({ schema: 2, mode: "path-overlay", platform: process.platform, wrapperPath: f.wrapper, launcherPath: f.native }); + const state = readState()!; + expect(stateFiles(state)).toEqual([{ wrapperPath: f.wrapper, originalPath: f.native, backupPath: f.native, realPath: f.native }]); + expect(inspectCodexShimBackingForCommand(f.wrapper)).toMatchObject({ status: "matched", selectedRole: "wrapper", backingPath: f.native, backingKind: "real" }); + expect(inspectCodexShimBackingForCommand(f.native)).toMatchObject({ status: "matched", selectedRole: "backing", backingPath: f.native }); + expect(readFileSync(f.statePath, "utf8")).toBe(raw); + expect(lstatSync(f.statePath).mode & 0o777).toBe(0o600); + expect(lstatSync(f.envFile).mode & 0o777).toBe(0o600); + })); + + test("management bypass preserves token, arguments, stdin, both output streams and exit code", () => fixture(f => { + unlinkSync(f.native); + writeFileSync(f.native, '#!/bin/sh\n[ "$1" = --version ] && exit 0\nprintf "token=%s\\n" "$OPENCODEX_API_AUTH_TOKEN"\nprintf "%s\\n" "$@"\ncat\nprintf "native stderr\\n" >&2\nexit 23\n', { mode: 0o755 }); + const token = "fixture-overlay-token"; + writeFileSync(join(f.home, "service-api-token"), `${token}\n`, { mode: 0o600 }); + expect(installCodexShim().installed).toBe(true); + // A management command skips ensure without OCX_SHIM_BYPASS, so no live proxy starts. + const result = spawnSync(f.wrapper, ["login", "a b", "'quoted'"], { env: { ...process.env, OCX_SHIM_BYPASS: "" }, encoding: "utf8", input: "input\n", timeout: 5000 }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(23); + expect(result.stdout).toBe(`token=${token}\nlogin\na b\n'quoted'\ninput\n`); + expect(result.stderr).toBe("native stderr\n"); + const inherited = spawnSync(f.wrapper, ["login"], { env: { ...process.env, OPENCODEX_API_AUTH_TOKEN: "inherited-token" }, encoding: "utf8", timeout: 5000 }); + expect(inherited.stdout).toBe("token=inherited-token\nlogin\n"); + expect(readFileSync(f.wrapper, "utf8")).not.toContain(token); + expect(readFileSync(f.statePath, "utf8")).not.toContain(token); + expect(readFileSync(f.envFile, "utf8")).not.toContain(token); + })); + + test("a missing recorded native launcher is never rediscovered from a different PATH installation", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const state = readFileSync(f.statePath, "utf8"); + unlinkSync(f.native); + const other = join(f.root, "other"); + mkdirSync(other); + symlinkSync(f.version("decoy"), join(other, "codex")); + process.env.PATH = `${other}:/usr/bin:/bin`; + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("ineligible"); + expect(installCodexShim().installed).toBe(false); + expect(existsSync(f.native)).toBe(false); + expect(readFileSync(f.statePath, "utf8")).toBe(state); + expect(readlinkSync(join(other, "codex"))).toContain("decoy"); + })); + + test("missing private wrapper respects opt-out and repairs only after opt-in", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const native = lstatSync(f.native).ino; + unlinkSync(f.wrapper); + expect(autoRestoreCodexShim({ enabled: () => false }).status).toBe("disabled"); + expect(existsSync(f.wrapper)).toBe(false); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("restored"); + expect(lstatSync(f.native).ino).toBe(native); + expect(invoke(f.wrapper).status).toBe(0); + })); + + test("uninstall removes only private artifacts and preserves native and unrelated files", () => fixture(f => { + expect(installCodexShim().installed).toBe(true); + const native = lstatSync(f.native).ino; + const unrelated = join(dirname(f.wrapper), "my-tool"); + writeFileSync(unrelated, "user-owned tool"); + const removed = uninstallCodexShim(); + expect(removed.removed, removed.message).toBe(true); + expect(existsSync(f.wrapper)).toBe(false); + expect(existsSync(f.envFile)).toBe(false); + expect(existsSync(f.statePath)).toBe(false); + expect(lstatSync(f.native).ino).toBe(native); + expect(readFileSync(unrelated, "utf8")).toBe("user-owned tool"); + expect(removed.message).toContain("source line"); + })); +}); diff --git a/tests/codex-integration/codex-shim-path-readiness.test.ts b/tests/codex-integration/codex-shim-path-readiness.test.ts index 4e9a155c2a3..4f1b87b198e 100644 --- a/tests/codex-integration/codex-shim-path-readiness.test.ts +++ b/tests/codex-integration/codex-shim-path-readiness.test.ts @@ -29,6 +29,77 @@ function fixture() { } describe("connect readiness PATH inspection", () => { + test.skipIf(process.platform === "win32").each([ + { kind: "ordinary file", shim: false, symlink: false, executable: false }, + { kind: "shim file", shim: true, symlink: false, executable: false }, + { kind: "ordinary symlink", shim: false, symlink: true, executable: false }, + { kind: "shim symlink", shim: true, symlink: true, executable: false }, + { kind: "ordinary executable", shim: false, symlink: false, executable: true }, + { kind: "shim executable", shim: true, symlink: false, executable: true }, + ])("$kind agrees with shell command lookup", ({ shim, symlink, executable }) => { + const f = fixture(); + const content = (isShim: boolean) => `#!/bin/sh\n${isShim ? `# ${marker}\n` : ""}exit 0\n`; + const target = symlink ? join(f.root, "package-codex") : f.command; + fs.writeFileSync(target, content(shim), { mode: executable ? 0o755 : 0o644 }); + if (symlink) fs.symlinkSync(target, f.command); + fs.writeFileSync(f.fallback, content(!shim)); + const env = { ...process.env, PATH: f.pathValue, OPENCODEX_HOME: join(f.root, "home") }; + const shell = spawnSync("/bin/sh", ["-c", "command -v codex"], { env, encoding: "utf8", timeout: 3000 }); + expect(shell.error).toBeUndefined(); + expect(shell.status).toBe(0); + const expectedPath = executable ? f.command : f.fallback; + const expectedShim = executable ? shim : !shim; + expect(shell.stdout.trim()).toBe(expectedPath); + const scanner = new URL("../../src/codex/shim-path-resolution.ts", import.meta.url).href; + const readiness = new URL("../../src/cli/codex-shim-readiness.ts", import.meta.url).href; + const script = ` + const { findFirstCodexOnPath } = await import(${JSON.stringify(scanner)}); + const { inspectCodexShimForConnect } = await import(${JSON.stringify(readiness)}); + console.log(JSON.stringify({ + candidate: findFirstCodexOnPath({ wsl: false }), + result: inspectCodexShimForConnect({ + diagnose: () => ({ installed: true, healthy: true, summary: "fixture" }), + }), + })); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + env, encoding: "utf8", timeout: 3000, killSignal: "SIGKILL", + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + const output = JSON.parse(child.stdout); + expect(output.candidate).toEqual({ path: shell.stdout.trim(), isShim: expectedShim }); + expect(output.result.status).toBe(expectedShim ? "ready" : "missing"); + }); + + test.skipIf(process.platform === "win32")("a non-executable shim alone is not a PATH command", () => { + const f = fixture(); + fs.writeFileSync(f.command, `#!/bin/sh\n# ${marker}\n`, { mode: 0o644 }); + const shell = spawnSync("/bin/sh", ["-c", "command -v codex"], { + env: { ...process.env, PATH: f.first }, timeout: 3000, + }); + expect(shell.error).toBeUndefined(); + expect(shell.status).not.toBe(0); + expect(findFirstCodexOnPath({ pathValue: f.first, wsl: false })).toBeNull(); + }); + + test.skipIf(process.platform !== "win32")("Windows PATHEXT lookup does not require POSIX execute access", () => { + const f = fixture(); + const cmd = `${f.command}.cmd`; + fs.writeFileSync(cmd, `@echo off\r\nREM ${marker}\r\n`, { mode: 0o644 }); + fs.writeFileSync(`${f.command}.exe`, "ordinary launcher", { mode: 0o644 }); + const previous = process.env.PATHEXT; + const access = spyOn(fs, "accessSync").mockImplementation(() => { throw new Error("POSIX access check"); }); + try { + process.env.PATHEXT = ".CMD;.EXE"; + expect(findFirstCodexOnPath({ pathValue: f.pathValue, wsl: false })).toEqual({ path: cmd, isShim: true }); + expect(access).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.PATHEXT; + else process.env.PATHEXT = previous; + } + }); + test("a real healthy tracked shim outside PATH does not imply connect readiness", () => { const f = fixture(); const home = join(f.root, "home"); @@ -71,7 +142,7 @@ describe("connect readiness PATH inspection", () => { expect(spawnSync("mkfifo", [fifo], { timeout: 3000 }).status).toBe(0); if (mode === "fifo") fs.renameSync(fifo, f.command); else if (mode === "symlink") fs.symlinkSync(fifo, f.command); - else fs.writeFileSync(f.command, "ordinary launcher"); + else fs.writeFileSync(f.command, "ordinary launcher", { mode: 0o755 }); const scanner = new URL("../../src/codex/shim-path-resolution.ts", import.meta.url).href; const readiness = new URL("../../src/cli/codex-shim-readiness.ts", import.meta.url).href; const script = ` @@ -154,14 +225,14 @@ describe("connect readiness PATH inspection", () => { test("does not search beyond the 16 KiB header or skip that first command", () => { const f = fixture(); - fs.writeFileSync(f.command, `${" ".repeat(16 * 1024)}${marker}`); + fs.writeFileSync(f.command, `${" ".repeat(16 * 1024)}${marker}`, { mode: 0o755 }); expect(findFirstCodexOnPath({ pathValue: f.pathValue, wsl: false })) .toEqual({ path: f.command, isShim: false }); }); test("handles short reads and closes the descriptor", () => { const f = fixture(); - fs.writeFileSync(f.command, `#!/bin/sh\n# ${marker}\n`); + fs.writeFileSync(f.command, `#!/bin/sh\n# ${marker}\n`, { mode: 0o755 }); const realRead = fs.readSync; const read = spyOn(fs, "readSync").mockImplementation(((fd: number, buffer: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null) => realRead(fd, buffer, offset, Math.min(length, 3), position)) as typeof fs.readSync); @@ -174,7 +245,7 @@ describe("connect readiness PATH inspection", () => { test("a failed regular-file read preserves shadowing and closes its descriptor", () => { const f = fixture(); - fs.writeFileSync(f.command, "ordinary launcher"); + fs.writeFileSync(f.command, "ordinary launcher", { mode: 0o755 }); spyOn(fs, "readSync").mockImplementation(() => { throw new Error("synthetic read failure"); }); const close = spyOn(fs, "closeSync"); expect(findFirstCodexOnPath({ pathValue: f.pathValue, wsl: false })) diff --git a/tests/codex-integration/codex-shim-readiness.test.ts b/tests/codex-integration/codex-shim-readiness.test.ts index 8926038fdee..46c55cfea52 100644 --- a/tests/codex-integration/codex-shim-readiness.test.ts +++ b/tests/codex-integration/codex-shim-readiness.test.ts @@ -1,3 +1,4 @@ +import { isolateCodexShimEnvironment, withInstalledShim } from "../helpers/codex-shim-install-fixture"; import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { @@ -14,6 +15,8 @@ import { codexShimReadinessWarnings } from "../../src/cli/codex-shim-readiness"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +isolateCodexShimEnvironment(); + const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); // This case proves a real install followed by advisory collection, not startup latency. @@ -50,10 +53,48 @@ describe("Codex shim install readiness", () => { if (!result.stdout) throw new Error(result.stderr); expect(result.status).toBe(1); expect(result.stdout).toContain(process.platform === "win32" - ? "Refusing to rename a real .exe" : "Could not find a codex executable"); + ? "Refusing to rename a real .exe" : "Native Codex launcher not found"); } finally { removeTreeWithRetry(root); } }, SHIM_INSTALL_CASE_MS); + test("a runnable inactive install succeeds with activation guidance and becomes green on PATH", () => { + if (process.platform === "win32") return; + withInstalledShim(({ home, binDir, launchers }) => { + const nativeBefore = readFileSync(launchers[0]); + const env = { ...process.env, PATH: binDir, OPENCODEX_HOME: home }; + const inactive = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { env, encoding: "utf8" }); + expect(inactive.status).toBe(0); + expect(inactive.stdout).toStartWith("⚠️"); + expect(inactive.stdout).not.toContain("✅"); + expect(inactive.stderr).toContain("codex-shell-env.sh"); + const active = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { + env: { ...env, PATH: `${join(home, "bin")}:${binDir}` }, encoding: "utf8", + }); + expect(active.status).toBe(0); + expect(active.stdout).toStartWith("✅"); + expect(readFileSync(launchers[0])).toEqual(nativeBefore); + }); + }, 20_000); + + test("refuses a foreign private launcher even when a previous overlay was runnable", () => { + if (process.platform === "win32") return; + withInstalledShim(({ home, wrappers, launchers, statePath }) => { + const stateBefore = readFileSync(statePath); + const nativeBefore = readFileSync(launchers[0]); + const foreign = "#!/bin/sh\n# foreign launcher\nexit 0\n"; + writeFileSync(wrappers[0], foreign); + const result = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { + env: { ...process.env, OPENCODEX_HOME: home }, encoding: "utf8", + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("installation was refused"); + expect(result.stdout).not.toContain("✅"); + expect(readFileSync(wrappers[0], "utf8")).toBe(foreign); + expect(readFileSync(launchers[0])).toEqual(nativeBefore); + expect(readFileSync(statePath)).toEqual(stateBefore); + }); + }, 20_000); + test("keeps a clean install green for native and managed routing", () => { expect(codexShimReadinessWarnings(ready)).toEqual([]); expect(codexShimReadinessWarnings({ @@ -167,7 +208,7 @@ describe("Codex shim install readiness", () => { }); expect(result.status).toBe(0); - expect(result.stdout).toStartWith("⚠️ Codex autostart shim installed"); + expect(result.stdout).toStartWith(process.platform === "win32" ? "⚠️ Codex autostart shim installed" : "⚠️ Codex PATH shim installed"); expect(result.stderr).toContain("Codex routing could not be verified"); expect(result.stderr).toContain("config.proxy"); expect(`${result.stdout}\n${result.stderr}`).not.toContain(proxyUrl); @@ -212,7 +253,7 @@ describe("Codex shim install readiness", () => { throw new Error(`Shim install fixture did not complete: error=${result.error?.name ?? "none"} signal=${result.signal ?? "none"}`); } expect(result.status).toBe(0); - expect(result.stdout).toStartWith("⚠️ Codex autostart shim installed"); + expect(result.stdout).toStartWith(process.platform === "win32" ? "⚠️ Codex autostart shim installed" : "⚠️ Codex PATH shim installed"); expect(result.stderr).toContain("Codex routing could not be verified"); // A healthy no-op reports installed:false internally but must still exit successfully. const repeat = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { @@ -226,7 +267,13 @@ describe("Codex shim install readiness", () => { expect(repeat.stdout).toContain("already installed"); expect(repeat.stderr).toContain("Codex routing could not be verified"); // Keep the marker and backing file, but break the launch-time ensure contract. - writeFileSync(codex, readFileSync(codex, "utf8").replaceAll("ensure", "broken")); + const wrapper = process.platform === "win32" ? codex : join(opencodexHome, "bin", "codex"); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replaceAll("ensure", "broken")); + const diagnosis = spawnSync(process.execPath, [cliPath, "codex-shim", "status"], { + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, PATH: binDir }, + encoding: "utf8", + }); + expect(diagnosis.stdout).toContain("unhealthy"); const damaged = spawnSync(process.execPath, [cliPath, "codex-shim", "install"], { cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, @@ -234,8 +281,9 @@ describe("Codex shim install readiness", () => { encoding: "utf8", timeout: SHIM_INSTALL_CHILD_MS, killSignal: "SIGKILL", }); expect(damaged.error).toBeUndefined(); - expect(damaged.status).toBe(1); - expect(damaged.stderr).toContain("unhealthy"); + expect(damaged.status).toBe(process.platform === "win32" ? 1 : 0); + if (process.platform === "win32") expect(damaged.stderr).toContain("unhealthy"); + else expect(readFileSync(wrapper, "utf8")).toContain("ensure"); } finally { removeTreeWithRetry(root); } diff --git a/tests/codex-integration/codex-shim-standalone.test.ts b/tests/codex-integration/codex-shim-standalone.test.ts index e38e86d88eb..45958c0ce2e 100644 --- a/tests/codex-integration/codex-shim-standalone.test.ts +++ b/tests/codex-integration/codex-shim-standalone.test.ts @@ -4,11 +4,13 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } fro import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "../../src/codex/shim-templates"; -import { prependPath } from "../helpers/codex-shim-install-fixture"; +import { isolateCodexShimEnvironment, prependPath } from "../helpers/codex-shim-install-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +isolateCodexShimEnvironment(); + describe("standalone shim command generation (#6276)", () => { test("Unix invokes the executable directly without a virtual CLI entrypoint", () => { const script = buildUnixCodexShim("/bin/codex-real", "/opt/ocx app/ocx", "/$bunfs/root/cli/index.ts", "standalone"); @@ -89,7 +91,7 @@ exit 7 expect(result.status, `${result.signal ?? ""}: ${result.stderr}`).toBe(0); const installed = JSON.parse(result.stdout); expect(installed.installed, installed.message).toBe(true); - return { wrapper, env, ensureFile, probeEnvFile }; + return { wrapper: join(home, "bin", "codex"), env, ensureFile, probeEnvFile }; } test("installs through the compiled probe and confines BUN_BE_BUN to its supervisor", () => { diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 4c4c8fc728c..29dba7381a2 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -3,8 +3,8 @@ import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, inspectCodexShimBackingForCommand, installCodexShim, isLocalAbsoluteInspectionPath, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, setCodexShimRollbackRestoreHookForTests, uninstallCodexShim } from "../../src/codex/shim"; -import { prependPath, withInstalledShim } from "../helpers/codex-shim-install-fixture"; +import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, inspectCodexShimBackingForCommand, installCodexShim, isLocalAbsoluteInspectionPath, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, uninstallCodexShim } from "../../src/codex/shim"; +import { isolateCodexShimEnvironment, prependPath, withInstalledShim } from "../helpers/codex-shim-install-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -33,6 +33,8 @@ function shimChildEnv(overrides: Record = {}): NodeJS.ProcessEnv delete env.OCX_SHIM_PROBE_ACTIVE; return env; } +isolateCodexShimEnvironment(); + const skipStabilityWait = () => {}; const python3Path = process.platform === "win32" ? "" @@ -314,7 +316,7 @@ exit 64 expect(installed.installed).toBe(false); expect(installed.message).toContain("saved launcher resolved back to the generated shim"); - expect(installed.message).toContain("original launcher was restored"); + expect(installed.message).toContain("Native launcher was preserved"); expect(readFileSync(codexPath, "utf8")).toBe(original); expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); expect(existsSync(join(home, "codex-shim.json"))).toBe(false); @@ -350,7 +352,8 @@ codex "$@" expect(installCodexShim().installed).toBe(true); - const result = spawnSync(codexPath, ["--help"], { + process.env.PATH = prependPath(join(home, "bin"), process.env.PATH); + const result = spawnSync(join(home, "bin", "codex"), ["--help"], { encoding: "utf8", env: { ...process.env, OCX_SHIM_BYPASS: "1" }, timeout: 3_000, @@ -419,8 +422,8 @@ exit 126 const installed = installCodexShim(); expect(installed.installed).toBe(true); - expect(readFileSync(codexPath, "utf8")).toContain(SHIM_MARKER); - expect(readFileSync(`${codexPath}.opencodex-real`, "utf8")).toBe(successfulLauncher("dash-valid-launcher")); + expect(readFileSync(join(home, "bin", "codex"), "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(codexPath, "utf8")).toBe(successfulLauncher("dash-valid-launcher")); expect(existsSync(join(home, "codex-shim.json"))).toBe(true); } finally { setCodexShimProbeShellForTests(null); @@ -526,8 +529,8 @@ exit 126 const installed = installCodexShim(); expect(installed.installed).toBe(true); - expect(readFileSync(codexPath, "utf8")).toContain(SHIM_MARKER); - expect(lstatSync(`${codexPath}.opencodex-real`).isFile()).toBe(true); + expect(readFileSync(join(home, "bin", "codex"), "utf8")).toContain(SHIM_MARKER); + expect(lstatSync(codexPath).isFile()).toBe(true); } finally { if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; @@ -555,8 +558,8 @@ exit 126 const installed = installCodexShim(); expect(installed.installed).toBe(true); - expect(readFileSync(codexPath, "utf8")).toContain(SHIM_MARKER); - expect(lstatSync(`${codexPath}.opencodex-real`).isSymbolicLink()).toBe(true); + expect(readFileSync(join(home, "bin", "codex"), "utf8")).toContain(SHIM_MARKER); + expect(lstatSync(codexPath).isSymbolicLink()).toBe(true); } finally { if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; @@ -770,7 +773,7 @@ wait "$child" expect(killCalls).toBe(1); expect(passiveProbes).toBeGreaterThanOrEqual(2); } - expect(installed.message).toContain("original launcher was restored"); + expect(installed.message).toContain("Native launcher was preserved"); expect(readFileSync(codexPath, "utf8")).toBe(original); expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); expect(existsSync(join(home, "codex-shim.json"))).toBe(false); @@ -811,7 +814,7 @@ wait "$child" }, 10_000); } - test("Unix install preserves an existing backup without probing or mutation", () => { + test("Unix overlay preserves an unrelated legacy backup and the native launcher", () => { if (process.platform === "win32") return; const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-backup-bin-")); @@ -832,10 +835,10 @@ wait "$child" const installed = installCodexShim(); - expect(installed).toEqual({ installed: false, message: `Refusing to overwrite existing backup: ${backupPath}` }); + expect(installed).toMatchObject({ installed: true, runnable: true }); expect(readFileSync(codexPath, "utf8")).toBe(original); expect(readFileSync(backupPath, "utf8")).toBe(backup); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(true); } finally { if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; @@ -894,7 +897,7 @@ wait "$child" chmodSync(codexPath, 0o755); setCodexShimProbeHookForTests(() => { throw new Error("synthetic probe infrastructure failure"); }); - expect(() => installCodexShim()).toThrow("synthetic probe infrastructure failure"); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true, message: "synthetic probe infrastructure failure" }); expect(readFileSync(codexPath, "utf8")).toBe(original); expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); @@ -910,134 +913,56 @@ wait "$child" } }); - test("Unix fresh install removes its marker-bearing partial wrapper before rollback", () => { + test("Unix fresh install refuses a tampered staged wrapper and preserves the native launcher", () => { if (process.platform === "win32") return; - - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-partial-write-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-partial-write-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const original = successfulLauncher("partial-write-original"); - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, original, "utf8"); - chmodSync(codexPath, 0o755); - setCodexShimFreshWriteHookForTests(() => { - writeFileSync(codexPath, `#!/bin/sh\n# ${SHIM_MARKER}\n`, "utf8"); - throw new Error("synthetic fresh partial write failure"); + withInstalledShim(({ home, wrappers, launchers, statePath }) => { + expect(uninstallCodexShim().removed).toBe(true); + const native = readFileSync(launchers[0]); + let staged = ""; + setCodexShimProbeHookForTests(() => { + staged = join(home, "bin", readdirSync(join(home, "bin")).find(name => name.endsWith(".stage"))!); + writeFileSync(staged, `#!/bin/sh\n# ${SHIM_MARKER}\n`, "utf8"); }); - - expect(() => installCodexShim()).toThrow("synthetic fresh partial write failure"); - - expect(readFileSync(codexPath, "utf8")).toBe(original); - expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - } finally { - setCodexShimFreshWriteHookForTests(null); - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } + try { + expect(installCodexShim()).toMatchObject({ installed: false, refused: true }); + expect(readFileSync(launchers[0])).toEqual(native); + expect(existsSync(wrappers[0])).toBe(false); + expect(existsSync(statePath)).toBe(false); + // Changed staging bytes are uncertain artifacts, so preserve them for inspection. + expect(readFileSync(staged, "utf8")).toContain(SHIM_MARKER); + } finally { setCodexShimProbeHookForTests(null); } + }); }); - test("Unix fresh install rolls an unprobeable original back into place", () => { + test("Unix fresh overlay refuses an empty native launcher without moving it", () => { if (process.platform === "win32") return; - - // #1625. An empty launcher is a legitimate thing for a user to own, and - // stableShimPathProbe deliberately returns null at zero bytes because it - // answers "does this look like a healthy shim". Recording the backup with - // that probe therefore left movedOriginalFingerprint unset, and rollback — - // which requires a matching fingerprint before it will restore — refused, - // stranding the launcher at codex.opencodex-real. Identity is metadata, not - // content, so the fingerprint must not depend on the file having bytes. - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-unprobeable-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-unprobeable-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const backupPath = `${codexPath}.opencodex-real`; - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, "", "utf8"); - chmodSync(codexPath, 0o755); - setCodexShimFreshWriteHookForTests(() => { - throw new Error("synthetic unprobeable-original rollback"); - }); - - expect(() => installCodexShim()).toThrow("synthetic unprobeable-original rollback"); - - // The launcher is back where the user had it, still empty and executable, - // and no backup residue is left behind. - expect(existsSync(codexPath)).toBe(true); - expect(readFileSync(codexPath, "utf8")).toBe(""); - expect(lstatSync(codexPath).mode & 0o777).toBe(0o755); - expect(existsSync(backupPath)).toBe(false); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - } finally { - setCodexShimFreshWriteHookForTests(null); - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } + withInstalledShim(({ wrappers, launchers, statePath }) => { + expect(uninstallCodexShim().removed).toBe(true); + writeFileSync(launchers[0], ""); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true }); + expect(readFileSync(launchers[0], "utf8")).toBe(""); + expect(lstatSync(launchers[0]).mode & 0o777).toBe(0o755); + expect(existsSync(`${launchers[0]}.opencodex-real`)).toBe(false); + expect(existsSync(wrappers[0])).toBe(false); + expect(existsSync(statePath)).toBe(false); + }); }); - test("Unix rollback restore refuses to replace a launcher published in the restore window", () => { + test("Unix overlay publication refuses a competing private launcher and preserves native", () => { if (process.platform === "win32") return; - - // #1625 follow-up. sourceOccupied is sampled before the backup fingerprint - // check, so a concurrent installer can still publish a launcher at the - // original path afterwards. renameSync would silently delete it; the restore - // uses link()+unlink(), which fails EEXIST instead. Only a hook inside that - // window can reach this: publishing any earlier makes sourceOccupied true and - // skips the restore branch entirely. - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-restore-window-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-restore-window-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const backupPath = `${codexPath}.opencodex-real`; - const original = successfulLauncher("restore-window-original"); - const intruder = successfulLauncher("restore-window-concurrent-installer"); - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, original, "utf8"); - chmodSync(codexPath, 0o755); - setCodexShimFreshWriteHookForTests(() => { - throw new Error("synthetic restore-window failure"); - }); - setCodexShimRollbackRestoreHookForTests(() => { - writeFileSync(codexPath, intruder, "utf8"); - chmodSync(codexPath, 0o755); - }); - - expect(() => installCodexShim()).toThrow(); - - // The competing launcher is untouched and the user's original is still - // recoverable from the backup instead of having been overwritten. - expect(readFileSync(codexPath, "utf8")).toBe(intruder); - expect(existsSync(backupPath)).toBe(true); - expect(readFileSync(backupPath, "utf8")).toBe(original); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - } finally { - setCodexShimFreshWriteHookForTests(null); - setCodexShimRollbackRestoreHookForTests(null); - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } + withInstalledShim(({ wrappers, launchers, statePath }) => { + expect(uninstallCodexShim().removed).toBe(true); + const original = readFileSync(launchers[0]); + const intruder = successfulLauncher("concurrent-installer"); + setCodexShimProbeHookForTests(() => writeFileSync(wrappers[0], intruder, { mode: 0o755 })); + try { + expect(installCodexShim()).toMatchObject({ installed: false, refused: true }); + expect(readFileSync(wrappers[0], "utf8")).toBe(intruder); + expect(readFileSync(launchers[0])).toEqual(original); + expect(existsSync(`${launchers[0]}.opencodex-real`)).toBe(false); + expect(existsSync(statePath)).toBe(false); + } finally { setCodexShimProbeHookForTests(null); } + }); }); test("Unix fresh install preserves a concurrent wrapper replacement after a successful probe", () => { @@ -1065,12 +990,10 @@ exit 0 const installed = installCodexShim(); expect(installed.installed).toBe(false); - expect(installed.message).toContain("generated wrapper changed during its validation probe"); + expect(installed.message).toContain("Native launcher changed during validation"); expect(readFileSync(codexPath, "utf8")).toBe(concurrent); - // The backup is kept, not deleted: the source path is occupied by a file we - // do not own, so this backup is the only copy of the user's real launcher. - // A stray `codex.opencodex-real` is recoverable; a deleted launcher is not. - expect(existsSync(`${codexPath}.opencodex-real`)).toBe(true); + // Package-manager publication wins; the overlay never backs up or restores native. + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); expect(existsSync(join(home, "codex-shim.json"))).toBe(false); } finally { if (oldPath === undefined) delete process.env.PATH; @@ -1082,61 +1005,27 @@ exit 0 } }); - test("Unix fresh install refuses to adopt a wrapper replaced between the write and the fingerprint", () => { + test("Unix fresh install refuses to adopt a marker-bearing staged inode replacement", () => { if (process.platform === "win32") return; - - // Ownership must come from the inode we staged, not from stat-ing the path - // afterwards. A replacement that lands in that window carries the OpenCodex - // markers by coincidence or by design; adopting it means our rollback later - // unlinks an executable we never wrote. - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-adopt-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-adopt-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const original = "#!/bin/sh\nexit 0\n"; - // Marker-bearing, so a marker/prefix check alone would happily adopt it. - const intruder = `#!/bin/sh\n# ${SHIM_MARKER}\n# concurrent updater, not ours\nexit 0\n`; - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, original, "utf8"); - chmodSync(codexPath, 0o755); - - setCodexShimFreshWriteHookForTests(() => { - // Replace the destination the way a real updater does: write a new file - // and rename it over ours, so the path now points at a different inode. - // (A plain writeFileSync would truncate our inode in place, which is a - // different situation — content tampering, not replacement.) - const replacement = `${codexPath}.updater-tmp`; - writeFileSync(replacement, intruder, "utf8"); - chmodSync(replacement, 0o755); - renameSync(replacement, codexPath); + withInstalledShim(({ home, wrappers, launchers, statePath }) => { + expect(uninstallCodexShim().removed).toBe(true); + const native = readFileSync(launchers[0]); + const intruder = `#!/bin/sh\n# ${SHIM_MARKER}\n# concurrent updater\nexit 0\n`; + let staged = ""; + setCodexShimProbeHookForTests(() => { + staged = join(home, "bin", readdirSync(join(home, "bin")).find(name => name.endsWith(".stage"))!); + const replacement = `${staged}.updater-tmp`; + writeFileSync(replacement, intruder, { mode: 0o755 }); + renameSync(replacement, staged); }); - const installed = installCodexShim(); - - expect(installed.installed).toBe(false); - expect(installed.message).toContain("changed during its validation probe"); - // The replacement survives: it is not ours, so rollback must not unlink it. - expect(readFileSync(codexPath, "utf8")).toBe(intruder); - // The user's real launcher is preserved rather than deleted, because the - // path is occupied by a file we do not own. A stray backup is recoverable; - // a deleted launcher is not. - expect(existsSync(`${codexPath}.opencodex-real`)).toBe(true); - expect(readFileSync(`${codexPath}.opencodex-real`, "utf8")).toBe(original); - // No install state is published for a refused install. - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - // No staging artifact leaked into the user's PATH directory. - expect(readdirSync(binDir).filter(name => name.includes("opencodex-staging"))).toEqual([]); - } finally { - setCodexShimFreshWriteHookForTests(null); - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } + try { + expect(installCodexShim()).toMatchObject({ installed: false, refused: true }); + expect(readFileSync(staged, "utf8")).toBe(intruder); + expect(readFileSync(launchers[0])).toEqual(native); + expect(existsSync(wrappers[0])).toBe(false); + expect(existsSync(statePath)).toBe(false); + } finally { setCodexShimProbeHookForTests(null); } + }); }); test("Unix shim permits a real Codex process to start a new child invocation", () => { @@ -1511,122 +1400,95 @@ printf '%s\\n' child-codex }); }); - test("auto-restore upgrades an obsolete Unix shim and validates its saved launcher", () => { + test("auto-restore upgrades an obsolete private Unix shim and validates its native launcher", () => { if (process.platform === "win32") return; - withInstalledShim(({ wrappers, backups, statePath }) => { + withInstalledShim(({ wrappers, launchers, statePath }) => { const current = readFileSync(wrappers[0], "utf8"); - const obsolete = obsoleteUnixShim(current); - const oldBackup = readFileSync(backups[0]); - const oldState = readFileSync(statePath); - expect(obsolete).not.toBe(current); - writeFileSync(wrappers[0], obsolete, "utf8"); - - const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - - expect(result.status).toBe("restored"); - expect(result.message).toContain("Upgraded Codex autostart shim"); - expect(readFileSync(wrappers[0], "utf8")).toContain(UNIX_SHIM_REVISION_MARKER); - expect(readFileSync(backups[0])).toEqual(oldBackup); - expect(readFileSync(statePath)).toEqual(oldState); - expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: true }); + const native = readFileSync(launchers[0]); + const oldState = JSON.parse(readFileSync(statePath, "utf8")); + writeFileSync(wrappers[0], obsoleteUnixShim(current)); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("restored"); + expect(readFileSync(wrappers[0], "utf8")).toBe(current); + expect(readFileSync(launchers[0])).toEqual(native); + const state = JSON.parse(readFileSync(statePath, "utf8")); + expect(state.launcherPath).toBe(oldState.launcherPath); + expect(state.transactionId).not.toBe(oldState.transactionId); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, runnable: true, active: false, healthy: false }); }); }); - test("manual install removes an obsolete Unix shim when its saved launcher recurses", () => { + test("manual overlay refresh rejects an obsolete wrapper's recursive native launcher", () => { if (process.platform === "win32") return; - withInstalledShim(({ binDir, wrappers, backups, statePath }) => { - const current = readFileSync(wrappers[0], "utf8"); - const obsolete = obsoleteUnixShim(current); - const dynamicLauncher = join(binDir, "obsolete-dynamic-launcher"); - const recursiveLauncher = `#!/bin/sh\nexec "${dynamicLauncher}" "$@"\n`; - writeFileSync(dynamicLauncher, "#!/bin/sh\nexec codex \"$@\"\n", "utf8"); - writeFileSync(backups[0], recursiveLauncher, "utf8"); - writeFileSync(wrappers[0], obsolete, "utf8"); - chmodSync(dynamicLauncher, 0o755); - chmodSync(backups[0], 0o755); - - const result = installCodexShim(); - - expect(result.installed).toBe(false); - expect(result.message).toContain("Removed an obsolete Codex autostart shim"); - expect(result.message).toContain("original launcher was restored"); - expect(readFileSync(wrappers[0], "utf8")).toBe(recursiveLauncher); - expect(existsSync(backups[0])).toBe(false); - expect(existsSync(statePath)).toBe(false); - expect(diagnoseCodexShim()).toMatchObject({ installed: false, healthy: false }); + withInstalledShim(({ binDir, wrappers, launchers, statePath }) => { + const obsolete = obsoleteUnixShim(readFileSync(wrappers[0], "utf8")); + const dynamic = join(binDir, "obsolete-dynamic-launcher"); + const recursive = `#!/bin/sh\nexec "${dynamic}" "$@"\n`; + writeFileSync(dynamic, "#!/bin/sh\nexec codex \"$@\"\n", { mode: 0o755 }); + writeFileSync(launchers[0], recursive); + writeFileSync(wrappers[0], obsolete); + const state = readFileSync(statePath); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true, message: expect.stringContaining("resolved back") }); + expect(readFileSync(wrappers[0], "utf8")).toBe(obsolete); + expect(readFileSync(launchers[0], "utf8")).toBe(recursive); + expect(readFileSync(statePath)).toEqual(state); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: false }); }); }); - test("obsolete Unix shim upgrade rolls back when probe infrastructure throws", () => { + test("obsolete private Unix shim upgrade rolls back when probe infrastructure throws", () => { if (process.platform === "win32") return; - withInstalledShim(({ wrappers, backups, statePath }) => { - const current = readFileSync(wrappers[0], "utf8"); - const obsolete = obsoleteUnixShim(current); - const oldBackup = readFileSync(backups[0]); - const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], obsolete, "utf8"); + withInstalledShim(({ wrappers, launchers, statePath }) => { + const obsolete = obsoleteUnixShim(readFileSync(wrappers[0], "utf8")); + const native = readFileSync(launchers[0]); + const state = readFileSync(statePath); + writeFileSync(wrappers[0], obsolete); setCodexShimProbeHookForTests(() => { throw new Error("synthetic obsolete upgrade probe failure"); }); - - let failure: unknown; try { - installCodexShim(); - } catch (error) { - failure = error; - } finally { - setCodexShimProbeHookForTests(null); - } - - expect(failure).toBeInstanceOf(AggregateError); - expect(readFileSync(wrappers[0], "utf8")).toBe(obsolete); - expect(readFileSync(backups[0])).toEqual(oldBackup); - expect(readFileSync(statePath)).toEqual(oldState); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true, message: "synthetic obsolete upgrade probe failure" }); + expect(readFileSync(wrappers[0], "utf8")).toBe(obsolete); + expect(readFileSync(launchers[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + } finally { setCodexShimProbeHookForTests(null); } }); }); - test("obsolete Unix shim upgrade preserves a concurrent wrapper replacement", () => { + test("obsolete private Unix shim upgrade preserves a concurrent wrapper replacement", () => { if (process.platform === "win32") return; - withInstalledShim(({ binDir, wrappers, backups, statePath }) => { - const current = readFileSync(wrappers[0], "utf8"); - const obsolete = obsoleteUnixShim(current); + withInstalledShim(({ wrappers, launchers, statePath }) => { const concurrent = successfulLauncher("obsolete upgrade concurrent replacement"); - const oldBackup = readFileSync(backups[0]); - const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], obsolete, "utf8"); - setCodexShimProbeHookForTests(() => { - writeFileSync(wrappers[0], concurrent, "utf8"); - chmodSync(wrappers[0], 0o755); - }); - - let result!: ReturnType; + const native = readFileSync(launchers[0]); + const state = readFileSync(statePath); + writeFileSync(wrappers[0], obsoleteUnixShim(readFileSync(wrappers[0], "utf8"))); + setCodexShimProbeHookForTests(() => writeFileSync(wrappers[0], concurrent)); try { - result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - } finally { - setCodexShimProbeHookForTests(null); - } - - expect(result.status).toBe("deferred"); - expect("message" in result && result.message).toContain("upgrade deferred because tracked launchers changed"); - expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); - expect(readFileSync(backups[0])).toEqual(oldBackup); - expect(readFileSync(statePath)).toEqual(oldState); - expect(readdirSync(binDir).some(name => name.includes(".upgrade-"))).toBe(false); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("deferred"); + expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); + expect(readFileSync(launchers[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + } finally { setCodexShimProbeHookForTests(null); } }); }); - test("stable shim replacement restores through the shared install transaction", () => { + test("foreign Unix overlay replacement is preserved while Windows still restores in place", () => { withInstalledShim(({ wrappers, backups }) => { const replacements = wrappers.map((wrapper, index) => successfulLauncher(`replacement-${index}`)); wrappers.forEach((wrapper, index) => writeFileSync(wrapper, replacements[index], "utf8")); const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); + if (process.platform !== "win32") { + expect(result.status).toBe("ineligible"); + wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(replacements[index])); + backups.forEach(backup => expect(readFileSync(backup, "utf8")).toBe("#!/bin/sh\necho real\n")); + return; + } expect(result.status).toBe("restored"); wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); backups.forEach((backup, index) => expect(readFileSync(backup, "utf8")).toBe(replacements[index])); }); }); - test("guarded auto-restore rejects a recursive replacement and restores both launcher generations", () => { + test("private auto-restore preserves a foreign recursive wrapper and its native launcher", () => { if (process.platform === "win32") return; withInstalledShim(({ binDir, wrappers, backups, statePath }) => { const dynamicLauncher = join(binDir, "dynamic-codex-launcher"); @@ -1640,7 +1502,7 @@ printf '%s\\n' child-codex const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - expect(result.status).toBe("deferred"); + expect(result.status).toBe("ineligible"); expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); expect(readFileSync(backups[0])).toEqual(oldBackup); expect(readFileSync(statePath)).toEqual(oldState); @@ -1657,7 +1519,7 @@ printf '%s\\n' child-codex const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - expect(result.status).toBe("deferred"); + expect(result.status).toBe("ineligible"); expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); expect(readFileSync(backups[0])).toEqual(oldBackup); expect(readFileSync(statePath)).toEqual(oldState); @@ -1666,53 +1528,37 @@ printf '%s\\n' child-codex test("guarded auto-restore rolls back when probe infrastructure throws", () => { if (process.platform === "win32") return; - withInstalledShim(({ wrappers, backups, statePath }) => { - const replacement = successfulLauncher("guarded-probe-error-replacement"); - const oldBackup = readFileSync(backups[0]); - const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], replacement, "utf8"); + withInstalledShim(({ home, wrappers, launchers, statePath }) => { + const replacement = obsoleteUnixShim(readFileSync(wrappers[0], "utf8")); + const native = readFileSync(launchers[0]); + const state = readFileSync(statePath); + writeFileSync(wrappers[0], replacement); setCodexShimProbeHookForTests(() => { throw new Error("synthetic guarded probe failure"); }); - - let failure: unknown; try { - autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - } catch (error) { - failure = error; - } finally { - setCodexShimProbeHookForTests(null); - } - - expect(failure).toBeInstanceOf(AggregateError); - expect((failure as AggregateError).errors.map(error => String(error))).toContain("Error: synthetic guarded probe failure"); - expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); - expect(readFileSync(backups[0])).toEqual(oldBackup); - expect(readFileSync(statePath)).toEqual(oldState); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait })).toMatchObject({ status: "deferred" }); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(launchers[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + } finally { setCodexShimProbeHookForTests(null); } }); }); - test("guarded auto-restore removes its unfingerprinted partial wrapper before rollback", () => { + test("private auto-restore refuses a tampered staged wrapper without replacing the prior overlay", () => { if (process.platform === "win32") return; - withInstalledShim(({ wrappers, backups, statePath }) => { - const replacement = successfulLauncher("guarded-partial-write-replacement"); - const oldBackup = readFileSync(backups[0]); - const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], replacement, "utf8"); - setCodexShimGuardedWriteHookForTests(() => { throw new Error("synthetic failure after wrapper write"); }); - - let failure: unknown; + withInstalledShim(({ home, wrappers, launchers, statePath }) => { + const replacement = obsoleteUnixShim(readFileSync(wrappers[0], "utf8")); + const native = readFileSync(launchers[0]); + const state = readFileSync(statePath); + writeFileSync(wrappers[0], replacement); + setCodexShimProbeHookForTests(() => { const staged = join(home, "bin", readdirSync(join(home, "bin")).find(name => name.endsWith(".stage"))!); + writeFileSync(staged, `#!/bin/sh\n# ${SHIM_MARKER}\n`); + throw new Error("synthetic private staging failure"); }); try { - autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); - } catch (error) { - failure = error; - } finally { - setCodexShimGuardedWriteHookForTests(null); - } - - expect(failure).toBeInstanceOf(AggregateError); - expect((failure as AggregateError).errors.map(error => String(error))).toContain("Error: synthetic failure after wrapper write"); - expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); - expect(readFileSync(backups[0])).toEqual(oldBackup); - expect(readFileSync(statePath)).toEqual(oldState); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait })).toMatchObject({ status: "deferred" }); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(launchers[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + } finally { setCodexShimProbeHookForTests(null); } }); }); @@ -1729,13 +1575,14 @@ exit 0 `; const oldBackup = readFileSync(backups[0]); const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], replacement, "utf8"); + writeFileSync(backups[0], replacement, "utf8"); + rmSync(wrappers[0]); const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); expect(result.status).toBe("deferred"); expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); - expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(backups[0], "utf8")).toBe(replacement); expect(readFileSync(statePath)).toEqual(oldState); expect(readdirSync(binDir).filter(name => name.includes(".autorestore-"))).toEqual([]); }); @@ -1766,7 +1613,7 @@ exit 0 }); }); - test("direct refresh rejects a recursive replacement without replacing the owned backup", () => { + test("direct overlay refresh rejects a foreign recursive wrapper without replacing native", () => { if (process.platform === "win32") return; withInstalledShim(({ binDir, wrappers, backups, statePath }) => { const dynamicLauncher = join(binDir, "dynamic-codex-launcher"); @@ -1781,7 +1628,7 @@ exit 0 const result = installCodexShim(); expect(result.installed).toBe(false); - expect(result.message).toContain("Refusing to overwrite existing backup"); + expect(result.message).toContain("unowned private Codex launcher"); expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); expect(readFileSync(backups[0])).toEqual(oldBackup); expect(readFileSync(statePath)).toEqual(oldState); @@ -1799,7 +1646,7 @@ exit 0 const result = installCodexShim(); expect(result.installed).toBe(false); - expect(result.message).toContain("Refusing to overwrite existing backup"); + expect(result.message).toContain("unowned private Codex launcher"); expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); expect(readFileSync(backups[0])).toEqual(oldBackup); expect(readFileSync(statePath)).toEqual(oldState); @@ -1808,27 +1655,18 @@ exit 0 test("direct refresh rolls back when probe infrastructure throws", () => { if (process.platform === "win32") return; - withInstalledShim(({ wrappers, backups, statePath }) => { - const replacement = successfulLauncher("direct-probe-error-replacement"); - const oldBackup = readFileSync(backups[0]); - const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], replacement, "utf8"); + withInstalledShim(({ home, wrappers, launchers, statePath }) => { + const replacement = obsoleteUnixShim(readFileSync(wrappers[0], "utf8")); + const native = readFileSync(launchers[0]); + const state = readFileSync(statePath); + writeFileSync(wrappers[0], replacement); setCodexShimProbeHookForTests(() => { throw new Error("synthetic direct probe failure"); }); - - let failure: unknown; try { - installCodexShim(); - } catch (error) { - failure = error; - } finally { - setCodexShimProbeHookForTests(null); - } - - expect(failure).toBeInstanceOf(AggregateError); - expect((failure as AggregateError).errors.map(error => String(error))).toContain("Error: synthetic direct probe failure"); - expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); - expect(readFileSync(backups[0])).toEqual(oldBackup); - expect(readFileSync(statePath)).toEqual(oldState); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true }); + expect(readFileSync(wrappers[0], "utf8")).toBe(replacement); + expect(readFileSync(launchers[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + } finally { setCodexShimProbeHookForTests(null); } }); }); @@ -1845,13 +1683,14 @@ exit 127 `; const oldBackup = readFileSync(backups[0]); const oldState = readFileSync(statePath); - writeFileSync(wrappers[0], replacement, "utf8"); + writeFileSync(backups[0], replacement, "utf8"); + rmSync(wrappers[0]); const result = installCodexShim(); expect(result.installed).toBe(false); expect(readFileSync(wrappers[0], "utf8")).toBe(concurrent); - expect(readFileSync(backups[0])).toEqual(oldBackup); + expect(readFileSync(backups[0], "utf8")).toBe(replacement); expect(readFileSync(statePath)).toEqual(oldState); expect(readdirSync(binDir).filter(name => name.includes(".autorestore-"))).toEqual([]); }); @@ -1863,8 +1702,8 @@ exit 127 const readyPath = join(home, "first-lock-ready"); const releasePath = join(home, "release-first-lock"); const restoreLockPath = join(home, "codex-shim.autorestore.lock"); - const wrapper = join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); - const backup = join(binDir, process.platform === "win32" ? "codex.opencodex-real.cmd" : "codex.opencodex-real"); + let wrapper = join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + let backup = join(binDir, process.platform === "win32" ? "codex.opencodex-real.cmd" : "codex.opencodex-real"); const replacement = successfulLauncher("concurrent replacement launcher"); const oldPath = process.env.PATH; const oldHome = process.env.OPENCODEX_HOME; @@ -1876,7 +1715,11 @@ exit 127 if (process.platform !== "win32") chmodSync(wrapper, 0o755); expect(installCodexShim().installed).toBe(true); writeFileSync(wrapper, replacement, "utf8"); - if (process.platform !== "win32") chmodSync(wrapper, 0o755); + if (process.platform !== "win32") { + backup = wrapper; + wrapper = join(home, "bin", "codex"); + chmodSync(wrapper, 0o644); + } const shimModule = repoPath("src", "codex", "shim.ts"); const firstScript = ` @@ -1943,7 +1786,7 @@ exit 127 removeTreeWithRetry(binDir); removeTreeWithRetry(home); } - }); + }, SPAWN_BUDGET_MS); test("stale-lock compare-and-delete never unlinks a successor lock", () => { withInstalledShim(({ home, wrappers, backups }) => { @@ -1953,7 +1796,9 @@ exit 127 const stale = JSON.stringify({ version: 1, token: "stale-owner", pid: 2_147_483_647, createdAt: 0 }) + "\n"; const successor = JSON.stringify({ version: 1, token: "successor-owner", pid: process.pid, createdAt: Date.now() }) + "\n"; const oldBackups = backups.map(path => readFileSync(path)); - wrappers.forEach((path, index) => writeFileSync(path, `replacement-${index}\n`, "utf8")); + const oldWrappers = wrappers.map(path => readFileSync(path)); + wrappers.forEach((path, index) => process.platform === "win32" + ? writeFileSync(path, `replacement-${index}\n`, "utf8") : chmodSync(path, 0o644)); mkdirSync(lockPath); writeFileSync(stalePath, stale, "utf8"); utimesSync(stalePath, new Date(0), new Date(0)); @@ -1971,7 +1816,8 @@ exit 127 expect(result).toEqual({ status: "deferred" }); expect(readdirSync(lockPath)).toEqual(["successor-owner.json"]); expect(readFileSync(successorPath, "utf8")).toBe(successor); - wrappers.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(`replacement-${index}\n`)); + wrappers.forEach((path, index) => process.platform === "win32" + ? expect(readFileSync(path, "utf8")).toBe(`replacement-${index}\n`) : expect(readFileSync(path)).toEqual(oldWrappers[index])); backups.forEach((path, index) => expect(readFileSync(path)).toEqual(oldBackups[index])); }); }); @@ -1981,7 +1827,9 @@ exit 127 const lockPath = join(home, "codex-shim.autorestore.lock"); const ownerPath = join(lockPath, "dead-owner.json"); const replacements = wrappers.map((_, index) => successfulLauncher(`dead-owner-replacement-${index}`)); - wrappers.forEach((path, index) => writeFileSync(path, replacements[index], "utf8")); + const oldBackups = backups.map(path => readFileSync(path)); + wrappers.forEach((path, index) => process.platform === "win32" + ? writeFileSync(path, replacements[index], "utf8") : chmodSync(path, 0o644)); mkdirSync(lockPath); writeFileSync(ownerPath, `${JSON.stringify({ version: 1, @@ -1996,12 +1844,25 @@ exit 127 expect(result.status).toBe("restored"); expect(existsSync(lockPath)).toBe(false); wrappers.forEach(path => expect(readFileSync(path, "utf8")).toContain(SHIM_MARKER)); - backups.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(replacements[index])); + backups.forEach((path, index) => process.platform === "win32" + ? expect(readFileSync(path, "utf8")).toBe(replacements[index]) : expect(readFileSync(path)).toEqual(oldBackups[index])); }); }); test("stalled partial write changing during the observation interval is never promoted", () => { withInstalledShim(({ wrappers, backups }) => { + if (process.platform !== "win32") { + const stateBefore = readFileSync(join(process.env.OPENCODEX_HOME!, "codex-shim.json")); + const changing = successfulLauncher("completed after native partial write"); + rmSync(wrappers[0]); + const result = autoRestoreCodexShim({ enabled: () => true, + stabilitySleep: () => writeFileSync(backups[0], changing) }); + expect(result.status).toBe("deferred"); + expect(readFileSync(backups[0], "utf8")).toBe(changing); + expect(existsSync(wrappers[0])).toBe(false); + expect(readFileSync(join(process.env.OPENCODEX_HOME!, "codex-shim.json"))).toEqual(stateBefore); + return; + } const oldBackups = backups.map(path => readFileSync(path)); wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `partial-${index}\n`, "utf8")); @@ -2018,6 +1879,21 @@ exit 127 test("mixed launcher siblings defer the whole restore without piecemeal mutation", () => { withInstalledShim(({ binDir, wrappers, backups, statePath }) => { + if (process.platform !== "win32") { + const native = readFileSync(backups[0]); + const current = readFileSync(wrappers[0]); + const legacy = { platform: process.platform, wrapperPath: wrappers[0], originalPath: wrappers[0], + backupPath: backups[0], wrappers: [{ wrapperPath: wrappers[0], originalPath: wrappers[0], backupPath: backups[0] }, + { wrapperPath: join(binDir, "codex.ps1"), originalPath: join(binDir, "codex.ps1"), backupPath: join(binDir, "saved.ps1") }] }; + writeFileSync(statePath, JSON.stringify(legacy)); + const state = readFileSync(statePath); + expect(autoRestoreCodexShim({ enabled: () => true }).status).toBe("ineligible"); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true, message: expect.stringContaining("ambiguous") }); + expect(readFileSync(wrappers[0])).toEqual(current); + expect(readFileSync(backups[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + return; + } if (wrappers.length === 1) { const sibling = join(binDir, "codex.ps1"); const siblingBackup = join(binDir, "codex.opencodex-real.ps1"); @@ -2049,10 +1925,13 @@ exit 127 test("opt-out set -> no restore and explicit install remains available", () => { withInstalledShim(({ wrappers }) => { const replacements = wrappers.map((_, index) => successfulLauncher(`disabled-${index}`)); - wrappers.forEach((wrapper, index) => writeFileSync(wrapper, replacements[index], "utf8")); + const current = wrappers.map(wrapper => readFileSync(wrapper)); + wrappers.forEach((wrapper, index) => process.platform === "win32" + ? writeFileSync(wrapper, replacements[index], "utf8") : chmodSync(wrapper, 0o644)); expect(autoRestoreCodexShim({ enabled: () => false, stabilitySleep: skipStabilityWait })).toEqual({ status: "disabled" }); - wrappers.forEach((wrapper, index) => expect(readFileSync(wrapper, "utf8")).toBe(replacements[index])); + wrappers.forEach((wrapper, index) => process.platform === "win32" + ? expect(readFileSync(wrapper, "utf8")).toBe(replacements[index]) : expect(readFileSync(wrapper)).toEqual(current[index])); expect(installCodexShim().installed).toBe(true); wrappers.forEach(wrapper => expect(readFileSync(wrapper, "utf8")).toContain(SHIM_MARKER)); }); @@ -2060,6 +1939,19 @@ exit 127 test("fingerprint mismatch before guarded rename defers without owned-path mutation", () => { withInstalledShim(({ wrappers, backups, statePath }) => { + if (process.platform !== "win32") { + const state = readFileSync(statePath); + const native = readFileSync(backups[0]); + const foreign = successfulLauncher("concurrent replacement"); + rmSync(wrappers[0]); + const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait, + afterRestoreLockAcquired: () => writeFileSync(wrappers[0], foreign, { mode: 0o755 }) }); + expect(result.status).toBe("deferred"); + expect(readFileSync(wrappers[0], "utf8")).toBe(foreign); + expect(readFileSync(backups[0])).toEqual(native); + expect(readFileSync(statePath)).toEqual(state); + return; + } wrappers.forEach((wrapper, index) => writeFileSync(wrapper, `candidate-${index}\n`, "utf8")); const oldBackups = backups.map(path => readFileSync(path)); const oldState = readFileSync(statePath); @@ -2117,7 +2009,7 @@ exit 127 }, }); - expect(result).toEqual({ status: "deferred" }); + expect(result.status).toBe(process.platform === "win32" ? "deferred" : "ineligible"); wrappers.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(wrapperBytes[index])); backups.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(backupBytes[index])); expect(readFileSync(statePath)).toEqual(stateBytes); @@ -2131,18 +2023,20 @@ exit 127 } }); - test("missing backup, missing wrapper, corrupt state, and platform mismatch never fresh-install", () => { + test("missing native refuses rediscovery, private wrapper repairs, and invalid state fails closed", () => { withInstalledShim(({ wrappers, backups, statePath }) => { rmSync(backups[0]); expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); - writeFileSync(backups[0], "backup\n", "utf8"); + writeFileSync(backups[0], successfulLauncher("native repaired"), "utf8"); + if (process.platform !== "win32") chmodSync(backups[0], 0o755); rmSync(wrappers[0]); - expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe(process.platform === "win32" ? "ineligible" : "restored"); if (process.platform !== "win32") { + rmSync(wrappers[0], { force: true }); mkdirSync(wrappers[0]); - expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("deferred"); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); removeTreeWithRetry(wrappers[0]); symlinkSync(join(dirname(wrappers[0]), "missing-target"), wrappers[0]); expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("ineligible"); @@ -2241,7 +2135,7 @@ describe("version-manager shim destruction (#2412)", () => { // The silent bail is the whole defect: cli/codex-shim-autorestore.ts warns // only when a message exists. expect(result.message).toBeTruthy(); - expect(result.message).toContain("backup"); + expect(result.message).toContain(process.platform === "win32" ? "backup" : "native Codex launcher"); }); }); }); @@ -2281,17 +2175,19 @@ describe("Codex shim read-only backing inspection", () => { status: "matched", selectedRole: "wrapper", backingPath: backups[0]!, - backingKind: "backup", + backingKind: "real", }); expect(inspectCodexShimBackingForCommand(backups[0]!)).toMatchObject({ status: "matched", selectedRole: "backing", backingPath: backups[0]!, - backingKind: "backup", + backingKind: "real", }); - const state = JSON.parse(readFileSync(statePath, "utf8")) as { wrappers: Array> }; - state.wrappers[0]!.preserveOnly = true; + // Retain legacy preserve-only inspection coverage independently of overlay schema. + const state = { platform: process.platform, wrapperPath: wrappers[0], + originalPath: wrappers[0], backupPath: backups[0], + wrappers: [{ wrapperPath: wrappers[0], originalPath: wrappers[0], backupPath: backups[0], preserveOnly: true }] }; writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toEqual({ status: "unknown", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 956884ba223..d04a0b6a007 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,9 @@ { + "codex-shim-overlay.test.ts": "codex-integration", + "codex-shim-migration.test.ts": "codex-integration", + "codex-shim-overlay-transaction.test.ts": "codex-integration", + "codex-shim-overlay-readiness.test.ts": "codex-integration", + "codex-shim-overlay-candidates.test.ts": "codex-integration", "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", "antigravity-discovered-families.test.ts": "adapters/google", "provider-antigravity-effort-families.test.ts": "providers", diff --git a/tests/helpers/codex-shim-install-fixture.ts b/tests/helpers/codex-shim-install-fixture.ts index 72582434156..71b15ad70b4 100644 --- a/tests/helpers/codex-shim-install-fixture.ts +++ b/tests/helpers/codex-shim-install-fixture.ts @@ -1,14 +1,14 @@ -import { expect } from "bun:test"; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { afterAll, beforeAll, expect } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { installCodexShim } from "../../src/codex/shim"; import { removeTreeWithRetry } from "./remove-tree"; /** - * Installs a real Codex shim over a throwaway PATH entry and a throwaway OPENCODEX_HOME, hands - * the resolved wrapper and backup paths to the caller, and restores both environment variables - * and removes both temporary trees afterwards. + * Installs against throwaway native launchers and HOME/OPENCODEX_HOME/CODEX_HOME. + * Exposes the private Unix overlay or Windows in-place wrappers and recorded native launchers, + * then restores the environment and removes both temporary trees. * * This lived inside tests/codex-integration/codex-shim.test.ts until a second file needed it. * Copying it would have been the cheaper edit and the wrong one: the fixture owns the contract @@ -24,18 +24,24 @@ export function withInstalledShim(run: (paths: { home: string; wrappers: string[]; backups: string[]; + launchers: string[]; statePath: string; }) => void): void { const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-bin-")); const home = mkdtempSync(join(tmpdir(), "ocx-shim-home-")); const oldPath = process.env.PATH; const oldHome = process.env.OPENCODEX_HOME; + const oldUserHome = process.env.HOME; + const oldCodexHome = process.env.CODEX_HOME; const wrappers = process.platform === "win32" ? [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")] : [join(binDir, "codex")]; try { process.env.PATH = prependPath(binDir, oldPath); process.env.OPENCODEX_HOME = home; + process.env.HOME = home; + process.env.CODEX_HOME = join(home, "codex-home"); + mkdirSync(process.env.CODEX_HOME); for (const wrapper of wrappers) { writeFileSync(wrapper, process.platform === "win32" ? `real ${wrapper}\n` : "#!/bin/sh\necho real\n", "utf8"); if (process.platform !== "win32") chmodSync(wrapper, 0o755); @@ -43,12 +49,15 @@ export function withInstalledShim(run: (paths: { const installed = installCodexShim(); expect(installed.installed, installed.message).toBe(true); const statePath = join(home, "codex-shim.json"); - const state = JSON.parse(readFileSync(statePath, "utf8")) as { wrappers: Array<{ wrapperPath: string; backupPath: string }> }; + const state = JSON.parse(readFileSync(statePath, "utf8")) as { schema?: number; wrapperPath: string; launcherPath?: string; wrappers?: Array<{ wrapperPath: string; backupPath: string }> }; + const launchers = state.schema === 2 ? [state.launcherPath!] : state.wrappers!.map(file => file.backupPath); run({ binDir, home, - wrappers: state.wrappers.map(file => file.wrapperPath), - backups: state.wrappers.map(file => file.backupPath), + wrappers: state.schema === 2 ? [state.wrapperPath] : state.wrappers!.map(file => file.wrapperPath), + // Compatibility for legacy callers; overlay launchers are never renamed backups. + backups: launchers, + launchers, statePath, }); } finally { @@ -56,7 +65,33 @@ export function withInstalledShim(run: (paths: { else process.env.PATH = oldPath; if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + if (oldUserHome === undefined) delete process.env.HOME; + else process.env.HOME = oldUserHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; removeTreeWithRetry(binDir); removeTreeWithRetry(home); } } + +/** Isolate template and direct-install tests that do not use withInstalledShim. */ +export function isolateCodexShimEnvironment(): void { + const keys = ["HOME", "OPENCODEX_HOME", "CODEX_HOME"] as const; + let root: string; + let saved: Array; + beforeAll(() => { + saved = keys.map(key => process.env[key]); + root = mkdtempSync(join(tmpdir(), "ocx-shim-environment-")); + for (const key of keys) { + process.env[key] = join(root, key); + mkdirSync(process.env[key]!); + } + }); + afterAll(() => { + keys.forEach((key, index) => { + if (saved[index] === undefined) delete process.env[key]; + else process.env[key] = saved[index]; + }); + removeTreeWithRetry(root); + }); +} diff --git a/tests/service/doctor-codex-envkey-readiness.test.ts b/tests/service/doctor-codex-envkey-readiness.test.ts index 657a97448f8..5cbd924cef5 100644 --- a/tests/service/doctor-codex-envkey-readiness.test.ts +++ b/tests/service/doctor-codex-envkey-readiness.test.ts @@ -1,7 +1,10 @@ +import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; import { describe, expect, test } from "bun:test"; import { collectCodexEnvKeyReadiness } from "../../src/cli/doctor"; import type { CodexShimDiagnostic } from "../../src/codex/shim"; +isolateCodexShimEnvironment(); + const config = ` model_provider = "opencodex" @@ -37,6 +40,17 @@ describe("doctor Codex env_key launch readiness", () => { expect(row?.shimState).toBe("unhealthy"); }); + test("an inactive runnable overlay keeps the token warning and advises shell activation", () => { + const row = collectCodexEnvKeyReadiness(config, {}, { + installed: true, healthy: false, runnable: true, active: false, summary: "inactive", + }, true); + expect(row?.shimState).toBe("inactive"); + expect(row?.detail).toContain("variable is unset"); + expect(row?.action).toContain("codex-shell-env.sh"); + expect(row?.action).not.toContain("codex-shim install"); + expect(row?.action).toContain("Absolute Codex paths and GUI launchers bypass"); + }); + test("does not warn when the configured environment variable is set", () => { expect(collectCodexEnvKeyReadiness(config, { OPENCODEX_API_AUTH_TOKEN: "secret" }, missingShim, true)).toBeNull(); }); From 499cd7ef2f3440d498e7a1c4c1cefdb42af57135 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 00:34:45 +0900 Subject: [PATCH 2/3] fix(codex): revalidate overlay artifacts before reporting healthy Diagnosis, automatic repair, and the already-installed install path now re-snapshot the state, wrapper, and shell environment right before they report healthy or print the activation command, and refuse when identity, bytes, mode, or ownership changed during the native or PATH inspection. The Unix destruction regression now replaces only the native launcher, which is what a version manager owns behind the private overlay. --- src/codex/shim-overlay.ts | 37 ++++++++--- .../codex-shim-overlay-transaction.test.ts | 63 +++++++++++++++++++ tests/codex-integration/codex-shim.test.ts | 5 +- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts index 5095ea818b6..c35187fd937 100644 --- a/src/codex/shim-overlay.ts +++ b/src/codex/shim-overlay.ts @@ -21,7 +21,7 @@ import { } from "./shim-state-file"; import { buildUnixCodexShim, SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, shQuote } from "./shim-templates"; -type Snapshot = { identity: OverlayIdentity; mode: number; content: string }; +type Snapshot = { identity: OverlayIdentity; mode: number; content: string; uid?: number; gid?: number }; type Publication = { path: string; staged: string; created: Snapshot; prior: Snapshot | null; rollback: string }; type Journal = { version: 1; token: string; launcher: string; files: Publication[] }; type InstallResult = { installed: boolean; message: string; refused?: boolean; runnable?: boolean }; @@ -63,12 +63,15 @@ function snapshot(path: string): Snapshot | null { || bounded.stat.size !== after.size || bounded.stat.mtimeMs !== after.mtimeMs || bounded.stat.ctimeMs !== after.ctimeMs) { throw new Error("Private Codex artifact changed"); } - return { identity: { dev: after.dev, ino: after.ino }, mode: after.mode & 0o777, content: bounded.content }; + return { identity: { dev: after.dev, ino: after.ino }, mode: after.mode & 0o7777, + content: bounded.content, uid: after.uid, gid: after.gid }; } function unchanged(path: string, expected: Snapshot | null): boolean { const current = snapshot(path); return expected ? !!current && sameId(current.identity, expected.identity) - && current.content === expected.content && current.mode === expected.mode : current === null; + && current.content === expected.content && current.mode === expected.mode + && (expected.uid === undefined || current.uid === expected.uid) + && (expected.gid === undefined || current.gid === expected.gid) : current === null; } /** Validate the physical configured home alias; managed bin may never be a symlink. */ @@ -128,8 +131,15 @@ function requireOverlayIdentities(state: OverlayState): void { function ownedEnvironment(value: Snapshot, state: OverlayState): boolean { return sameId(value.identity, state.envIdentity) && value.content === shellEnvironment(); } +function revalidateOverlay(state: OverlayState, saved: Snapshot | null, wrapper: Snapshot | null, env: Snapshot | null): void { + if (!unchanged(statePath(), saved) || !unchanged(state.wrapperPath, wrapper) || !unchanged(overlayPaths().env, env)) { + throw new Error("Private Codex installation changed during inspection; preserving artifacts"); + } +} /** Validate the whole installation before endorsing either execution or activation. */ -function inspectOverlay(state: OverlayState): { wrapper: Snapshot | null; environmentPresent: boolean; complete: boolean } { +function inspectOverlay(state: OverlayState): { + wrapper: Snapshot | null; environmentPresent: boolean; complete: boolean; revalidate: () => void; +} { requireOverlayIdentities(state); const saved = snapshot(statePath()); const recorded = saved ? decodeOverlayState(JSON.parse(saved.content), getConfigDir()) : null; @@ -142,10 +152,9 @@ function inspectOverlay(state: OverlayState): { wrapper: Snapshot | null; enviro const env = snapshot(overlayPaths().env); if (wrapper && !ownedWrapper(wrapper, state)) throw new Error("Private Codex launcher was replaced; preserving it"); if (env && !ownedEnvironment(env, state)) throw new Error("Codex shell environment file was replaced; preserving it"); - if (!unchanged(statePath(), saved) || !unchanged(state.wrapperPath, wrapper) || !unchanged(overlayPaths().env, env)) { - throw new Error("Private Codex installation changed during inspection; preserving artifacts"); - } - return { wrapper, environmentPresent: !!env, complete: saved.mode === 0o600 && !!env && env.mode === 0o600 }; + const revalidate = () => revalidateOverlay(state, saved, wrapper, env); + revalidate(); + return { wrapper, environmentPresent: !!env, complete: saved.mode === 0o600 && !!env && env.mode === 0o600, revalidate }; } function nativeProbe(path: string) { if (!isExecutableCodexCandidate(path)) return null; @@ -181,6 +190,7 @@ export function overlayDiagnostic(state: ShimState): CodexShimDiagnostic { } catch { active = null; } } check(); + inspection?.revalidate(); } catch (error) { active = null; runnable = false; integrityMessage = failure(error).message; } return { installed: true, healthy: complete && runnable && active === true, runnable, active, summary: `Codex PATH shim: ${complete && runnable ? "ready" : "unhealthy"} at ${state.wrapperPath}; launcher ${state.launcherPath}; PATH ${active === null ? "unverified" : active ? "active" : "inactive"}. ${complete && runnable ? overlayActivationHint() : integrityMessage || "Run ocx codex-shim install to repair the shim."}` }; @@ -214,7 +224,10 @@ function validSnapshot(value: unknown): value is Snapshot { if (!value || typeof value !== "object") return false; const item = value as Snapshot; return !!item.identity && Number.isSafeInteger(item.identity.dev) && Number.isSafeInteger(item.identity.ino) - && Number.isInteger(item.mode) && item.mode >= 0 && item.mode <= 0o777 && typeof item.content === "string"; + && Number.isInteger(item.mode) && item.mode >= 0 && item.mode <= 0o7777 && typeof item.content === "string" + // Older journals have no owner fields; snapshot() still requires current-user ownership. + && (item.uid === undefined || Number.isSafeInteger(item.uid) && item.uid >= 0) + && (item.gid === undefined || Number.isSafeInteger(item.gid) && item.gid >= 0); } function readJournal(): { journal: Journal; saved: Snapshot } | null { const saved = snapshot(journalPath()); @@ -287,6 +300,8 @@ function publishOverlay(launcher: string, state: OverlayState | null, check: () const script = scriptFor(launcher); if (state && wrapper?.content === script && isExecutableCodexCandidate(paths.wrapper) && env?.content === shellEnvironment() && env.mode === 0o600 && currentState?.mode === 0o600) { + check(); + revalidateOverlay(state, currentState, wrapper, env); return { installed: false, runnable: true, message: `Codex PATH shim already installed. ${overlayActivationHint()}` }; } const token = randomUUID(); @@ -430,7 +445,9 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe const inspection = inspectOverlay(state); const runnable = overlayRunnable(state, inspection.wrapper); check(); - if (inspection.complete && runnable && !lexical(journalPath())) return { status: "healthy" }; + const pending = lexical(journalPath()); + inspection.revalidate(); + if (inspection.complete && runnable && !pending) return { status: "healthy" }; } 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" }; diff --git a/tests/codex-integration/codex-shim-overlay-transaction.test.ts b/tests/codex-integration/codex-shim-overlay-transaction.test.ts index d67deba5df8..338fa17057c 100644 --- a/tests/codex-integration/codex-shim-overlay-transaction.test.ts +++ b/tests/codex-integration/codex-shim-overlay-transaction.test.ts @@ -114,6 +114,69 @@ describe.skipIf(process.platform === "win32")("Codex overlay publication transac expect(saved(f.native)).toEqual(native); })); + for (const operation of ["diagnosis", "auto-restore", "install"] as const) { + test.each(["env-replace", "env-chmod", "state-chmod"])(`${operation} rejects late %s during executable inspection`, mutation => fixture(f => { + expect(installCodexShim().installed).toBe(true); + process.env.PATH = `${dirname(f.wrapper)}:${process.env.PATH}`; + const targets = [f.statePath, f.wrapper, f.envFile, f.native]; + const before = targets.map(saved); + const path = mutation === "state-chmod" ? f.statePath : f.envFile; + let hit = false; + let changed: ReturnType | undefined; + const change = () => { + hit = true; + if (mutation === "env-replace") { + // Keep bytes and mode identical so inode replacement alone must be rejected. + fs.renameSync(path, `${path}.original`); + fs.writeFileSync(path, before[2]!.bytes, { mode: 0o600 }); + } else { + fs.chmodSync(path, 0o666); + } + changed = saved(path); + }; + if (operation === "install") { + const access = fs.accessSync; + spyOn(fs, "accessSync").mockImplementation((candidate, mode) => { + if (!hit && String(candidate) === f.wrapper && mode === fs.constants.X_OK) change(); + return access(candidate, mode); + }); + } else { + const open = fs.openSync; + spyOn(fs, "openSync").mockImplementation((candidate, flags, mode) => { + if (!hit && String(candidate) === f.native) change(); + return open(candidate, flags, mode); + }); + } + let message: string; + if (operation === "diagnosis") { + const result = diagnoseCodexShim(); + expect(result).toMatchObject({ healthy: false, runnable: false, active: null }); + message = result.summary; + } else if (operation === "auto-restore") { + const result = autoRestoreCodexShim({ enabled: () => { throw new Error("unsafe artifact must not consult opt-in"); } }); + expect(result.status).toBe("ineligible"); + message = result.message ?? ""; + } else { + const result = installCodexShim(); + expect(result).toMatchObject({ installed: false, refused: true }); + expect(result.runnable).not.toBe(true); + message = result.message; + } + mock.restore(); + expect(hit).toBe(true); + expect(message).toMatch(/changed.*preserving|not an owned regular file/i); + expect(message).not.toMatch(/For sh\/bash\/zsh|run \. /); + targets.forEach((target, index) => expect(saved(target)).toEqual(target === path ? changed : before[index])); + expect(privateDebris(f.home)).toEqual([]); + if (mutation === "env-replace") { + expect(changed!.ino).not.toBe(before[2]!.ino); + expect(saved(`${path}.original`)).toEqual(before[2]); + } else { + expect(changed!.mode).toBe(0o666); + } + })); + } + test("foreign replacement with identical marker-bearing bytes is preserved by install, repair and uninstall", () => fixture(f => { expect(installCodexShim().installed).toBe(true); const original = saved(f.wrapper); diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 29dba7381a2..b9836cbe317 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -2127,8 +2127,9 @@ describe("version-manager shim destruction (#2412)", () => { test("a destroyed shim reports the paths instead of bailing silently", () => { withInstalledShim(({ wrappers, backups }) => { - writeFileSync(wrappers[0], "#!/bin/sh\necho version-manager codex\n", "utf8"); - if (process.platform !== "win32") chmodSync(wrappers[0], 0o755); + // A version manager rewrites the entry it owns: the in-place wrapper on + // Windows, only the native launcher behind the private Unix overlay. + if (process.platform === "win32") writeFileSync(wrappers[0], "#!/bin/sh\necho version-manager codex\n", "utf8"); rmSync(backups[0]); const result = autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }); expect(result.status).toBe("ineligible"); From f1486313994c34241af87f123d3251c75e7c0a3a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 02:18:04 +0900 Subject: [PATCH 3/3] fix(codex): let a migration retry after a failed journal write Explicit install creates codex-shim.migration.json before it moves anything. If writing it failed part-way (ENOSPC, EIO) or the path was replaced, the empty or truncated journal stayed behind, and every later install refused on the unreadable record. Nothing has moved at that point, so the run now removes the journal it created, but only while the path still names the descriptor's own inode. A replaced journal and every native and recovery artifact are left alone. Regressions cover a partial ENOSPC write followed by a successful retry and a journal replaced during creation that must survive. Reported-by: Ingwannu --- src/codex/shim-migration.ts | 14 ++++++ .../codex-shim-migration.test.ts | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/codex/shim-migration.ts b/src/codex/shim-migration.ts index 6b51a80977e..c963a26d2c1 100644 --- a/src/codex/shim-migration.ts +++ b/src/codex/shim-migration.ts @@ -28,6 +28,15 @@ function identity(path: string): Identity | null { function same(left: Identity | null, right: Identity | null): boolean { return !!left && !!right && JSON.stringify(left) === JSON.stringify(right); } +/** Drop a journal this run created before any migration step, never a replacement at that path. */ +function discardCreatedJournal(journal: string, created: { dev: number; ino: number }): void { + try { + const now = lstatSync(journal); + if (now.isFile() && now.dev === created.dev && now.ino === created.ino) unlinkSync(journal); + } catch { + // An unreadable or vanished path is left for the next run's ownership checks. + } +} function binding(path: string, backup: string): boolean { const probe = stableShimPathProbe(path); return !!probe && probe.fingerprint.kind === "file" && probe.prefix.includes(SHIM_MARKER) @@ -92,6 +101,11 @@ export function migrateLegacyUnixShim(state: ShimState): UnixShimMigrationResult journalIdentity = identity(journal); if (journalIdentity?.ino !== created.ino || journalIdentity.dev !== created.dev || journalIdentity.size !== written.size || journalIdentity.mtimeMs !== written.mtimeMs) throw new Error("Journal replaced"); + } catch (error) { + // Nothing has moved yet, so a partial journal of ours would only block every retry. + discardCreatedJournal(journal, created); + journalIdentity = null; + throw error; } finally { closeSync(fd); } } if (!same(current, record.wrapper) || !same(saved, record.saved)) return refused("Migration inputs changed; preserving all generations."); diff --git a/tests/codex-integration/codex-shim-migration.test.ts b/tests/codex-integration/codex-shim-migration.test.ts index 3c96b2ec1bd..555ea875129 100644 --- a/tests/codex-integration/codex-shim-migration.test.ts +++ b/tests/codex-integration/codex-shim-migration.test.ts @@ -194,4 +194,47 @@ describe.skipIf(process.platform === "win32")("legacy Unix shim migration", () = if (kind === "missing") expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); else expect(lexical(f.native)?.isSymbolicLink()).toBe(true); })); + + test("a partially written journal is discarded so the next install can migrate", () => fixture(f => { + const realWrite = fs.writeFileSync; + const journal = join(f.home, "codex-shim.migration.json"); + const hook = spyOn(fs, "writeFileSync").mockImplementation(((target: fs.PathOrFileDescriptor, data: string | NodeJS.ArrayBufferView, options?: fs.WriteFileOptions) => { + if (typeof target === "number" && String(data).includes('"original"')) { + fs.writeSync(target, "{"); + throw Object.assign(new Error("ENOSPC: no space left on device, write"), { code: "ENOSPC" }); + } + return realWrite(target, data, options); + }) as typeof fs.writeFileSync); + expect(installCodexShim().installed).toBe(false); + expect(hook).toHaveBeenCalled(); + hook.mockRestore(); + expect(lexical(journal)).toBeNull(); + expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + expect(lexical(f.backup)?.isSymbolicLink()).toBe(true); + expect(lexical(f.quarantine)).toBeNull(); + const retry = installCodexShim(); + expect(retry.installed, retry.message).toBe(true); + expect(lexical(journal)).toBeNull(); + expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); + })); + + test("a journal replaced during creation is preserved and migration refuses", () => fixture(f => { + const realWrite = fs.writeFileSync; + const journal = join(f.home, "codex-shim.migration.json"); + const foreign = join(f.root, "foreign-journal"); + realWrite(foreign, "foreign journal\n", { mode: 0o600 }); + const hook = spyOn(fs, "writeFileSync").mockImplementation(((target: fs.PathOrFileDescriptor, data: string | NodeJS.ArrayBufferView, options?: fs.WriteFileOptions) => { + realWrite(target, data, options); + if (typeof target === "number" && String(data).includes('"original"')) fs.renameSync(foreign, journal); + }) as typeof fs.writeFileSync); + expect(installCodexShim().installed).toBe(false); + expect(hook).toHaveBeenCalled(); + hook.mockRestore(); + expect(fs.readFileSync(journal, "utf8")).toBe("foreign journal\n"); + expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + expect(lexical(f.backup)?.isSymbolicLink()).toBe(true); + expect(lexical(f.quarantine)).toBeNull(); + expect(installCodexShim().installed).toBe(false); + expect(fs.readFileSync(journal, "utf8")).toBe("foreign journal\n"); + })); });