diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 2539fbfcbf..4caba038d1 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -23,6 +23,10 @@ import { } from "../src/update/npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs"; import { bootRestoreProbe, transactionalNpmUpdate } from "../src/update/transactional-install.mjs"; +import { + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS, + isCodexCliUpdateInspectionArgv, +} from "../src/update/codex-cli-update-launch-policy.mjs"; const PKG = "@bitkyc08/opencodex"; const require = createRequire(import.meta.url); @@ -468,7 +472,7 @@ function fail(msg) { process.exit(1); } -function resolveBun() { +function resolveBun({ allowInstall = true } = {}) { // Keep direct npm-launcher starts aligned with durable service/shim installs: // a valid explicit runtime must win even when the bundled dependency exists. const override = process.env[BUN_OVERRIDE_ENV]?.trim(); @@ -493,7 +497,7 @@ function resolveBun() { // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. const installJs = join(bunDir, "install.js"); - if (existsSync(installJs)) { + if (allowInstall && existsSync(installJs)) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } @@ -512,6 +516,12 @@ if (updateHelpRequested) { process.exit(0); } +const codexCliUpdateInspection = isCodexCliUpdateInspectionArgv(process.argv); +if (codexCliUpdateInspection && typeof process.versions.bun === "string") { + console.error("opencodex: codex-cli-update inspection must use the published Node launcher."); + process.exit(1); +} + if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { runNpmSelfUpdate(); } @@ -519,7 +529,7 @@ if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstal // #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a // backup sibling and a broken live tree. Restore before anything tries to run from the // broken tree; reap stale backups once the live tree verifies healthy. -if (isNodeModulesInstall() && !isBunGlobalInstall()) { +if (!codexCliUpdateInspection && isNodeModulesInstall() && !isBunGlobalInstall()) { try { const probe = bootRestoreProbe(resolve(here, "..")); if (probe.action === "restored") { @@ -530,7 +540,7 @@ if (isNodeModulesInstall() && !isBunGlobalInstall()) { } catch { /* the probe must never block launch */ } } -const bunRuntime = resolveBun(); +const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection }); const bun = bunRuntime.path; // Run the Bun child asynchronously and FORWARD termination signals to it, then wait @@ -554,12 +564,61 @@ const bun = bunRuntime.path; // interpolation and provider settings legitimately read the project environment. const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); +// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the +// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture +// the absolute form here, in the launcher, while the original cwd is still authoritative; +// resolving it later would silently reinterpret it against a different working directory. +// +// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver +// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` +// would make the inspector treat it as an explicit path and stop searching PATH entirely. +const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" + ? process.env.CODEX_CLI_PATH + : null; +const preBunCodexCliPath = configuredCodexCliPath !== null + && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) + ? resolve(configuredCodexCliPath) + : configuredCodexCliPath; +const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; +const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; +const preBunCodexCliManagerRoots = Object.fromEntries( + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { + const value = process.env[name]; + return typeof value === "string" && value !== "" ? [[name, value]] : []; + }), +); const launchProof = randomBytes(32).toString("base64url"); const launchContext = JSON.stringify({ version: 1, proof: launchProof, anthropicEnvSlots: preBunAnthropicSlots, + codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliPath: preBunCodexCliPath, + path: preBunPath, + pathExt: preBunPathExt, + managerRoots: preBunCodexCliManagerRoots, + configDir: configDir(), + } : null, }); +// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as +// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than +// from the live environment. Inheriting them again would spend the 32,767-character Windows +// environment block twice, so a large-but-valid shell environment could stop the Bun child +// from spawning and fail the command before it reports anything. Drop the duplicates for the +// one-shot inspection launch only; every other launch inherits the environment unchanged. +// Windows environment names are case-insensitive, but this spread produces an ordinary +// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. +// Deleting only the canonical upper-case spelling would silently leave that copy behind and +// reintroduce the duplication this block exists to prevent, so match on the lowercase form. +const inheritedEnv = { ...process.env }; +if (codexCliUpdateInspection) { + const snapshotted = new Set( + ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), + ); + for (const name of Object.keys(inheritedEnv)) { + if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + } +} const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { stdio: "inherit", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no @@ -567,7 +626,7 @@ const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, // the long-running Bun child, and closing that window kills the proxy (#1236). windowsHide: true, env: { - ...process.env, + ...inheritedEnv, [NODE_LAUNCH_CONTEXT_ENV]: launchContext, [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index d8db4b15d3..5b333ad44a 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -11,12 +11,14 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér - [Cycle de vie](/fr/reference/cli/lifecycle/) — configuration initiale, cycle de vie du proxy et du service, état de santé, diagnostics, synchronisation du catalogue, tableau de bord et mises à jour. - [Fournisseurs, comptes et modèles](/fr/reference/cli/providers-accounts/) — configuration des fournisseurs, authentification, pools d’identifiants, quotas, modèles personnalisés, visibilité, modèles sélectionnés et limites de contexte. -- [Agents, routage et intégrations](/fr/reference/cli/agents/) — contrôles multi-agents, combinaisons, observabilité, clés d’admission, intégrations clientes, paramètres d’exécution et configuration validée. +- [Agents, routage et intégrations](/fr/reference/cli/agents/) — contrôles multi-agents, combinaisons, observabilité, clés d’admission, intégrations clientes, paramètres d’exécution, configuration validée et inspection en lecture seule des mises à jour de la CLI Codex. ## Fonctionnement sans interface interactive Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. +`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. + L’affichage d’une liste ou d’un état est l’action par défaut lorsqu’il n’y a aucune ambiguïté. Utilisez `--json` pour obtenir des instantanés structurés et `ocx observe logs --follow --jsonl` pour suivre un flux de journaux de requêtes. Le thème, la langue, la navigation et les autres états purement visuels du navigateur n’ont pas d’équivalent dans la CLI. La configuration de Cloudflare Tunnel ne fait pas partie de cet ensemble de commandes. ## Codes de sortie et confirmation diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 91db38efea..217d4ef04c 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -239,7 +239,7 @@ le CLI, l’API, et le GUI utilisent les mêmes octets. ## Exécution et configuration -### `ocx system ...` +### `ocx system ...` Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisation, les diagnostics et les mises à jour. @@ -247,6 +247,14 @@ Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisatio ocx system settings --stream-mode eager-relay ``` +`ocx system update` met à jour OpenCodex lui-même. Utilisez cette commande distincte et en lecture seule pour Codex CLI : + +```bash +ocx system codex-cli-update check --json +``` + +`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. + ### `ocx config ...` Inspectez et modifiez en toute sécurité la configuration OpenCodex validée. `show` et `get` masquent les secrets. Importer 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 772e80556f..d6d9220333 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -233,7 +233,7 @@ Pendant une mise à niveau, un shim Unix installé qui ne contient pas la garde 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. -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. 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. +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. | Sous-commande | Action | | --- | --- | @@ -264,6 +264,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ ## Mise à jour +`ocx update` met à jour OpenCodex lui-même, et non la CLI Codex. Utilisez `ocx system codex-cli-update check` parmi les [commandes d’inspection système](/fr/reference/cli/agents/) pour vérifier, de façon bornée et en lecture seule, la provenance du candidat Codex CLI configuré. Cette commande n’interroge aucun registre de paquets et n’installe aucune mise à jour. + ### `ocx update [--tag latest|preview]` Met à jour opencodex depuis npm. Les installations stables utilisent `@latest` ; les préversions restent sur `@preview`, sauf si vous indiquez `--tag latest|preview`. La commande détecte un dépôt de sources et vous invite alors à exécuter `git pull && bun install`. Elle ne fait rien si la version la plus récente correspondant à cette balise est déjà installée. diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 0086e4c239..1279a03713 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -13,13 +13,15 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ カタログの同期、ダッシュボード、および更新。 - [プロバイダー、アカウント、モデル](/reference/cli/providers-accounts/) — プロバイダー構成、 認証、資格情報プール、クォータ、カスタム モデル、可視性、選択されたモデル、およびコンテキストの上限。 -- [エージェント、ルーティング、統合](/reference/cli/agents/) — マルチエージェント コントロール、コンボ、 -可観測性、アドミッション キー、クライアント統合、ランタイム設定、および検証済みの構成。 +- [エージェント、ルーティング、統合](/ja/reference/cli/agents/) — マルチエージェント コントロール、コンボ、 +可観測性、アドミッション キー、クライアント統合、ランタイム設定、検証済みの構成、および Codex CLI 更新の読み取り専用検査。 ## ヘッドレス動作 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 +`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 + リストまたはステータスは、明確なデフォルトです。構造化スナップショットには `--json` を使用し、ストリーミング リクエスト ログ フィードには `ocx observe logs --follow --jsonl` を使用します。テーマ、言語、ナビゲーション、その他の純粋に視覚的なブラウザーの状態には、同等の CLI がありません。 Cloudflare Tunnel のセットアップはこのコマンド セットの外にあります。 ## 終了コードと確認 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index ae395ed8e3..283a861e28 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -173,7 +173,7 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex ## ランタイムと構成 -### `ocx system ...` +### `ocx system ...` ヘッドレス ランタイムの設定、起動、同期、診断、更新を管理します。 @@ -181,6 +181,14 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex ocx system settings --stream-mode eager-relay ``` +`ocx system update` は OpenCodex 自体を更新します。Codex CLI は次の独立した読み取り専用コマンドで検査します。 + +```bash +ocx system codex-cli-update check --json +``` + +`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 + ### `ocx config ...` 検証された OpenCodex 設定を検査し、安全に変更します。 `show` および `get` はシークレットをマスクします。インポートは書き込む前に検証され、`--yes` が必要です。 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 277c78a373..6b307e7b00 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -191,7 +191,7 @@ Windows では、タスク スケジューラ エントリを作成するには アップグレード時には、現在の検証ガードを持たない既存の Unix shim を再生成して検証します。保存済みランチャーが安全でない場合、OpenCodex は危険な wrapper を残さず、古い shim を削除して元のランチャーを復元します。 -完了した外部 Codex アップデートがインストールされている shim を上書きした場合、次の通常の `ocx` コマンドは安定した新しいランチャーをバックアップし、ディスパッチ前に shim を復元します。まだ変更中のランチャーは変更されず、後で再試行されます。修復の失敗は、要求されたコマンドを失敗させることなく警告します。手動フォールバック: `ocx codex-shim install`。 `codexShimAutoRestore` を `false` に設定するか、プロセス レベルのオプトアウトの場合は `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 +完了した外部 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` を設定します。 |サブコマンド |アクション | | --- | --- | @@ -222,6 +222,8 @@ Windows ステータス トレイ アイコンをインストールして制御 ## 更新 +`ocx update` は OpenCodex 自体を更新し、Codex CLI は更新しません。[system 検査コマンド](/ja/reference/cli/agents/)の `ocx system codex-cli-update check` を使用すると、設定済みの Codex CLI 候補の provenance を範囲を限定して読み取り専用で確認できます。このコマンドは package registry に問い合わせず、更新をインストールしません。 + ### `ocx update [--tag latest|preview]` npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index d98118d618..3b384c20f0 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -11,12 +11,14 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s - [라이프사이클](/reference/cli/lifecycle/) — 설정, 프록시와 서비스 라이프사이클, 상태 확인, 진단, 카탈로그 동기화, 대시보드, 업데이트. - [프로바이더, 계정, 모델](/reference/cli/providers-accounts/) — 프로바이더 설정, 인증, 자격 증명 풀, quota, 사용자 지정 모델, 표시 여부, 선택된 모델, 컨텍스트 상한. -- [에이전트, 라우팅, 통합](/reference/cli/agents/) — 다중 에이전트 제어, 조합, 관측성, admission key, 클라이언트 통합, 런타임 설정, 검증된 설정. +- [에이전트, 라우팅, 통합](/ko/reference/cli/agents/) — 다중 에이전트 제어, 조합, 관측성, admission key, 클라이언트 통합, 런타임 설정, 검증된 설정, 읽기 전용 Codex CLI 업데이트 검사. ## 헤드리스 동작 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. +`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. + 뜻이 분명하면 `list`나 `status`가 기본입니다. 구조화된 스냅샷은 `--json`을, 스트리밍 요청 로그 피드는 `ocx observe logs --follow --jsonl`을 사용합니다. 테마, 언어, 내비게이션처럼 순수하게 시각적인 브라우저 상태에는 CLI 대응이 없습니다. Cloudflare Tunnel 설정은 이 명령 집합 밖입니다. ## 종료 코드와 확인 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index e2b77cf713..2490aa628f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -179,7 +179,7 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 ## 런타임과 설정 -### `ocx system ...` +### `ocx system ...` 헤드리스 런타임 설정, 시작, 동기화, 진단, 업데이트를 관리합니다. @@ -187,6 +187,14 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 ocx system settings --stream-mode eager-relay ``` +`ocx system update`는 OpenCodex 자체를 업데이트합니다. Codex CLI는 다음의 별도 읽기 전용 명령으로 점검합니다. + +```bash +ocx system codex-cli-update check --json +``` + +`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. + ### `ocx config ...` 검증된 OpenCodex configuration을 검사하고 안전하게 수정합니다. `show`와 `get`은 비밀 값을 가립니다. import는 쓰기 전에 검증하며 `--yes`가 필요합니다. 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 94c6fd2c00..1eeea82a43 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -256,7 +256,7 @@ PATH 항목이 구체적인 실행 파일 또는 런처를 가리키도록 Codex 런처를 복원합니다. 완료된 외부 Codex 업데이트가 설치된 shim을 덮어쓰면, 다음 일반 `ocx` 명령이 안정적인 새 런처를 -백업하고 명령을 처리하기 전에 shim을 복원합니다. 아직 변경 중인 런처는 건드리지 않고 나중에 다시 시도합니다. +백업하고 명령을 처리하기 전에 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`을 설정합니다. @@ -294,6 +294,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ## 업데이트 +`ocx update`는 OpenCodex 자체를 업데이트하며 Codex CLI를 업데이트하지 않습니다. [system 검사 명령](/ko/reference/cli/agents/)의 `ocx system codex-cli-update check`로 설정된 Codex CLI 후보의 provenance를 제한된 읽기 전용 방식으로 확인할 수 있습니다. 이 명령은 package registry를 조회하거나 업데이트를 설치하지 않습니다. + ### `ocx update [--tag latest|preview]` npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 1ea37d6c44..e39d0cc018 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -24,7 +24,8 @@ opencodex state. authentication, credential pools, quota, custom models, visibility, selected models, and context caps. - [Agents, routing, and integrations](/reference/cli/agents/) — multi-agent controls, combos, - observability, admission keys, client integrations, runtime settings, and validated configuration. + observability, admission keys, client integrations, runtime settings, validated configuration, and + read-only Codex CLI update inspection. ## Headless behavior @@ -34,6 +35,19 @@ is represented as HTTP 503 and produces a nonzero CLI exit. Commands explicitly offline configuration operations can instead validate and edit the config file without a live proxy. +`ocx system codex-cli-update check` needs no live proxy and makes no package-registry request. It +inspects bounded provenance metadata for the configured install candidate, including its redacted +executable location and ownership evidence. Trusted published-launcher context authenticates that candidate snapshot, +not a successful Codex execution. Because this one-shot command never executes Codex, environment and persisted candidates +remain report-only (`managed: false`, normally `selection_unattested`) and `selectionAttested` remains `false`. +The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. +Inspecting the configured candidate requires a trusted published-launcher context; +a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report +`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; +every other Windows candidate fails closed. The command does not install or repair software, execute +Codex or npm, control a running process, or write configuration/cache state. + List or status is the default where unambiguous. Use `--json` for structured snapshots and `ocx observe logs --follow --jsonl` for a streaming request-log feed. Theme, language, navigation, and other purely visual browser state have no CLI equivalent; Cloudflare Tunnel setup is outside diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index f6b2e56e00..290baef6e6 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -296,7 +296,7 @@ the CLI, the API, and the GUI use the same bytes. ## Runtime and configuration -### `ocx system ...` +### `ocx system ...` Manage headless runtime settings, startup, sync, diagnostics, and updates. @@ -304,6 +304,26 @@ Manage headless runtime settings, startup, sync, diagnostics, and updates. ocx system settings --stream-mode eager-relay ``` +`ocx system update` updates OpenCodex itself. The separate Codex CLI inspection surface is: + +```bash +ocx system codex-cli-update check --json +``` + +`check` makes no package-registry request and inspects bounded configured-candidate provenance evidence, +including a redacted executable location and ownership evidence. Trusted published-launcher context authenticates +the candidate snapshot, not successful Codex execution. Because this one-shot command never executes Codex, +environment and persisted candidates remain report-only (`managed: false`, normally `selection_unattested`); +`selectionAttested` remains `false`. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, +and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; +a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report +`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; +every other Windows candidate fails closed. The command does not execute Codex or a package manager, repair a shim, +write configuration or cache state, stop a process, or install anything. App-bundled, recognized +version-manager, unverified standalone, and ambiguous shim states are reported as unmanaged or unknown +and are never classified as managed. + ### `ocx config ...` Inspect and safely modify validated OpenCodex configuration. `show` and `get` mask secrets. Import diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 97182382f9..371f8e3654 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -368,7 +368,10 @@ never print proxy values; resolve the reported handoff and run `ocx doctor` befo 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. A launcher that is still +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 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. @@ -419,6 +422,11 @@ if it is not running. ## Updating +`ocx update` updates OpenCodex itself; it does not update the Codex CLI. Use the +[system inspection commands](/reference/cli/agents/) to inspect the configured Codex CLI candidate +with bounded, read-only provenance inspection. `ocx system codex-cli-update check` does not query a +package registry or install an update. + ### `ocx update [--tag latest|preview]` Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 4e61f3b516..e25eff1afd 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -19,9 +19,9 @@ alias вроде `setup`/`init`, `restore`/`eject` и `models`/`model` прив - [Providers, accounts, and models](/reference/cli/providers-accounts/) — конфигурация провайдеров, аутентификация, credential pool'ы, квоты, custom model'и, видимость, selected model'и и context cap'ы. -- [Agents, routing, and integrations](/reference/cli/agents/) — multi-agent controls, combo, +- [Agents, routing, and integrations](/ru/reference/cli/agents/) — multi-agent controls, combo, observability, admission key, client integration'ы, runtime setting'и и валидированная - конфигурация. + конфигурация, а также read-only инспекция обновления Codex CLI. ## Поведение в headless-режиме @@ -31,6 +31,8 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. +`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. + Там, где это недвусмысленно, `list` или `status` являются действием по умолчанию. Для структурированных снимков используйте `--json`, а для потокового лога запросов — `ocx observe logs --follow --jsonl`. Theme, language, navigation и прочее чисто визуальное diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 6f09a90d04..efaed2a6c1 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -222,7 +222,7 @@ env-reference, либо несекретную loopback-заглушку. Loopba ## Runtime и configuration -### `ocx system ...` +### `ocx system ...` Управляйте headless runtime-setting'ами, startup, sync, diagnostics и update. @@ -230,6 +230,14 @@ env-reference, либо несекретную loopback-заглушку. Loopba ocx system settings --stream-mode eager-relay ``` +`ocx system update` обновляет сам OpenCodex. Для Codex CLI используйте отдельную read-only команду: + +```bash +ocx system codex-cli-update check --json +``` + +`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. + ### `ocx config ...` Проверяйте и безопасно меняйте валидированную конфигурацию OpenCodex. `show` и `get` 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 d165a1574f..5517cb3459 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -275,6 +275,8 @@ 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` или @@ -315,6 +317,8 @@ one-click управление прокси. `start` и `stop` управляю ## Обновление +`ocx update` обновляет сам OpenCodex, а не Codex CLI. Используйте `ocx system codex-cli-update check` из [system-команд инспекции](/ru/reference/cli/agents/) для ограниченной read-only проверки provenance настроенного кандидата Codex CLI. Команда не обращается к package registry и не устанавливает обновление. + ### `ocx update [--tag latest|preview]` Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 83d4d9a33a..a36e60fed7 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -24,7 +24,8 @@ veya yeniden yazmazlar. özel modeller, görünürlük, seçilen modeller ve bağlam sınırları. - [Ajanlar, yönlendirme ve entegrasyonlar](/tr/reference/cli/agents/) — çoklu ajan kontrolleri, kombolar, gözlemlenebilirlik, kabul anahtarları, istemci - entegrasyonları, çalışma zamanı ayarları ve doğrulanmış yapılandırma. + entegrasyonları, çalışma zamanı ayarları, doğrulanmış yapılandırma ve salt + okunur Codex CLI güncelleme denetimi. ## Başsız (Headless) davranış @@ -35,6 +36,8 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. +`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. + Belirsiz olmayan yerlerde liste veya durum varsayılandır. Yapılandırılmış anlık görüntüler için `--json` ve akışlı bir istek günlüğü akışı için `ocx observe logs --follow --jsonl` kullanın. Tema, dil, gezinme ve diğer tamamen görsel @@ -67,5 +70,3 @@ kullanıcıya yönelik komutlar değil, uygulama ayrıntılarıdır. Kontrol pan çalışan PID'sini kaydeder, çalışanı ölen aktif bir işi kurtarır, daha eski PID'siz aktif kayıtları on dakika sonra eski olarak değerlendirir ve canlı bir çalışanı eşzamanlı güncellemelerden korur. - - diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 42f8b9bffc..3cbd9ffdc3 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -267,7 +267,7 @@ sekmesinde işlenir; böylece CLI, API ve GUI aynı baytları kullanır. ## Çalışma zamanı ve yapılandırma -### `ocx system ...` +### `ocx system ...` Başsız çalışma zamanı ayarlarını, başlatmayı, senkronizasyonu, tanılamayı ve güncellemeleri yönetin. @@ -276,6 +276,14 @@ güncellemeleri yönetin. ocx system settings --stream-mode eager-relay ``` +`ocx system update` OpenCodex'in kendisini günceller. Codex CLI için ayrı, salt okunur komutu kullanın: + +```bash +ocx system codex-cli-update check --json +``` + +`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. + ### `ocx config ...` Doğrulanmış OpenCodex yapılandırmasını inceleyin ve güvenle değiştirin. `show` 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 125077b568..aec2d52bd5 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -379,7 +379,8 @@ 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. Hala değişmekte olan bir başlatıcı dokunulmadan +ö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 @@ -420,6 +421,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın. ## Güncelleme +`ocx update`, Codex CLI'yi değil OpenCodex'in kendisini günceller. Yapılandırılmış Codex CLI adayının provenance bilgisini sınırlı ve salt okunur biçimde denetlemek için [sistem denetim komutları](/tr/reference/cli/agents/) arasındaki `ocx system codex-cli-update check` komutunu kullanın. Komut package registry'ye istek göndermez ve güncelleme kurmaz. + ### `ocx update [--tag latest|preview]` opencodex'i npm'den kendi kendine güncelleyin. Kararlı kurulumlar `@latest` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 30a946ffb8..e804df71af 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -11,12 +11,14 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 - [生命周期](/reference/cli/lifecycle/) —— 设置、代理和服务生命周期、健康检查、诊断、目录同步、仪表盘和更新。 - [提供商、账号与模型](/reference/cli/providers-accounts/) —— 提供商配置、认证、凭据池、配额、自定义模型、可见性、已选模型和上下文上限。 -- [代理、路由与集成](/reference/cli/agents/) —— 多代理控制、组合、可观测性、准入密钥、客户端集成、运行时设置和已验证配置。 +- [代理、路由与集成](/zh-cn/reference/cli/agents/) —— 多代理控制、组合、可观测性、准入密钥、客户端集成、运行时设置、已验证配置,以及只读的 Codex CLI 更新检查。 ## 无头行为 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 +`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 + 在语义明确时,默认操作是 `list` 或 `status`。使用 `--json` 获取结构化快照,使用 `ocx observe logs --follow --jsonl` 获取流式请求日志。主题、语言、导航以及其他纯视觉浏览器状态都没有 CLI 对应项;Cloudflare Tunnel 的设置不在这组命令之内。 ## 退出码与确认 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index e535215eed..48563c6a07 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -180,7 +180,7 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi ## Runtime and configuration -### `ocx system ...` +### `ocx system ...` 管理无头运行时设置、启动、同步、诊断和更新。 @@ -188,6 +188,14 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi ocx system settings --stream-mode eager-relay ``` +`ocx system update` 更新 OpenCodex 本身。Codex CLI 使用以下独立的只读检查命令: + +```bash +ocx system codex-cli-update check --json +``` + +`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 + ### `ocx config ...` 检查并安全修改已验证的 OpenCodex 配置。`show` 和 `get` 会隐藏密钥。导入会先验证再写入,并且需要 `--yes`。 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 ff3446c381..5153258b7a 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 @@ -190,7 +190,7 @@ ocx service uninstall 仅安装启动器并不能证明 Codex 请求会经过 OpenCodex。完成健康安装后,命令会检查当前 Codex 路由;当路由由外部配置、用户自有网关管理或无法验证时,会显示警告而不是绿色成功。若出站代理变量只存在于当前进程,而 `config.proxy` 未设置或无法解析,也会给出警告,因为 Codex 启动器和后台服务未必继承该环境。这些检查只读且绝不会打印代理值;在依赖自动启动前,请先处理提示的交接配置并运行 `ocx doctor`。 -如果已完成的外部 Codex 更新覆盖了已安装的 shim,下一次普通的 `ocx` 命令会先备份稳定的新启动器,再在分发前恢复 shim。仍在变动中的启动器会保持不动,并在稍后重试。修复失败只会警告,不会让所请求的命令失败;手动回退:`ocx codex-shim install`。将 `codexShimAutoRestore` 设为 `false`,或设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可在进程级别关闭自动恢复。 +如果已完成的外部 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`,即可在进程级别关闭自动恢复。 | 子命令 | 操作 | | --- | --- | @@ -221,6 +221,8 @@ ocx codex-shim uninstall ## 更新 +`ocx update` 更新的是 OpenCodex 本身,而不是 Codex CLI。请使用 [system 检查命令](/zh-cn/reference/cli/agents/)中的 `ocx system codex-cli-update check`,对已配置的 Codex CLI 候选项进行有界、只读的 provenance 检查。该命令不会查询 package registry,也不会安装更新。 + ### `ocx update [--tag latest|preview]` 从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index b7bc90b61a..81121d4f51 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -19,7 +19,8 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 - [Providers、帳號與模型](/zh-tw/reference/cli/providers-accounts/) — provider 設定、 認證、憑證池、配額、自訂模型、可見性、選定模型與 context 上限。 - [Agents、路由與整合](/zh-tw/reference/cli/agents/) — multi-agent 控制、combos、 - 可觀測性、admission key、用戶端整合、執行環境設定與已驗證的設定。 + 可觀測性、admission key、用戶端整合、執行環境設定、已驗證的設定,以及唯讀的 + Codex CLI 更新檢查。 ## 無頭(headless)行為 @@ -27,6 +28,8 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 +`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 + 沒有歧義時,list 或 status 是預設。使用 `--json` 取得結構化快照,並以 `ocx observe logs --follow --jsonl` 取得串流的請求 log feed。佈景主題、語言、導覽與 其他純視覺的瀏覽器狀態沒有 CLI 對應;Cloudflare Tunnel 設定不在此命令集內。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index e8e7955d67..479a8ec8bf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -183,7 +183,7 @@ Gajae 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑 ## 執行階段與設定 -### `ocx system ...` +### `ocx system ...` 管理無頭執行階段設定、啟動、同步、診斷與更新。 @@ -191,6 +191,14 @@ Gajae 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑 ocx system settings --stream-mode eager-relay ``` +`ocx system update` 更新 OpenCodex 本身。Codex CLI 使用以下獨立唯讀檢查指令: + +```bash +ocx system codex-cli-update check --json +``` + +`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 + ### `ocx config ...` 檢查並安全地修改已驗證的 OpenCodex 設定。`show` 與 `get` 會遮罩秘密。匯入在寫入前驗證且需要 `--yes`。 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 d1a49b3c3b..0c877c25da 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 @@ -193,7 +193,7 @@ ocx service uninstall 在 PATH 上以輕量自動啟動腳本包裝基於腳本的 `codex` 啟動器。真實的 `codex.exe` 目標保持不動,以避免破壞精確的可執行檔呼叫。 -若已完成的外部 Codex 更新覆寫了已安裝的 shim,下一個普通 `ocx` 指令會備份穩定的新啟動器並在分派前還原 shim。仍在變動中的啟動器保持不動並稍後重試。修復失敗會發出警告但不會使請求的指令失敗;手動後備:`ocx codex-shim install`。將 `codexShimAutoRestore` 設為 `false`,或設定 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` 以進行行程層級的退出。 +若已完成的外部 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` 以進行行程層級的退出。 | 子指令 | 動作 | | --- | --- | @@ -224,6 +224,8 @@ ocx codex-shim uninstall ## 更新 +`ocx update` 更新的是 OpenCodex 本身,而不是 Codex CLI。請使用 [system 檢查指令](/zh-tw/reference/cli/agents/)中的 `ocx system codex-cli-update check`,對已設定的 Codex CLI 候選項進行有界、唯讀的 provenance 檢查。此命令不會查詢 package registry,也不會安裝更新。 + ### `ocx update [--tag latest|preview]` 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 3a5a2ad989..eea5f96421 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -300,6 +300,23 @@ JSON mode: `payload`. - The GUI reads this state directly; without a verb an agent could not tell whether the Codex app-server was reachable at all. +### `ocx system codex-cli-update check` + +Inspect a configured Codex CLI candidate and its ownership provenance. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the redacted provenance report as JSON. | + +JSON mode: `envelope`. + +- Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; this check does not attest or admit a selected runtime. +- On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed. +- Makes no package-registry request. +- Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state. + ### `ocx claude desktop status` Applied-vs-desired Claude Desktop state, including staleness, drift, and health. @@ -530,6 +547,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 29 +- declared capabilities: 30 - of those, state-changing: 11 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 76b85e0690..e1569bf467 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -418,6 +418,20 @@ export const CAPABILITIES: readonly Capability[] = [ "The GUI reads this state directly; without a verb an agent could not tell whether the Codex app-server was reachable at all.", ], }, + { + command: ["system", "codex-cli-update", "check"], + summary: "Inspect a configured Codex CLI candidate and its ownership provenance.", + routes: [], + flags: [{ name: "--json", value: "boolean", summary: "Emit the redacted provenance report as JSON." }], + mutates: false, + json: "envelope", + details: [ + "Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; this check does not attest or admit a selected runtime.", + "On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed.", + "Makes no package-registry request.", + "Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.", + ], + }, { command: ["system", "codex-restart"], summary: "Restart the Codex app-server.", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts new file mode 100644 index 0000000000..e184fb73e4 --- /dev/null +++ b/src/cli/codex-cli-update.ts @@ -0,0 +1,96 @@ +import { + inspectCodexCliInstall, + type CodexCliInstallProvenanceDeps, + type CodexCliInstallReport, +} from "../codex/cli-install-provenance"; +import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; +import { trustedNodeLauncherContext } from "./launcher-context"; + +export const CODEX_CLI_UPDATE_USAGE = `Usage: + ocx system codex-cli-update check [--json]`; + +export type ParsedCodexCliUpdateArgs = Readonly<{ + json: boolean; +}>; + +export interface CodexCliUpdateCommandDeps { + readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise; +} + +function installSummary(report: CodexCliInstallReport): string[] { + return [ + `candidate: ${report.candidateAvailable ? "yes" : "no"}`, + `candidate-source: ${report.candidateSource ?? "unavailable"}`, + `selection-attested: ${report.selectionAttested ? "yes" : "no"}`, + `provenance: ${report.provenance}`, + `managed: ${report.managed ? "yes" : "no"}`, + `reason: ${report.reason}`, + `candidate-version: ${report.candidateVersion ?? "unavailable"}`, + `package-version: ${report.packageVersion ?? "unavailable"}`, + `version-evidence: ${report.versionEvidence.kind}`, + `location: ${report.location ?? "unavailable"}`, + `shim: ${report.shim.status}${report.shim.backingKind ? `/${report.shim.backingKind}` : ""}`, + ]; +} + +export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCliUpdateArgs { + // `--json` is accepted in any argv position CLI-wide, so remove it before positional + // validation. Requiring `check` at index 0 first would reject `--json check`, which + // automation that puts output flags ahead of the subcommand legitimately produces. + let json = false; + const positional: string[] = []; + for (const token of argv) { + if (isJsonOption(token)) { + if (json) throw new CliUsageError("--json may be specified only once", CODEX_CLI_UPDATE_USAGE); + json = true; + continue; + } + positional.push(token); + } + if (positional[0] !== "check") { + throw new CliUsageError("codex-cli-update action must be check", CODEX_CLI_UPDATE_USAGE); + } + if (positional.length > 1) { + throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ json }); +} + +export async function handleCodexCliUpdateCommand( + argv: readonly string[], + deps: CodexCliUpdateCommandDeps = {}, +): Promise { + let parsed: ParsedCodexCliUpdateArgs; + try { + parsed = parseCodexCliUpdateArgs(argv); + } catch (error) { + if (error instanceof CliUsageError) { + console.error(`Error: ${error.message}`); + console.error(error.usage ?? CODEX_CLI_UPDATE_USAGE); + return 2; + } + throw error; + } + return runCliAction(async () => { + const trustedInspectionEnv = trustedNodeLauncherContext()?.codexCliInspectionEnv; + const inspectionDeps: CodexCliInstallProvenanceDeps = trustedInspectionEnv + && trustedInspectionEnv.managerRoots !== null ? { + env: { + ...trustedInspectionEnv.managerRoots, + CODEX_CLI_PATH: trustedInspectionEnv.codexCliPath ?? undefined, + PATH: trustedInspectionEnv.path ?? undefined, + PATHEXT: trustedInspectionEnv.pathExt ?? undefined, + }, + configDir: trustedInspectionEnv.configDir, + // This is a fresh one-shot CLI process. Its proof-bound launcher snapshot + // supplies configured candidate evidence, not selected-runtime admission. + } : { + // Direct Bun/source launches have no pre-dotenv proof. Do not inspect + // ambient or persisted candidate state at all. + env: { PATH: "" }, + configDir: ".", + }; + const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps); + printData(report, parsed.json, installSummary(report)); + }); +} diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index b41e509e86..87b59feefb 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -19,6 +19,9 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str if (command === "uninstall" || command === "remove") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; + // The entire updater-inspection namespace is zero-effect, including malformed + // or future actions. A later `apply` implementation must own its preflight. + if (command === "system" && args[1] === "codex-cli-update") return true; return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? ""); } diff --git a/src/cli/help.ts b/src/cli/help.ts index be3d7a8995..a7a79fdf98 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -76,7 +76,7 @@ Usage: ocx export --client Print a client config wired to the running proxy (11 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply - ocx system Runtime settings, startup, sync, and updates + ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection ocx config Validated configuration show/get/set/import/export ocx lab Read-only Compatibility Lab projection inspection ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts index 7395a9677d..091e46d3a9 100644 --- a/src/cli/launcher-context.ts +++ b/src/cli/launcher-context.ts @@ -14,9 +14,19 @@ export const ANTHROPIC_PARENT_ENV_SLOTS = [ ] as const; export type AnthropicParentEnvSlot = typeof ANTHROPIC_PARENT_ENV_SLOTS[number]; +export type CodexCliVersionManagerRootEnvSlot = typeof CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS[number]; + +type CodexCliVersionManagerRoots = Readonly>>; export type TrustedNodeLaunchContext = { anthropicEnvSlots: readonly AnthropicParentEnvSlot[]; + codexCliInspectionEnv: Readonly<{ + codexCliPath: string | null; + path: string | null; + pathExt: string | null; + managerRoots: CodexCliVersionManagerRoots | null; + configDir: string; + }> | null; }; let trustedContext: TrustedNodeLaunchContext | null = null; @@ -25,6 +35,21 @@ function isLaunchProof(value: string): boolean { return /^[A-Za-z0-9_-]{43}$/.test(value); } +function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots | null | undefined { + // Missing means an older launcher. Preserve compatibility for unrelated + // commands, but updater inspection treats the incomplete snapshot as + // untrusted and fails closed. + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const allowed = new Set(CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS); + const entries = Object.entries(value); + if (entries.length > allowed.size || entries.some(([name, root]) => + !allowed.has(name) || typeof root !== "string" || root.length === 0 || root.length > 32 * 1024)) { + return undefined; + } + return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots; +} + /** Consume the internal proof before normal CLI argument parsing. */ export function initializeNodeLauncherContext( argv: string[] = process.argv, @@ -45,7 +70,7 @@ export function initializeNodeLauncherContext( delete env.OCX_PRE_BUN_ANTHROPIC_ENV; trustedContext = null; - if (proofArgs.length !== 1 || !raw || raw.length > 2048) return null; + if (proofArgs.length !== 1 || !raw || raw.length > 64 * 1024) return null; const proof = proofArgs[0]!; if (!isLaunchProof(proof)) return null; @@ -54,6 +79,7 @@ export function initializeNodeLauncherContext( version?: unknown; proof?: unknown; anthropicEnvSlots?: unknown; + codexCliInspectionEnv?: unknown; }; if (parsed.version !== 1 || parsed.proof !== proof || !Array.isArray(parsed.anthropicEnvSlots)) { return null; @@ -65,7 +91,31 @@ export function initializeNodeLauncherContext( if (slots.length !== parsed.anthropicEnvSlots.length || new Set(slots).size !== slots.length) { return null; } - trustedContext = { anthropicEnvSlots: slots }; + const inspection = parsed.codexCliInspectionEnv; + const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection) + ? parseVersionManagerRoots((inspection as Record).managerRoots) + : null; + const codexCliInspectionEnv = inspection === null || inspection === undefined + ? null + : inspection && typeof inspection === "object" && !Array.isArray(inspection) + && ["codexCliPath", "path", "pathExt"].every(key => { + const value = (inspection as Record)[key]; + return value === null || typeof value === "string"; + }) + && typeof (inspection as Record).configDir === "string" + && (inspection as Record).configDir.length > 0 + && (inspection as Record).configDir.length <= 32 * 1024 + && managerRoots !== undefined + ? Object.freeze({ + codexCliPath: (inspection as Record).codexCliPath ?? null, + path: (inspection as Record).path ?? null, + pathExt: (inspection as Record).pathExt ?? null, + managerRoots, + configDir: (inspection as Record).configDir, + }) + : undefined; + if (codexCliInspectionEnv === undefined) return null; + trustedContext = { anthropicEnvSlots: slots, codexCliInspectionEnv }; return trustedContext; } catch { return null; @@ -75,3 +125,4 @@ export function initializeNodeLauncherContext( export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null { return trustedContext; } +import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs"; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 7719f8d5cf..004992e87f 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -268,8 +268,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "system", - usage: "ocx system ...", - summary: "Manage headless runtime settings, startup, sync, diagnostics, and updates.", + usage: "ocx system ...", + summary: "Manage headless runtime settings, startup, sync, diagnostics, OpenCodex updates, and read-only Codex CLI inspection.", + details: [ + "system update manages OpenCodex itself.", + "ocx system codex-cli-update check [--json]", + "The Codex CLI inspection command makes no package-registry request, does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.", + ], }, { name: "config", diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index 8babcd3e03..34e69e7975 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -19,6 +19,7 @@ const USAGE = `Usage: ocx system sync [--json] ocx system codex-app-server [--json] ocx system codex-restart --yes [--json] + ocx system codex-cli-update check [--json] ocx system update check [--channel ] [--json] ocx system update run [--channel ] [--restart ] --yes [--json] ocx system update status [--json]`; @@ -95,8 +96,12 @@ async function update(argv: string[], deps: RuntimeApiDeps): Promise { } export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const [sub = "status", ...rest] = argv; + if (sub === "codex-cli-update") { + const { handleCodexCliUpdateCommand } = await import("./codex-cli-update"); + return await handleCodexCliUpdateCommand(rest); + } return runCliAction(async () => { - const [sub = "status", ...rest] = argv; if (sub === "status") await status(rest, deps); else if (sub === "settings") await settings(rest, deps); else if (sub === "startup") await startup(rest, deps); diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts new file mode 100644 index 0000000000..ffca581f5f --- /dev/null +++ b/src/codex/cli-install-provenance.ts @@ -0,0 +1,795 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { posix, win32 } from "node:path"; +import { getConfigDir } from "../config"; +import { parseStrictSemver } from "../lib/strict-semver"; +import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs"; +import { isSpawnableCodexCandidate } from "./exec-invocation"; +import { + codexRuntimeStatePath, + parsePersistedCodexRuntime, +} from "./runtime"; +import { + inspectCodexShimBackingForCommand, + isLocalAbsoluteInspectionPath, + isVersionManagerOwnedCodexPath, + type CodexShimBackingForCommand, +} from "./shim"; + +const CODEX_PACKAGE = "@openai/codex"; +const MAX_MANIFEST_BYTES = 256 * 1024; +const MAX_RUNTIME_STATE_BYTES = 256 * 1024; +const MAX_MANIFEST_ANCESTORS = 12; + +export type CodexCliInstallKind = + | "npm-global" + | "app-bundle" + | "version-manager" + | "standalone-unverified" + | "unknown"; + +export type CodexCliInstallReason = + | "candidate_unavailable" + | "candidate_path_unavailable" + | "candidate_path_unsafe" + | "windows_inspection_deferred" + | "shim_state_unknown" + | "shim_update_deferred" + | "app_bundle" + | "version_manager_owned" + | "npm_global_unverified" + | "selection_unattested" + | "version_mismatch" + | "unverified_standalone" + | "inspection_failed"; + +export type CodexCliCandidateSource = "environment" | "persisted"; +export type CodexCliInstallEvidence = + | "canonical_path" + | "app_bundle_path" + | "version_manager_path" + | "package_manifest" + | "package_manifest_digest" + | "shim_backing" + | "global_npm_layout"; + +export interface ReadOnlyCodexRuntimeCandidate { + readonly command: string; + readonly version: string | null; + readonly evidence: CodexCliCandidateSource; +} + +export interface CodexCliInstallReport { + readonly schemaVersion: 1; + readonly candidateAvailable: boolean; + readonly candidateVersion: string | null; + readonly candidateSource: CodexCliCandidateSource | null; + readonly selectionAttested: boolean; + readonly versionEvidence: Readonly<{ + kind: "package-manifest" | "advisory-runtime" | "unavailable"; + }>; + readonly provenance: CodexCliInstallKind; + readonly managed: boolean; + readonly reason: CodexCliInstallReason; + readonly location: string | null; + readonly packageVersion: string | null; + readonly shim: Readonly<{ + status: "not-tracked" | "matched" | "unknown"; + backingKind: "backup" | "real" | null; + }>; + readonly evidence: readonly CodexCliInstallEvidence[]; +} + +export interface CodexCliInstallProvenanceDeps { + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly configDir?: string; + readonly exists?: (path: string) => boolean; + readonly lstat?: typeof lstatSync; + readonly stat?: typeof statSync; + readonly readFile?: (path: string) => Buffer; + readonly boundedFileReadMode?: "native-hardened" | "injected-test"; + readonly realpath?: (path: string) => string; + readonly inspectShim?: ( + command: string, + platform: NodeJS.Platform, + configDir: string, + ) => CodexShimBackingForCommand; +} + +interface PackageManifestEvidence { + readonly path: string; + readonly root: string; + readonly binPath: string; + readonly version: string; + readonly digest: string; +} + +function sha256(domain: string, value: string | Uint8Array): string { + return createHash("sha256") + .update(domain, "utf8") + .update("\0", "utf8") + .update(value) + .digest("hex"); +} + +function validatedVersion(value: string | null | undefined): string | null { + if (typeof value !== "string" || value !== value.trim()) return null; + return parseStrictSemver(value, 96)?.raw ?? null; +} + +function publicExecutableLocation(path: string, platform: NodeJS.Platform): string { + const raw = pathTools(platform).basename(path).toLowerCase(); + const safe = ["codex", "codex.exe", "codex.cmd", "codex.bat", "codex.com", "codex.js"].includes(raw) + ? raw : "codex"; + return `/${safe}`; +} + +function freezeReport(report: CodexCliInstallReport): CodexCliInstallReport { + Object.freeze(report.shim); + Object.freeze(report.evidence); + return Object.freeze(report); +} + +function unknownReport( + reason: CodexCliInstallReason, + candidate?: ReadOnlyCodexRuntimeCandidate, + extra: Partial> = {}, +): CodexCliInstallReport { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: Boolean(candidate), + candidateVersion: candidate?.version ?? null, + candidateSource: candidate?.evidence ?? null, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate?.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "unknown", + managed: false, + reason, + location: extra.location ?? null, + packageVersion: null, + shim: Object.freeze({ status: "not-tracked", backingKind: null }), + evidence: Object.freeze([]), + }); +} + +function unknownWindowsReport( + reason: CodexCliInstallReason, + candidate?: ReadOnlyCodexRuntimeCandidate, + extra: Partial> = {}, +): CodexCliInstallReport { + return freezeReport({ + ...unknownReport(reason, candidate, extra), + shim: Object.freeze({ status: "unknown", backingKind: null }), + }); +} + +function readPersistedCandidate( + deps: CodexCliInstallProvenanceDeps, +): ReadOnlyCodexRuntimeCandidate | null { + // A pathname-only Windows read cannot prove that a writable ancestor stayed + // local and non-reparse between validation and open. PR1 therefore accepts + // only the proof-captured environment candidate on Windows; persisted-state + // inspection requires the later handle-bound Windows provenance layer. + if ((deps.platform ?? process.platform) === "win32") return null; + const configDir = deps.configDir ?? getConfigDir(); + if (!isSafeLocalInspectionPath(configDir, deps)) return null; + try { + const statePath = codexRuntimeStatePath(configDir); + const bytes = readBoundedFile(statePath, MAX_RUNTIME_STATE_BYTES, deps); + if (!bytes) return null; + const parsed = parsePersistedCodexRuntime( + bytes, + ); + if (!parsed) return null; + return { + command: parsed.command, + version: validatedVersion(parsed.selectedVersion), + evidence: "persisted", + }; + } catch { + return null; + } +} + +/** + * Observe configured candidate evidence without launching Codex, creating a + * probe home, or persisting a replacement selection. + */ +export function observeCodexRuntimeCandidateReadOnly( + deps: CodexCliInstallProvenanceDeps = {}, +): ReadOnlyCodexRuntimeCandidate | null { + const env = deps.env ?? process.env; + const configured = env.CODEX_CLI_PATH?.trim(); + if (configured) { + return { + command: configured, + version: null, + evidence: "environment", + }; + } + return readPersistedCandidate(deps); +} + +function pathTools(platform: NodeJS.Platform): typeof posix | typeof win32 { + return platform === "win32" ? win32 : posix; +} + +function isWindowsPlatform(platform: NodeJS.Platform): boolean { + return platform === "win32"; +} + +function isSafeLocalInspectionPath( + path: string, + deps: CodexCliInstallProvenanceDeps, +): boolean { + const platform = deps.platform ?? process.platform; + return isLocalAbsoluteInspectionPath(path, platform); +} + +function caseInsensitiveEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { + const entry = Object.entries(env).find(([key]) => key.toLowerCase() === name.toLowerCase()); + return entry?.[1]; +} + +function resolveCandidateCommandPath( + command: string, + deps: CodexCliInstallProvenanceDeps, +): string | null { + const platform = deps.platform ?? process.platform; + // Windows inspection returns before this resolver until the next slice can + // bind command resolution and wrapper reads to stable filesystem handles. + if (isWindowsPlatform(platform)) return null; + const exists = deps.exists ?? existsSync; + const lstat = deps.lstat ?? lstatSync; + const stat = deps.stat ?? statSync; + const usable = (path: string): boolean => { + if (!isSafeLocalInspectionPath(path, deps)) return false; + try { + const entry = lstat(path); + if (!exists(path) || (!entry.isFile() && !entry.isSymbolicLink()) || !isSpawnableCodexCandidate(path, platform)) return false; + if (platform !== "win32") { + const target = stat(path); + if (!target.isFile() || (target.mode & 0o111) === 0) return false; + } + return true; + } catch { + return false; + } + }; + const env = deps.env ?? process.env; + const tools = pathTools(platform); + const explicit = tools.isAbsolute(command) || command.includes("/") || command.includes("\\"); + if (explicit) return usable(command) ? command : null; + const pathValue = env.PATH ?? ""; + const names = [command]; + for (const entry of pathValue.split(tools.delimiter)) { + // Empty and relative entries name the current working directory. Either can + // shadow a later absolute hit and therefore makes the candidate path unknown. + if (!entry || !isSafeLocalInspectionPath(entry, deps)) return null; + for (const name of names) { + const candidate = tools.join(entry, name); + if (usable(candidate)) return candidate; + } + } + return null; +} + +function canonicalize(path: string, deps: CodexCliInstallProvenanceDeps): string | null { + if (!isSafeLocalInspectionPath(path, deps)) return null; + try { + const canonical = (deps.realpath ?? realpathSync.native)(path); + return isSafeLocalInspectionPath(canonical, deps) ? canonical : null; + } catch { + return null; + } +} + +function normalizePath(path: string, platform: NodeJS.Platform): string { + const slashNormalized = platform === "win32" ? path.replace(/\\/g, "/") : path; + const normalized = platform !== "win32" && /^\/+$/u.test(slashNormalized) + ? "/" + : slashNormalized.replace(/\/+$/, ""); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { + return normalizePath(left, platform) === normalizePath(right, platform); +} + +export function isAppBundledCodexPath(path: string, platform: NodeJS.Platform): boolean { + const normalized = normalizePath(path, platform); + if (platform === "win32") { + return normalized.includes("/windowsapps/") + || normalized.includes("/microsoft/windowsapps/") + || normalized.includes("/packages/openai.codex_"); + } + if (platform === "darwin") return /[.]app\/contents\//i.test(normalized); + return normalized.startsWith("/snap/") || normalized.includes("/flatpak/app/"); +} + +/** Updater ownership is intentionally broader than shim-repair refusal. */ +export function isCodexCliUpdateVersionManagerPath( + path: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const normalized = (platform === "win32" + ? win32.normalize(path).replace(/\\/g, "/") + : posix.normalize(path)).toLowerCase(); + if (isVersionManagerOwnedCodexPath(normalized, platform)) return true; + return normalized.includes("/.nvm/") + || normalized.includes("/nvm/versions/") + || /\/nvm\/v?\d+(?:[.]\d+){1,2}(?:\/|$)/.test(normalized) + || normalized.includes("/.proto/") + || normalized.includes("/proto/tools/") + || normalized.includes("/.nodenv/") + || normalized.includes("/nodenv/versions/") + || normalized.includes("/.nvs/") + || normalized.includes("/nvs/node/") + || normalized.includes("/.fnm/") + || normalized.includes("/fnm/node-versions/") + || normalized.includes("/fnm_multishells/") + || (platform === "win32" && ( + normalized.includes("/scoop/apps/") + || normalized.includes("/scoop/shims/") + )); +} + +function configuredVersionManagerRoots( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + deps: CodexCliInstallProvenanceDeps, +): readonly string[] { + const roots: string[] = []; + for (const name of CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS) { + const raw = platform === "win32" ? caseInsensitiveEnv(env, name) : env[name]; + if (!raw || !isLocalAbsoluteInspectionPath(raw, platform)) continue; + // Windows roots are advisory lexical labels only in this first slice. Do + // not resolve or open them until the handle-bound Windows layer exists. + if (platform === "win32") { + roots.push(normalizePath(win32.normalize(raw), platform)); + continue; + } + const canonical = isSafeLocalInspectionPath(raw, deps) ? canonicalize(raw, deps) : null; + if (canonical) roots.push(normalizePath(canonical, platform)); + } + return Object.freeze([...new Set(roots)]); +} + +function isWithinConfiguredVersionManagerRoot(path: string, roots: readonly string[], platform: NodeJS.Platform): boolean { + const candidate = normalizePath(path, platform); + return roots.some(root => { + const filesystemRoot = root === "/" || (platform === "win32" && /^[a-z]:$/i.test(root)); + return candidate === root || (!filesystemRoot && candidate.startsWith(`${root}/`)); + }); +} + +function readBoundedFile( + path: string, + maxBytes: number, + deps: CodexCliInstallProvenanceDeps, +): Buffer | null { + if (!isSafeLocalInspectionPath(path, deps)) return null; + // Production inspection accepts only a direct regular file. This prevents a + // persisted-state or manifest symlink from silently redirecting a nominally + // local check. Virtual filesystem tests may omit lstat and retain their + // injected stat/read behavior. + const inspectLexical = deps.lstat + ?? (deps.stat === undefined && deps.readFile === undefined ? lstatSync : null); + let lexicalBefore: ReturnType | null = null; + if (inspectLexical) { + try { + lexicalBefore = inspectLexical(path); + if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) return null; + } catch { + return null; + } + } + const useInjectedReader = deps.boundedFileReadMode === "injected-test"; + if (!useInjectedReader) { + let fd: number | null = null; + try { + const flags = process.platform === "win32" + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + fd = openSync(path, flags); + const before = fstatSync(fd); + if (!before.isFile() || before.size > maxBytes) return null; + const bytes = Buffer.allocUnsafe(before.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(fd, bytes, offset, bytes.length - offset, offset); + if (count <= 0) return null; + offset += count; + } + const extra = Buffer.allocUnsafe(1); + if (readSync(fd, extra, 0, 1, offset) !== 0) return null; + const after = fstatSync(fd); + const lexicalAfter = inspectLexical ? inspectLexical(path) : null; + if (lexicalAfter?.isSymbolicLink()) return null; + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + || (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink() + || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino + || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs + || lexicalAfter.ctimeMs !== after.ctimeMs))) return null; + return bytes; + } catch { + return null; + } finally { + if (fd !== null) closeSync(fd); + } + } + if (!deps.stat || !deps.readFile) return null; + const stat = deps.stat; + const read = deps.readFile; + try { + const before = stat(path); + if (!before.isFile() || before.size > maxBytes) return null; + const bytes = read(path); + const after = stat(path); + const lexicalAfter = inspectLexical ? inspectLexical(path) : null; + if ( + bytes.byteLength !== before.size + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeMs !== after.mtimeMs + || (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink() + || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino)) + ) return null; + return bytes; + } catch { + return null; + } +} + +function manifestCandidates( + logicalPath: string, + canonicalPath: string, + platform: NodeJS.Platform, +): string[] { + const tools = pathTools(platform); + const candidates: string[] = []; + let cursor = tools.dirname(canonicalPath); + for (let depth = 0; depth < MAX_MANIFEST_ANCESTORS; depth += 1) { + candidates.push(tools.join(cursor, "package.json")); + const parent = tools.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + const binDir = tools.dirname(logicalPath); + if (platform === "win32") { + candidates.push(tools.join(binDir, "node_modules", "@openai", "codex", "package.json")); + } else { + const prefix = tools.dirname(binDir); + candidates.push(tools.join(prefix, "lib", "node_modules", "@openai", "codex", "package.json")); + candidates.push(tools.join(prefix, "node_modules", "@openai", "codex", "package.json")); + } + return [...new Set(candidates)]; +} + +function manifestBinPath(bin: unknown): string | null { + const raw = typeof bin === "string" + ? bin + : bin && typeof bin === "object" && !Array.isArray(bin) + ? (bin as Record).codex + : null; + if (typeof raw !== "string") return null; + const normalized = raw.replace(/\\/g, "/").replace(/^\.\//, ""); + return normalized === "bin/codex.js" ? normalized : null; +} + +function findCodexPackageManifest( + logicalPath: string, + canonicalPath: string, + deps: CodexCliInstallProvenanceDeps, +): PackageManifestEvidence | null { + const platform = deps.platform ?? process.platform; + for (const candidate of manifestCandidates(logicalPath, canonicalPath, platform)) { + const bytes = readBoundedFile(candidate, MAX_MANIFEST_BYTES, deps); + if (!bytes) continue; + try { + const value = JSON.parse(bytes.toString("utf8")) as Record; + const version = validatedVersion(typeof value.version === "string" ? value.version : null); + if (value.name !== CODEX_PACKAGE || !version) continue; + const binPath = manifestBinPath(value.bin); + if (!binPath) continue; + const root = canonicalize(pathTools(platform).dirname(candidate), deps); + const path = canonicalize(candidate, deps); + if (!root || !path) continue; + return { + path, + root, + binPath, + version, + digest: sha256("codex-cli-package-manifest-v1", bytes), + }; + } catch { + continue; + } + } + return null; +} + +function launcherIsLinkedToManifest( + canonicalOwnershipPath: string, + manifest: PackageManifestEvidence, + deps: CodexCliInstallProvenanceDeps, +): boolean { + const platform = deps.platform ?? process.platform; + const tools = pathTools(platform); + const entrypoint = canonicalize(tools.join(manifest.root, ...manifest.binPath.split("/")), deps); + if (!entrypoint) return false; + const normalizedRoot = normalizePath(manifest.root, platform); + const normalizedEntrypoint = normalizePath(entrypoint, platform); + if (!normalizedEntrypoint.startsWith(`${normalizedRoot}/`)) return false; + if (samePath(canonicalOwnershipPath, entrypoint, platform)) return true; + return false; +} + +function isProvenGlobalNpmLayout( + launcherPath: string, + packageRoot: string, + platform: NodeJS.Platform, + deps: CodexCliInstallProvenanceDeps, +): boolean { + const tools = pathTools(platform); + const launcherName = tools.basename(launcherPath).toLowerCase(); + if (launcherName !== (platform === "win32" ? "codex.cmd" : "codex")) return false; + const root = normalizePath(packageRoot, platform); + // Keep the POSIX launcher itself lexical because npm commonly installs it as + // a symlink into the package. Canonicalize only its parent so a symlinked or + // case-aliased npm prefix is compared against the canonical manifest root. + const launcherParent = tools.dirname(launcherPath); + const canonicalLauncherParent = platform === "win32" + ? launcherParent + : canonicalize(launcherParent, deps); + if (!canonicalLauncherParent) return false; + const launcherDir = normalizePath(canonicalLauncherParent, platform); + const suffix = "/node_modules/@openai/codex"; + if (!root.endsWith(suffix)) return false; + const beforeNodeModules = root.slice(0, -suffix.length); + if (platform === "win32") return launcherDir === beforeNodeModules; + if (!beforeNodeModules.endsWith("/lib")) return false; + const prefix = beforeNodeModules.slice(0, -"/lib".length); + return launcherDir === `${prefix}/bin`; +} + +function shimReport(shim: CodexShimBackingForCommand): CodexCliInstallReport["shim"] { + if (shim.status === "matched") { + return Object.freeze({ status: "matched" as const, backingKind: shim.backingKind }); + } + if (shim.status === "unknown") { + return Object.freeze({ status: "unknown" as const, backingKind: null }); + } + return Object.freeze({ status: "not-tracked" as const, backingKind: null }); +} + +/** Inspect ownership of one configured Codex CLI candidate, without mutation. */ +export async function inspectCodexCliInstall( + deps: CodexCliInstallProvenanceDeps = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const candidate = observeCodexRuntimeCandidateReadOnly(deps); + if (!candidate) { + return isWindowsPlatform(platform) + ? unknownWindowsReport("candidate_unavailable") + : unknownReport("candidate_unavailable"); + } + + const env = deps.env ?? process.env; + if (platform === "win32") { + // This first slice never opens a candidate-controlled Windows pathname. + // A boolean precheck followed by lstat/realpath/open is raceable when an + // ancestor can be replaced with a remote reparse point. Preserve only + // lexical, report-only classifications until a handle-bound inspector is + // introduced; every result remains unattested and unmanaged. + const lexicalCandidatePath = win32.isAbsolute(candidate.command) + && isLocalAbsoluteInspectionPath(candidate.command, platform) + ? win32.normalize(candidate.command) + : null; + if (!lexicalCandidatePath) { + return unknownWindowsReport("candidate_path_unavailable", candidate); + } + const managerRoots = configuredVersionManagerRoots(env, platform, deps); + const appBundle = isAppBundledCodexPath(lexicalCandidatePath, platform); + const versionManager = isCodexCliUpdateVersionManagerPath(lexicalCandidatePath, platform) + || isWithinConfiguredVersionManagerRoot(lexicalCandidatePath, managerRoots, platform); + if (appBundle || versionManager) { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: appBundle ? "app-bundle" : "version-manager", + managed: false, + reason: appBundle ? "app_bundle" : "version_manager_owned", + location: publicExecutableLocation(lexicalCandidatePath, platform), + packageVersion: null, + shim: Object.freeze({ status: "unknown", backingKind: null }), + evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]), + }); + } + return unknownWindowsReport("windows_inspection_deferred", candidate, { + location: publicExecutableLocation(lexicalCandidatePath, platform), + }); + } + const configDir = deps.configDir ?? getConfigDir(); + if (!isSafeLocalInspectionPath(configDir, deps)) { + return unknownReport("shim_state_unknown", candidate); + } + const candidatePath = resolveCandidateCommandPath(candidate.command, deps); + if (!candidatePath) { + return unknownReport("candidate_path_unavailable", candidate); + } + const canonicalCandidatePath = canonicalize(candidatePath, deps); + if (!canonicalCandidatePath) { + return unknownReport("candidate_path_unsafe", candidate); + } + const inspectShim = deps.inspectShim ?? inspectCodexShimBackingForCommand; + const shim = inspectShim(candidatePath, platform, configDir); + if (shim.status === "unknown") { + return freezeReport({ + ...unknownReport("shim_state_unknown", candidate, { + location: publicExecutableLocation(canonicalCandidatePath, platform), + }), + shim: shimReport(shim), + }); + } + if (shim.status === "matched") { + return freezeReport({ + ...unknownReport("shim_update_deferred", candidate, { + location: publicExecutableLocation(canonicalCandidatePath, platform), + }), + provenance: "standalone-unverified", + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path", "shim_backing"] as const), + }); + } + + const ownershipPath = candidatePath; + const canonicalOwnershipPath = canonicalize(ownershipPath, deps); + if (!canonicalOwnershipPath) { + return unknownReport("candidate_path_unsafe", candidate); + } + const location = publicExecutableLocation(canonicalCandidatePath, platform); + const canonicalPathSet = [canonicalCandidatePath, canonicalOwnershipPath]; + if (canonicalPathSet.some(path => isAppBundledCodexPath(path, platform))) { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "app-bundle", + managed: false, + reason: "app_bundle", + location, + packageVersion: null, + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path", "app_bundle_path"]), + }); + } + const configuredManagerRoots = configuredVersionManagerRoots(env, platform, deps); + if (canonicalPathSet.some(path => isCodexCliUpdateVersionManagerPath(path, platform) + || isWithinConfiguredVersionManagerRoot(path, configuredManagerRoots, platform))) { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "version-manager", + managed: false, + reason: "version_manager_owned", + location, + packageVersion: null, + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path", "version_manager_path"]), + }); + } + if (/codex[.]opencodex-real(?:[.](?:cmd|bat|exe))?$/i.test(pathTools(platform).basename(candidatePath))) { + return freezeReport({ + ...unknownReport("shim_state_unknown", candidate, { location }), + shim: shimReport(shim), + }); + } + + const manifest = findCodexPackageManifest(ownershipPath, canonicalOwnershipPath, deps); + if (manifest) { + // POSIX npm launchers are commonly symlinks into the package, so their + // lexical prefix is the ownership evidence instead. + const global = isProvenGlobalNpmLayout( + ownershipPath, + manifest.root, + platform, + deps, + ); + const linked = launcherIsLinkedToManifest( + canonicalOwnershipPath, + manifest, + deps, + ); + const manifestOwned = global && linked; + const versionMatches = candidate.version === null || candidate.version === manifest.version; + const reason: CodexCliInstallReason = !global || !linked + ? "npm_global_unverified" + : !versionMatches ? "version_mismatch" : "selection_unattested"; + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: manifestOwned && candidate.version !== null && versionMatches + ? "package-manifest" as const + : candidate.version !== null ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: manifestOwned ? "npm-global" : "standalone-unverified", + managed: false, + reason, + location, + packageVersion: manifest.version, + shim: shimReport(shim), + evidence: Object.freeze([ + "canonical_path", "package_manifest", "package_manifest_digest", + ...(manifestOwned ? ["global_npm_layout" as const] : []), + ]), + }); + } + + const stat = deps.stat ?? statSync; + if (!isSafeLocalInspectionPath(canonicalOwnershipPath, deps)) { + return unknownReport("candidate_path_unsafe", candidate); + } + try { + if (!stat(canonicalOwnershipPath).isFile()) { + return unknownReport("candidate_path_unsafe", candidate); + } + } catch { + return unknownReport("inspection_failed", candidate); + } + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "standalone-unverified", + managed: false, + reason: "unverified_standalone", + location, + packageVersion: null, + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path"]), + }); +} diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 4f2ba3a6db..5d64d0dbbe 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { basename, delimiter, dirname, extname, join, posix } from "node:path"; +import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path"; import { chmodSync, closeSync, @@ -264,6 +264,28 @@ interface ShimFileState { preserveOnly?: boolean; } +export type CodexShimBackingForCommand = + | Readonly<{ status: "not-tracked" }> + | Readonly<{ + status: "matched"; + selectedRole: "wrapper" | "backing"; + backingPath: string; + backingKind: "backup" | "real"; + }> + | Readonly<{ + status: "unknown"; + reason: + | "state_invalid" + | "platform_mismatch" + | "ambiguous_match" + | "preserve_only" + | "backing_missing" + | "backing_mismatch" + | "binding_unavailable" + | "wrapper_unhealthy" + | "version_manager_refused"; + }>; + interface ShimPathFingerprint { dev: number; ino: number; @@ -616,8 +638,13 @@ function backupPathFor(path: string): string { * deliberately excluded: a false positive here refuses a restore that would * otherwise be correct. */ -export function isVersionManagerOwnedCodexPath(path: string): boolean { - const normalized = path.replace(/\\/g, "/").toLowerCase(); +export function isVersionManagerOwnedCodexPath( + path: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const normalized = (platform === "win32" + ? win32.normalize(path).replace(/\\/g, "/") + : posix.normalize(path)).toLowerCase(); return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/") || normalized.includes("/.asdf/installs/") @@ -1078,6 +1105,7 @@ exit $LASTEXITCODE interface ShimStateReadResult { state: ShimState | null; + present: boolean; warning?: string; } @@ -1087,7 +1115,17 @@ function fileErrorCode(error: unknown): string | undefined { : undefined; } -function readBoundedRegularFile(path: string, maxBytes: number): { content: string } | { warning: string } | null { +function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string } | { warning: string } | null { + let lexicalBefore: Stats; + try { + lexicalBefore = lstatSync(path); + if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) { + return { warning: `Codex shim state is not a direct regular file at ${path}; auto-restore skipped.` }; + } + } catch (error) { + if (fileErrorCode(error) === "ENOENT") return null; + return { warning: `Codex shim state could not be inspected at ${path}.` }; + } let fd: number; try { fd = openSync(path, "r"); @@ -1113,25 +1151,33 @@ function readBoundedRegularFile(path: string, maxBytes: number): { content: stri return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; } const after = fstatSync(fd); + let lexicalAfter: Stats; + try { + lexicalAfter = lstatSync(path); + } catch { + return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; + } if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size - || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) { + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino) { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - return { content: buffer.toString("utf8") }; + return { bytes: buffer, content: buffer.toString("utf8") }; } finally { closeSync(fd); } } -function readStateResult(): ShimStateReadResult { - const bounded = readBoundedRegularFile(statePath(), CODEX_SHIM_STATE_MAX_BYTES); - if (!bounded) return { state: null }; - if ("warning" in bounded) return { state: null, warning: bounded.warning }; +function readStateResult(path = statePath()): ShimStateReadResult { + const bounded = readBoundedRegularFile(path, CODEX_SHIM_STATE_MAX_BYTES); + if (!bounded) return { state: null, present: false }; + if ("warning" in bounded) return { state: null, present: true, warning: bounded.warning }; try { const value = JSON.parse(bounded.content) as unknown; - if (!value || typeof value !== "object") return { state: null }; + if (!value || typeof value !== "object") return { state: null, present: true }; const state = value as Record; - if (typeof state.platform !== "string") return { state: null }; + if (typeof state.platform !== "string") return { state: null, present: true }; const validFile = (item: unknown): item is ShimFileState => { if (!item || typeof item !== "object") return false; const file = item as Record; @@ -1142,13 +1188,13 @@ function readStateResult(): ShimStateReadResult { && (file.preserveOnly === undefined || typeof file.preserveOnly === "boolean"); }; if (state.wrappers !== undefined) { - if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return { state: null }; + if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return { state: null, present: true }; } else if (!validFile(state)) { - return { state: null }; + return { state: null, present: true }; } - return { state: state as unknown as ShimState }; + return { state: state as unknown as ShimState, present: true }; } catch { - return { state: null }; + return { state: null, present: true }; } } @@ -1156,6 +1202,146 @@ function readState(): ShimState | null { return readStateResult().state; } +export function isLocalAbsoluteInspectionPath(path: string, platform: NodeJS.Platform): boolean { + if (platform !== "win32") return posix.isAbsolute(path); + const normalized = path.replace(/\//g, "\\"); + // UNC and device namespaces can initiate remote I/O while a nominally local + // inspection is resolving user-controlled paths. Root-relative paths are + // drive-context dependent, so require an explicit local drive as well. + return win32.isAbsolute(path) + && /^[a-z]:\\/i.test(normalized) + && !normalized.startsWith("\\\\"); +} + +function windowsShimInspectionIsDeferred(platform: NodeJS.Platform): boolean { + return platform === "win32"; +} + +/** Resolve one selected command through already-recorded shim state, without repair. */ +export function inspectCodexShimBackingForCommand( + selectedCommand: string, + platform: NodeJS.Platform = process.platform, + configDir: string = getConfigDir(), +): CodexShimBackingForCommand { + // Pathname prechecks cannot prevent a writable Windows ancestor from being + // replaced with a remote reparse point before the later state/fingerprint + // reads. Keep the exported read-only helper fail-closed until those reads are + // performed through a handle-bound Windows provenance layer. + if (windowsShimInspectionIsDeferred(platform)) { + return Object.freeze({ status: "unknown" as const, reason: "binding_unavailable" as const }); + } + if (!isLocalAbsoluteInspectionPath(configDir, platform)) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + const stateFile = join(configDir, "codex-shim.json"); + try { + const stateEntry = lstatSync(stateFile); + if (stateEntry.isSymbolicLink()) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + } catch (error) { + if (fileErrorCode(error) !== "ENOENT") { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + } + const result = readStateResult(stateFile); + if (!result.state) { + return result.present + ? Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }) + : Object.freeze({ status: "not-tracked" as const }); + } + const pathApi = platform === "win32" ? win32 : posix; + const samePath = (left: string, right: string): boolean => { + const normalizedLeft = pathApi.resolve(left); + const normalizedRight = pathApi.resolve(right); + return platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; + }; + const files = stateFiles(result.state); + if (files.some(file => !file.wrapperPath || !file.originalPath || !file.backupPath + || ![file.wrapperPath, file.originalPath, file.backupPath, file.realPath] + .filter((path): path is string => typeof path === "string") + .every(path => isLocalAbsoluteInspectionPath(path, platform)))) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + const wrapperKeys = files.map(file => platform === "win32" + ? pathApi.resolve(file.wrapperPath).toLowerCase() + : pathApi.resolve(file.wrapperPath)); + if (new Set(wrapperKeys).size !== wrapperKeys.length) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + const selectedFingerprint = shimPathFingerprint(selectedCommand); + if (!selectedFingerprint) { + return Object.freeze({ status: "unknown" as const, reason: "binding_unavailable" as const }); + } + const selectedIdentity = selectedFingerprint.target ?? selectedFingerprint; + const sameEffectiveIdentity = (fingerprint: ShimPathFingerprint | null): boolean => { + if (!fingerprint) return false; + const identity = fingerprint.target ?? fingerprint; + return identity.dev === selectedIdentity.dev && identity.ino === selectedIdentity.ino; + }; + const matches = files.flatMap(file => { + const backingPath = file.realPath ?? file.backupPath; + const roles: Array<"wrapper" | "backing"> = []; + if (samePath(file.wrapperPath, selectedCommand) + || sameEffectiveIdentity(shimPathFingerprint(file.wrapperPath))) { + roles.push("wrapper"); + } + if (samePath(backingPath, selectedCommand) + || sameEffectiveIdentity(shimPathFingerprint(backingPath))) { + roles.push("backing"); + } + return roles.map(selectedRole => ({ file, backingPath, selectedRole })); + }); + if (matches.length === 0) return Object.freeze({ status: "not-tracked" as const }); + if (result.state.platform !== platform) { + return Object.freeze({ status: "unknown" as const, reason: "platform_mismatch" as const }); + } + if (matches.length !== 1) { + return Object.freeze({ status: "unknown" as const, reason: "ambiguous_match" as const }); + } + const { file, backingPath, selectedRole } = matches[0]!; + if (file.preserveOnly === true) { + return Object.freeze({ status: "unknown" as const, reason: "preserve_only" as const }); + } + const backing = statFingerprint(backingPath, true); + if (!backing || backing.size <= 0 || samePath(backingPath, file.wrapperPath)) { + return Object.freeze({ status: "unknown" as const, reason: "backing_missing" as const }); + } + const wrapperProbe = stableShimPathProbe(file.wrapperPath); + if (!wrapperProbe || !isHealthyShimProbe(wrapperProbe, result.state.platform)) { + return Object.freeze({ + status: "unknown" as const, + reason: isVersionManagerOwnedCodexPath(file.wrapperPath) + ? "version_manager_refused" as const + : "wrapper_unhealthy" as const, + }); + } + const wrapperIdentity = wrapperProbe.fingerprint.target ?? wrapperProbe.fingerprint; + if (backing.dev === wrapperIdentity.dev && backing.ino === wrapperIdentity.ino) { + return Object.freeze({ status: "unknown" as const, reason: "backing_mismatch" as const }); + } + const wrapperExt = extname(file.wrapperPath).toLowerCase(); + const invokesBacking = platform !== "win32" + ? wrapperProbe.prefix.includes(`exec ${shQuote(backingPath)} "$@"`) + : wrapperExt === ".cmd" || wrapperExt === ".bat" + ? wrapperProbe.prefix.includes(windowsBatchSet("OCX_REAL_CODEX", backingPath)) + && wrapperProbe.prefix.includes('"%OCX_REAL_CODEX%" %*') + : wrapperExt === ".ps1" + ? wrapperProbe.prefix.includes(`& ${psString(backingPath)} @args`) + : wrapperProbe.prefix.includes(`exec ${shQuote(gitBashPath(backingPath))} "$@"`); + if (!invokesBacking) { + return Object.freeze({ status: "unknown" as const, reason: "backing_mismatch" as const }); + } + return Object.freeze({ + status: "matched" as const, + selectedRole, + backingPath, + backingKind: file.realPath !== undefined ? "real" as const : "backup" as const, + }); +} + function statePath(): string { return join(getConfigDir(), "codex-shim.json"); } @@ -1283,7 +1469,7 @@ function stateFiles(state: ShimState): ShimFileState[] { } function primaryState(files: ShimFileState[]): ShimState { - const first = files[0]; + const first = files[0]!; return { platform: process.platform, ...first, wrappers: files }; } @@ -2067,7 +2253,7 @@ export function autoRestoreCodexShim(options: { const state = stateRead.state; if (!state) { if (stateRead.warning) return { status: "ineligible", message: stateRead.warning }; - return { status: existsSync(statePath()) ? "ineligible" : "not-installed" }; + return { status: stateRead.present ? "ineligible" : "not-installed" }; } if (state.platform !== process.platform) return { status: "ineligible" }; diff --git a/src/lib/strict-semver.ts b/src/lib/strict-semver.ts new file mode 100644 index 0000000000..7475db156a --- /dev/null +++ b/src/lib/strict-semver.ts @@ -0,0 +1,20 @@ +const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +export interface StrictSemver { + readonly raw: string; + readonly core: readonly [bigint, bigint, bigint]; + readonly prerelease: readonly (bigint | string)[]; +} + +export function parseStrictSemver(value: unknown, maxLength = 128): StrictSemver | null { + if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return null; + const match = STRICT_SEMVER_RE.exec(value); + if (!match) return null; + return Object.freeze({ + raw: value, + core: Object.freeze([BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)]) as readonly [bigint, bigint, bigint], + prerelease: Object.freeze(match[4] + ? match[4].split(".").map(part => /^\d+$/.test(part) ? BigInt(part) : part) + : []), + }); +} diff --git a/src/update/codex-cli-update-launch-policy.d.mts b/src/update/codex-cli-update-launch-policy.d.mts new file mode 100644 index 0000000000..5e4221031d --- /dev/null +++ b/src/update/codex-cli-update-launch-policy.d.mts @@ -0,0 +1,18 @@ +export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS: readonly [ + "ASDF_DATA_DIR", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "MISE_DATA_DIR", + "NODENV_ROOT", + "NVS_HOME", + "NVS_NODE_PATH", + "N_PREFIX", + "NVM_DIR", + "NVM_HOME", + "NVM_SYMLINK", + "PROTO_HOME", + "SCOOP", + "SCOOP_GLOBAL", + "VOLTA_HOME", +]; +export function isCodexCliUpdateInspectionArgv(argv: readonly string[]): boolean; diff --git a/src/update/codex-cli-update-launch-policy.mjs b/src/update/codex-cli-update-launch-policy.mjs new file mode 100644 index 0000000000..0d12c40338 --- /dev/null +++ b/src/update/codex-cli-update-launch-policy.mjs @@ -0,0 +1,30 @@ +export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS = Object.freeze([ + "ASDF_DATA_DIR", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "MISE_DATA_DIR", + "NODENV_ROOT", + "NVS_HOME", + "NVS_NODE_PATH", + "N_PREFIX", + "NVM_DIR", + "NVM_HOME", + "NVM_SYMLINK", + "PROTO_HOME", + "SCOOP", + "SCOOP_GLOBAL", + "VOLTA_HOME", +]); + +/** + * Detect the read-only Codex CLI updater inspection namespace before Bun loads. + * Keep this exact and argument-position based: malformed actions still inherit + * the zero-effect launcher contract and are rejected by the Bun-side parser. + */ +export function isCodexCliUpdateInspectionArgv(argv) { + // Bun consumes every internal launch-proof argument before ordinary command + // parsing. Classify the same effective argv here so a user-supplied invalid + // proof cannot hide this namespace from the pre-Bun zero-effect policy. + const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof=")); + return args[0] === "system" && args[1] === "codex-cli-update"; +} diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 12995b8c93..f6bdaa1740 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -4,9 +4,9 @@ | Path | Responsibility | | --- | --- | -| `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. | +| `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/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. 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. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. 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. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary 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/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, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/config.ts` | Persisted `~/.opencodex/config.json` schema, defaults, migrations, transactions, and compatibility re-exports for split config modules. | @@ -80,6 +80,15 @@ tracked sibling before mutation and rolls back earlier siblings in reverse order Failures warn without changing the requested command's exit behavior. The probe uses read-only config diagnostics only for a confirmed candidate and never reads adjacent auth state. +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, +package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; +environment and persisted candidates remain report-only and cannot produce a managed classification in this one-shot command. +On Windows this first slice performs no candidate/configuration filesystem I/O: it preserves only proof-captured +absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. +This check does not attest or admit a selected runtime. The command exposes no private mutation authority and does not query +a registry, execute Codex/npm, install, repair, stop, restart, or change configuration/cache state. + The bridge enforces a heartbeat stall deadline. It defaults to 300 seconds sampled on a 2 s tick (`src/stall-timeout.ts`) and is configurable, so treat the number as a default rather than an invariant; sidecars keep their own clocks. On expiry the stream is closed and the upstream request diff --git a/tests/claude-dotenv-provenance-transport.test.ts b/tests/claude-dotenv-provenance-transport.test.ts index d048f75598..411c949a38 100644 --- a/tests/claude-dotenv-provenance-transport.test.ts +++ b/tests/claude-dotenv-provenance-transport.test.ts @@ -46,7 +46,16 @@ describe("Node launcher context transport", () => { if (result.error) throw result.error; expect(result.status).toBe(0); return JSON.parse(result.stdout) as { - context: { anthropicEnvSlots: string[] } | null; + context: { + anthropicEnvSlots: string[]; + codexCliInspectionEnv: { + codexCliPath: string | null; + path: string | null; + pathExt: string | null; + managerRoots: Record | null; + configDir: string; + } | null; + } | null; args: string[]; contextEnv: string | null; }; @@ -66,6 +75,44 @@ describe("Node launcher context transport", () => { expect(seen.contextEnv).toBeNull(); }); + test("a proof-bound long parent PATH remains trusted for updater inspection", () => { + const longPath = Array.from({ length: 300 }, (_, index) => `C:\\Tools\\${index}`).join(";"); + const payload = JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "C:\\npm\\codex.cmd", + path: longPath, + pathExt: ".EXE;.CMD", + managerRoots: { FNM_DIR: "C:\\Tools\\fnm-data" }, + configDir: "C:\\Users\\person\\.opencodex", + }, + }); + expect(payload.length).toBeGreaterThan(2048); + const seen = run([`--ocx-internal-launch-proof=${proof}`, "system"], payload); + expect(seen.context?.codexCliInspectionEnv?.path).toBe(longPath); + expect(seen.context?.codexCliInspectionEnv?.managerRoots).toEqual({ FNM_DIR: "C:\\Tools\\fnm-data" }); + expect(seen.context?.codexCliInspectionEnv?.configDir).toBe("C:\\Users\\person\\.opencodex"); + }); + + test("an unknown manager-root key invalidates the trusted context", () => { + const payload = JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "C:\\npm\\codex.cmd", + path: "C:\\npm", + pathExt: ".CMD", + managerRoots: { UNBOUNDED_ROOT: "C:\\" }, + configDir: "C:\\Users\\person\\.opencodex", + }, + }); + const seen = run([`--ocx-internal-launch-proof=${proof}`, "system"], payload); + expect(seen.context).toBeNull(); + }); + test("duplicate internal proofs fail closed and are removed from user argv", () => { const seen = run([ `--ocx-internal-launch-proof=${proof}`, diff --git a/tests/cli-capabilities.test.ts b/tests/cli-capabilities.test.ts index dac18e19ba..17e4beecf0 100644 --- a/tests/cli-capabilities.test.ts +++ b/tests/cli-capabilities.test.ts @@ -73,6 +73,18 @@ describe("capability table is a leaf data module", () => { expect(findCommand("capabilities")?.name).toBe("capabilities"); expect(CAPABILITIES.some(c => c.command[0] === "capabilities")).toBe(true); }); + + test("the check-only Codex CLI updater is declared as a local read capability", () => { + const cap = CAPABILITIES.find(c => c.command.join(" ") === "system codex-cli-update check"); + expect(cap).toBeDefined(); + expect(cap?.routes).toEqual([]); + expect(cap?.mutates).toBe(false); + expect(cap?.json).toBe("envelope"); + expect(cap?.flags.some(flag => flag.name === "--json")).toBe(true); + expect(cap?.summary).toContain("configured Codex CLI candidate"); + expect(cap?.details.join(" ")).toContain("does not attest or admit a selected runtime"); + expect(cap?.details.join(" ")).not.toContain("dry-run"); + }); }); describe("ocx capabilities output", () => { diff --git a/tests/cli-codex-cli-update.test.ts b/tests/cli-codex-cli-update.test.ts new file mode 100644 index 0000000000..e0231e40b6 --- /dev/null +++ b/tests/cli-codex-cli-update.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../src/cli/codex-cli-update"; +import { + initializeNodeLauncherContext, + NODE_LAUNCH_CONTEXT_ENV, + NODE_LAUNCH_PROOF_PREFIX, +} from "../src/cli/launcher-context"; +import type { CodexCliInstallProvenanceDeps, CodexCliInstallReport } from "../src/codex/cli-install-provenance"; + +const report: CodexCliInstallReport = { + schemaVersion: 1, + candidateAvailable: false, + candidateVersion: null, + candidateSource: null, + selectionAttested: false, + versionEvidence: { kind: "unavailable" }, + provenance: "unknown", managed: false, reason: "candidate_unavailable", location: null, + packageVersion: null, + shim: { status: "not-tracked", backingKind: null }, evidence: [], +}; + +describe("Codex CLI update CLI", () => { + test("parses the shared JSON flag spellings within the exact check grammar", () => { + expect(parseCodexCliUpdateArgs(["check"])).toEqual({ json: false }); + for (const flag of ["--json", "--json=true", "-json", "—json"]) { + expect(parseCodexCliUpdateArgs(["check", flag])).toEqual({ json: true }); + } + for (const args of [ + ["check", "--channel", "latest"], + ["dry-run"], + ["apply"], + ["check", "--json", "--json"], + ["check", "-json", "--json=true"], + ]) expect(() => parseCodexCliUpdateArgs(args)).toThrow(); + }); + + /** + * `--json` is accepted in any argv position CLI-wide, so automation that puts output + * flags ahead of the subcommand must not get a usage error. + */ + test("the JSON flag is accepted before the check action", () => { + for (const flag of ["--json", "--json=true", "-json", "—json"]) { + expect(parseCodexCliUpdateArgs([flag, "check"])).toEqual({ json: true }); + } + // Duplicate detection and positional validation still hold in that order. + expect(() => parseCodexCliUpdateArgs(["--json", "check", "--json"])).toThrow(); + expect(() => parseCodexCliUpdateArgs(["--json"])).toThrow(); + expect(() => parseCodexCliUpdateArgs(["--json", "apply"])).toThrow(); + expect(() => parseCodexCliUpdateArgs(["--json", "check", "extra"])).toThrow(); + }); + + test("malformed input performs no inspection", async () => { + let inspectedCalls = 0; + const code = await handleCodexCliUpdateCommand(["apply"], { + inspectInstall: async () => { inspectedCalls += 1; return report; }, + }); + expect(code).toBe(2); + expect(inspectedCalls).toBe(0); + }); + + test("check inspects exactly once", async () => { + let inspectedCalls = 0; + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async () => { inspectedCalls += 1; return report; }, + })).toBe(0); + expect(inspectedCalls).toBe(1); + }); + + test("passes only proof-bound manager roots into production provenance inspection", async () => { + const proof = "M".repeat(43); + const env: NodeJS.ProcessEnv = { + [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "C:\\managed\\codex.cmd", + path: "C:\\managed", + pathExt: ".CMD", + managerRoots: { FNM_DIR: "C:\\custom-manager" }, + configDir: "C:\\opencodex", + }, + }), + }; + initializeNodeLauncherContext(["bun", "cli", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], env); + let received: CodexCliInstallProvenanceDeps | null = null; + try { + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async deps => { + received = deps; + return report; + }, + })).toBe(0); + expect(received?.env).toEqual({ + FNM_DIR: "C:\\custom-manager", + CODEX_CLI_PATH: "C:\\managed\\codex.cmd", + PATH: "C:\\managed", + PATHEXT: ".CMD", + }); + expect(received?.configDir).toBe("C:\\opencodex"); + } finally { + initializeNodeLauncherContext(["bun", "cli"], {}); + } + }); + + test("a launch without proof passes only sealed inspection dependencies", async () => { + initializeNodeLauncherContext(["bun", "cli"], {}); + let received: CodexCliInstallProvenanceDeps | null = null; + try { + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async deps => { + received = deps; + return report; + }, + })).toBe(0); + expect(received?.env).toEqual({ PATH: "" }); + expect(received?.configDir).toBe("."); + } finally { + initializeNodeLauncherContext(["bun", "cli"], {}); + } + }); + + test("JSON output serializes only the public report once", async () => { + const logs: string[] = []; + const oldLog = console.log; + try { + console.log = (...values: unknown[]) => logs.push(values.map(String).join(" ")); + const code = await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async () => report, + }); + expect(code).toBe(0); + expect(logs).toHaveLength(1); + const output = JSON.parse(logs[0]!) as Record; + expect(output).toEqual(report); + expect(output).toMatchObject({ + candidateAvailable: false, + candidateVersion: null, + candidateSource: null, + selectionAttested: false, + }); + for (const stale of ["selected", "selectedVersion", "selectionSource", "selectionEvidence"]) { + expect(stale in output).toBe(false); + } + expect(logs[0]).not.toContain("authority"); + } finally { + console.log = oldLog; + } + }); + + test("human output uses command-specific scalar lines", async () => { + const logs: string[] = []; + const oldLog = console.log; + try { + console.log = (...values: unknown[]) => logs.push(values.map(String).join(" ")); + expect(await handleCodexCliUpdateCommand(["check"], { + inspectInstall: async () => report, + })).toBe(0); + expect(logs.join("\n")).not.toContain("[object Object]"); + expect(logs).toContain("candidate: no"); + expect(logs).toContain("candidate-source: unavailable"); + expect(logs).toContain("selection-attested: no"); + expect(logs).toContain("candidate-version: unavailable"); + expect(logs).toContain("package-version: unavailable"); + expect(logs).toContain("version-evidence: unavailable"); + expect(logs).toContain("location: unavailable"); + expect(logs).toContain("shim: not-tracked"); + } finally { + console.log = oldLog; + } + }); + + test("human output keeps mismatched candidate and package versions distinct", async () => { + const logs: string[] = []; + const oldLog = console.log; + const mismatchReport: CodexCliInstallReport = { + ...report, + candidateAvailable: true, + candidateVersion: "1.2.3", + candidateSource: "persisted", + versionEvidence: { kind: "advisory-runtime" }, + provenance: "npm-global", + reason: "version_mismatch", + location: "/codex", + packageVersion: "1.2.4", + }; + try { + console.log = (...values: unknown[]) => logs.push(values.map(String).join(" ")); + expect(await handleCodexCliUpdateCommand(["check"], { + inspectInstall: async () => mismatchReport, + })).toBe(0); + expect(logs).toContain("candidate-source: persisted"); + expect(logs).toContain("candidate-version: 1.2.3"); + expect(logs).toContain("package-version: 1.2.4"); + expect(logs).toContain("version-evidence: advisory-runtime"); + expect(logs).toContain("location: /codex"); + expect(logs.some(line => line.startsWith("version: "))).toBe(false); + } finally { + console.log = oldLog; + } + }); +}); diff --git a/tests/cli-registry.test.ts b/tests/cli-registry.test.ts index 5c91b97aaf..4accf73048 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -98,6 +98,12 @@ describe("CLI command registry parity", () => { const names = CLI_COMMANDS.map(entry => entry.name); expect(new Set(names).size).toBe(names.length); }); + + test("system help exposes the exact Codex CLI inspection grammar", () => { + const details = findCommand("system")?.details ?? []; + expect(details).toContain("ocx system codex-cli-update check [--json]"); + expect(details.some(line => line.includes("dry-run"))).toBe(false); + }); }); describe("help banner command coverage", () => { diff --git a/tests/codex-cli-install-provenance.test.ts b/tests/codex-cli-install-provenance.test.ts new file mode 100644 index 0000000000..7f7692cd1f --- /dev/null +++ b/tests/codex-cli-install-provenance.test.ts @@ -0,0 +1,544 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + inspectCodexCliInstall, + isAppBundledCodexPath, + isCodexCliUpdateVersionManagerPath, + type CodexCliInstallProvenanceDeps, +} from "../src/codex/cli-install-provenance"; +import { buildUnixCodexShim } from "../src/codex/shim"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(label: string): string { + const root = mkdtempSync(join(tmpdir(), label)); + roots.push(root); + return root; +} + +function noFilesystemDeps(onCall: () => void): Pick< + CodexCliInstallProvenanceDeps, + "exists" | "lstat" | "stat" | "readFile" | "realpath" | "inspectShim" +> { + const fail = (): never => { + onCall(); + throw new Error("Windows lexical inspection must not access the filesystem"); + }; + return { + exists: fail, + lstat: fail as CodexCliInstallProvenanceDeps["lstat"], + stat: fail as CodexCliInstallProvenanceDeps["stat"], + readFile: fail, + realpath: fail, + inspectShim: fail as CodexCliInstallProvenanceDeps["inspectShim"], + }; +} + +function createPosixNpmGlobal(prefix: string): { launcher: string; packageRoot: string; entrypoint: string } { + const launcher = join(prefix, "bin", "codex"); + const packageRoot = join(prefix, "lib", "node_modules", "@openai", "codex"); + const entrypoint = join(packageRoot, "bin", "codex.js"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + writeFileSync(entrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(entrypoint, 0o755); + symlinkSync(entrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", + version: "1.2.3", + bin: { codex: "bin/codex.js" }, + }), "utf8"); + return { launcher, packageRoot, entrypoint }; +} + +describe("Codex CLI install provenance", () => { + test("Windows ordinary, bare, remote, and device candidates fail closed without filesystem access", async () => { + let calls = 0; + const deps = noFilesystemDeps(() => { calls += 1; }); + for (const [command, reason] of [ + ["C:\\Tools\\codex.cmd", "windows_inspection_deferred"], + ["codex", "candidate_path_unavailable"], + ["\\Windows\\codex.cmd", "candidate_path_unavailable"], + ["/Windows/codex.cmd", "candidate_path_unavailable"], + ["\\\\server\\share\\codex.cmd", "candidate_path_unavailable"], + ["\\\\?\\C:\\Tools\\codex.cmd", "candidate_path_unavailable"], + ] as const) { + const report = await inspectCodexCliInstall({ + ...deps, + platform: "win32", + configDir: "\\\\server\\share\\opencodex", + env: { CODEX_CLI_PATH: command, PATH: "\\\\server\\share", PATHEXT: ".CMD" }, + }); + expect(report.candidateAvailable).toBe(true); + expect(report.candidateSource).toBe("environment"); + expect(report.selectionAttested).toBe(false); + expect(report.managed).toBe(false); + expect(report.reason).toBe(reason); + expect(report.packageVersion).toBeNull(); + expect(report.shim.status).toBe("unknown"); + } + const driveRoot = await inspectCodexCliInstall({ + ...deps, + platform: "win32", + env: { CODEX_CLI_PATH: "C:\\Tools\\codex.cmd", NVM_HOME: "C:\\", PATH: "" }, + }); + expect(driveRoot.reason).toBe("windows_inspection_deferred"); + for (const [candidate, managerRoot] of [ + ["C:\\Users\\user\\.fnm\\..\\outside\\codex.cmd", undefined], + ["C:\\custom-store\\..\\Tools\\codex.cmd", "C:\\custom-store"], + ] as const) { + const escaped = await inspectCodexCliInstall({ + ...deps, + platform: "win32", + env: { + CODEX_CLI_PATH: candidate, + PATH: "", + ...(managerRoot ? { FNM_DIR: managerRoot } : {}), + }, + }); + expect(escaped.reason).toBe("windows_inspection_deferred"); + } + expect(calls).toBe(0); + }); + + test("Windows does not read persisted candidate state", async () => { + let calls = 0; + const report = await inspectCodexCliInstall({ + ...noFilesystemDeps(() => { calls += 1; }), + platform: "win32", + configDir: "C:\\OpenCodex", + env: { PATH: "C:\\Tools" }, + }); + expect(report.candidateAvailable).toBe(false); + expect(report.reason).toBe("candidate_unavailable"); + expect(report.shim.status).toBe("unknown"); + expect(calls).toBe(0); + }); + + test("Windows lexical app and version-manager candidates remain report-only", async () => { + let calls = 0; + const deps = noFilesystemDeps(() => { calls += 1; }); + for (const [path, provenance] of [ + ["C:\\Program Files\\WindowsApps\\OpenAI.Codex_1.0.0\\codex.exe", "app-bundle"], + ["C:\\Users\\user\\.fnm\\node-versions\\v22.1.0\\installation\\codex.exe", "version-manager"], + ["C:\\custom-store\\v22\\codex.cmd", "version-manager"], + ] as const) { + const env = path.startsWith("C:\\custom-store") + ? { CODEX_CLI_PATH: path, PATH: "", FNM_DIR: "C:\\custom-store" } + : { CODEX_CLI_PATH: path, PATH: "" }; + const report = await inspectCodexCliInstall({ ...deps, platform: "win32", env }); + expect(report.provenance).toBe(provenance); + expect(report.managed).toBe(false); + expect(report.selectionAttested).toBe(false); + expect(report.packageVersion).toBeNull(); + expect(report.shim.status).toBe("unknown"); + } + expect(calls).toBe(0); + }); + + test("recognizes updater-only version-manager layouts without catching ordinary paths", () => { + expect(isCodexCliUpdateVersionManagerPath("C:\\Users\\u\\.fnm\\node-versions\\v22\\installation\\codex.exe", "win32")).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("C:\\Users\\u\\scoop\\apps\\nodejs\\current\\codex.cmd", "win32")).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("/home/u/.nvm/versions/node/v22.1.0/bin/codex", "linux")).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("/opt/apps/service/releases/v2/data/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/apps/nodejs/current/bin/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/srv/app/versions/2024/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/node-versions/22/installation/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/srv/installs/node/22/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/tools/image/node/22/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/home/u/.nvm/../outside/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/plain\\.nvm\\bin/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/scoop/apps/tools/bin/codex", "linux")).toBe(false); + expect(isAppBundledCodexPath("/opt/plain\\flatpak\\codex", "linux")).toBe(false); + expect(isAppBundledCodexPath("/opt/flatpak/tools/bin/codex", "linux")).toBe(false); + expect(isAppBundledCodexPath( + "/var/lib/flatpak/app/com.openai.Codex/x86_64/stable/active/files/bin/codex", + "linux", + )).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("/usr/local/bin/codex", "linux")).toBe(false); + }); + + test("a POSIX filesystem-root manager setting does not claim unrelated absolute candidates", async () => { + const fileStat = { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o755, + size: 2, + dev: 1, + ino: 1, + mtimeMs: 0, + }; + for (const candidate of ["/usr/local/bin/codex", "//usr/local/bin/codex", "///usr/local/bin/codex"]) { + const report = await inspectCodexCliInstall({ + platform: "linux", + configDir: "/tmp/opencodex", + env: { CODEX_CLI_PATH: candidate, N_PREFIX: "/", PATH: "" }, + exists: () => true, + lstat: (() => fileStat) as never, + stat: (() => fileStat) as never, + realpath: path => path, + readFile: (() => Buffer.from("{}", "utf8")) as never, + boundedFileReadMode: "injected-test", + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("standalone-unverified"); + expect(report.reason).toBe("unverified_standalone"); + } + + let injectedReads = 0; + await inspectCodexCliInstall({ + platform: "linux", + configDir: "/tmp/opencodex", + env: { CODEX_CLI_PATH: "/virtual/codex", PATH: "" }, + exists: () => true, + lstat: (() => fileStat) as never, + stat: (() => fileStat) as never, + realpath: path => path, + readFile: (() => { injectedReads += 1; return Buffer.from("{}", "utf8"); }) as never, + boundedFileReadMode: "invalid" as never, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(injectedReads).toBe(0); + }); + + test.skipIf(process.platform === "win32")("proves a configured POSIX npm symlink and redacts paths", async () => { + const prefix = tempRoot("ocx-codex-posix-npm-"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report).toMatchObject({ + candidateAvailable: true, + candidateSource: "environment", + selectionAttested: false, + provenance: "npm-global", + managed: false, + reason: "selection_unattested", + packageVersion: "1.2.3", + location: "/codex.js", + }); + expect(JSON.stringify(report)).not.toContain(prefix); + expect(JSON.stringify(report)).not.toContain("authority"); + }); + + test.skipIf(process.platform !== "linux")("does not mistake a flatpak path component for an app bundle", async () => { + const prefix = join(tempRoot("ocx-codex-flatpak-component-"), "flatpak", "tools"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: "linux", + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + + expect(report.provenance).toBe("npm-global"); + expect(report.reason).toBe("selection_unattested"); + expect(report.packageVersion).toBe("1.2.3"); + expect(report.evidence).toEqual(expect.arrayContaining([ + "package_manifest", + "package_manifest_digest", + "global_npm_layout", + ])); + }); + + test.skipIf(process.platform !== "linux")("does not mistake a Scoop path component for a version manager", async () => { + const prefix = join(tempRoot("ocx-codex-scoop-component-"), "scoop", "apps", "tools"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: "linux", + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + + expect(report.provenance).toBe("npm-global"); + expect(report.reason).toBe("selection_unattested"); + expect(report.packageVersion).toBe("1.2.3"); + expect(report.evidence).toEqual(expect.arrayContaining([ + "package_manifest", + "package_manifest_digest", + "global_npm_layout", + ])); + }); + + test.skipIf(process.platform === "win32")("proves a POSIX npm global through a symlinked prefix", async () => { + const prefix = tempRoot("ocx-codex-posix-prefix-"); + const { launcher: physicalLauncher } = createPosixNpmGlobal(prefix); + const alias = `${prefix}-alias`; + roots.push(alias); + symlinkSync(prefix, alias, "dir"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: join(alias, "bin", "codex"), PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(physicalLauncher).not.toBe(join(alias, "bin", "codex")); + expect(report.provenance).toBe("npm-global"); + expect(report.reason).toBe("selection_unattested"); + expect(JSON.stringify(report)).not.toContain(prefix); + expect(JSON.stringify(report)).not.toContain(alias); + }); + + test.skipIf(process.platform === "win32")("does not adopt a project-local POSIX node_modules layout", async () => { + const prefix = tempRoot("ocx-codex-posix-project-"); + const launcher = join(prefix, "bin", "codex"); + const packageRoot = join(prefix, "node_modules", "@openai", "codex"); + const entrypoint = join(packageRoot, "bin", "codex.js"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + writeFileSync(entrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(entrypoint, 0o755); + symlinkSync(entrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", version: "1.2.3", bin: { codex: "bin/codex.js" }, + }), "utf8"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.managed).toBe(false); + expect(report.provenance).not.toBe("npm-global"); + expect(report.reason).toBe("npm_global_unverified"); + expect(report.versionEvidence.kind).toBe("unavailable"); + + writeFileSync(join(prefix, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.3", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const persisted = await inspectCodexCliInstall({ + platform: process.platform, + configDir: prefix, + env: { PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(persisted.reason).toBe("npm_global_unverified"); + expect(persisted.versionEvidence.kind).toBe("advisory-runtime"); + }); + + test.skipIf(process.platform === "win32")("fails closed on literal or relative POSIX PATH shadowing", async () => { + const prefix = tempRoot("ocx-codex-posix-path-"); + createPosixNpmGlobal(prefix); + for (const path of [`${join(prefix, "bin")} `, `relative:${join(prefix, "bin")}`]) { + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: "codex", PATH: path }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.reason).toBe("candidate_path_unavailable"); + } + }); + + test.skipIf(process.platform === "win32")("rejects a manifest entrypoint redirected outside its package root", async () => { + const prefix = tempRoot("ocx-codex-external-entry-"); + const packageRoot = join(prefix, "lib", "node_modules", "@openai", "codex"); + const launcher = join(prefix, "bin", "codex"); + const outside = join(prefix, "outside-bin"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(packageRoot, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const externalEntrypoint = join(outside, "codex.js"); + writeFileSync(externalEntrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(externalEntrypoint, 0o755); + symlinkSync(outside, join(packageRoot, "bin"), "dir"); + symlinkSync(externalEntrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", version: "1.2.3", bin: { codex: "bin/codex.js" }, + }), "utf8"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.managed).toBe(false); + expect(report.provenance).not.toBe("npm-global"); + }); + + test("uses canonical POSIX paths instead of escaped manager-looking aliases", async () => { + const lexical = "/home/u/.nvm/bin/codex"; + const canonical = "/opt/outside/codex"; + const managerRoot = "/home/u/.nvm"; + const fileStat = { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o755, + size: 2, + dev: 1, + ino: 1, + mtimeMs: 0, + }; + const candidatePaths = new Set([lexical, canonical]); + const report = await inspectCodexCliInstall({ + platform: "linux", + configDir: "/tmp/opencodex", + env: { CODEX_CLI_PATH: lexical, NVM_DIR: managerRoot, PATH: "" }, + exists: path => candidatePaths.has(path), + lstat: (path => { + if (!candidatePaths.has(path)) throw new Error("absent"); + return fileStat; + }) as never, + stat: (path => { + if (!candidatePaths.has(path)) throw new Error("absent"); + return fileStat; + }) as never, + realpath: path => path === lexical ? canonical : path, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("standalone-unverified"); + expect(report.reason).toBe("unverified_standalone"); + }); + + test.skipIf(process.platform === "win32")("does not classify a manager-looking symlink that resolves outside its root", async () => { + const root = tempRoot("ocx-codex-manager-escape-"); + const managerRoot = join(root, ".nvm"); + const launcher = join(managerRoot, "bin", "codex"); + const outside = join(root, "outside", "codex"); + mkdirSync(join(managerRoot, "bin"), { recursive: true }); + mkdirSync(join(root, "outside"), { recursive: true }); + writeFileSync(outside, "#!/usr/bin/env node\n", "utf8"); + chmodSync(outside, 0o755); + symlinkSync(outside, launcher); + + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, NVM_DIR: managerRoot, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("standalone-unverified"); + expect(report.reason).toBe("unverified_standalone"); + }); + + test.skipIf(process.platform === "win32")("persisted and environment npm candidates remain unattested", async () => { + const root = tempRoot("ocx-codex-unattested-"); + const { launcher } = createPosixNpmGlobal(root); + writeFileSync(join(root, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.3", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const persisted = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(persisted).toMatchObject({ + candidateSource: "persisted", + candidateVersion: "1.2.3", + selectionAttested: false, + provenance: "npm-global", + managed: false, + reason: "selection_unattested", + versionEvidence: { kind: "package-manifest" }, + }); + + writeFileSync(join(root, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.4", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const mismatched = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(mismatched).toMatchObject({ + candidateVersion: "1.2.4", + packageVersion: "1.2.3", + reason: "version_mismatch", + versionEvidence: { kind: "advisory-runtime" }, + }); + + const environment = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(environment).toMatchObject({ + candidateSource: "environment", + candidateVersion: null, + packageVersion: "1.2.3", + selectionAttested: false, + managed: false, + reason: "selection_unattested", + versionEvidence: { kind: "unavailable" }, + }); + }); + + test.skipIf(process.platform === "win32")("rejects a symbolic-link persisted-state file before reading", async () => { + const root = tempRoot("ocx-codex-state-link-"); + let reads = 0; + const report = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + lstat: (() => ({ isSymbolicLink: () => true, isFile: () => false })) as never, + readFile: (() => { reads += 1; throw new Error("must not read"); }) as never, + }); + expect(report.reason).toBe("candidate_unavailable"); + expect(reads).toBe(0); + }); + + test.skipIf(process.platform === "win32")("a POSIX version-manager candidate remains report-only", async () => { + const root = tempRoot("ocx-codex-posix-fnm-"); + const launcher = join(root, ".fnm", "codex"); + mkdirSync(join(root, ".fnm"), { recursive: true }); + writeFileSync(launcher, "#!/bin/sh\nexit 0\n", "utf8"); + chmodSync(launcher, 0o755); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("version-manager"); + expect(report.managed).toBe(false); + }); + + test.skipIf(process.platform === "win32")("the real POSIX shim inspector keeps wrapper and backing candidates report-only", async () => { + const root = tempRoot("ocx-codex-shim-deferred-"); + const wrapper = join(root, "codex"); + const backing = join(root, "codex.real"); + writeFileSync(wrapper, buildUnixCodexShim( + backing, + join(root, "bun"), + join(root, "cli.ts"), + "bundled", + join(root, "token"), + ), "utf8"); + writeFileSync(backing, "#!/bin/sh\nexit 0\n", "utf8"); + chmodSync(wrapper, 0o755); + chmodSync(backing, 0o755); + const file = { wrapperPath: wrapper, originalPath: wrapper, backupPath: backing }; + writeFileSync(join(root, "codex-shim.json"), `${JSON.stringify({ + platform: process.platform, + ...file, + wrappers: [file], + }, null, 2)}\n`, "utf8"); + const backingAlias = join(root, "codex-alias"); + linkSync(backing, backingAlias); + for (const candidatePath of [wrapper, backing, backingAlias]) { + const report = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { CODEX_CLI_PATH: candidatePath, PATH: "" }, + }); + expect(report.reason).toBe("shim_update_deferred"); + expect(report.shim.status).toBe("matched"); + expect(report.managed).toBe(false); + } + }); +}); diff --git a/tests/codex-cli-update-launcher-policy.test.ts b/tests/codex-cli-update-launcher-policy.test.ts new file mode 100644 index 0000000000..b8e4cf84ec --- /dev/null +++ b/tests/codex-cli-update-launcher-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isCodexCliUpdateInspectionArgv } from "../src/update/codex-cli-update-launch-policy.mjs"; + +describe("Codex CLI updater launcher policy", () => { + test("covers the whole exact namespace including malformed actions", () => { + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "codex-cli-update", "check"])).toBe(true); + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "codex-cli-update", "bad"])).toBe(true); + expect(isCodexCliUpdateInspectionArgv([ + "node", "ocx", "--ocx-internal-launch-proof=bad", "system", "codex-cli-update", "check", + ])).toBe(true); + expect(isCodexCliUpdateInspectionArgv([ + "node", "ocx", "--ocx-internal-launch-proof=bad", "system", "codex-cli-update", "bad", + ])).toBe(true); + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "update"])).toBe(false); + }); + + test("launcher skips boot repair and lazy Bun installation for this namespace", () => { + const source = readFileSync(join(import.meta.dir, "..", "bin", "ocx.mjs"), "utf8"); + expect(source).toContain("!codexCliUpdateInspection && isNodeModulesInstall()"); + expect(source).toContain("resolveBun({ allowInstall: !codexCliUpdateInspection })"); + expect(source).toContain("if (allowInstall && existsSync(installJs))"); + }); +}); diff --git a/tests/codex-cli-update-zero-effect.test.ts b/tests/codex-cli-update-zero-effect.test.ts new file mode 100644 index 0000000000..d12a8106ed --- /dev/null +++ b/tests/codex-cli-update-zero-effect.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex CLI updater zero-effect boundary", () => { + test("direct Bun execution of the Node launcher fails before updater inspection", () => { + const result = spawnSync(process.execPath, [ + join(import.meta.dir, "..", "bin", "ocx.mjs"), + "system", "codex-cli-update", "check", "--json", + ], { + cwd: join(import.meta.dir, ".."), encoding: "utf8", timeout: 15_000, + env: { ...process.env }, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("must use the published Node launcher"); + }); + + test("published Node launcher check neither executes the candidate launcher nor rewrites invalid state", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-codex-check-zero-effect-")); + roots.push(root); + const launcher = join(root, process.platform === "win32" ? "codex.cmd" : "codex"); + const marker = join(root, "executed.txt"); + const home = join(root, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(launcher, process.platform === "win32" + ? `@echo off\r\necho executed>${marker}\r\n` + : `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\n`, "utf8"); + if (process.platform !== "win32") chmodSync(launcher, 0o755); + const statePath = join(home, "codex-shim.json"); + writeFileSync(statePath, "{broken", "utf8"); + const before = readFileSync(statePath); + const result = spawnSync("node", [join(import.meta.dir, "..", "bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { + cwd: join(import.meta.dir, ".."), + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_CLI_PATH: launcher }, + windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout) as Record; + expect(report.managed).toBe(false); + expect(typeof report.reason).toBe("string"); + expect(report.candidateAvailable).toBe(true); + expect(report.candidateSource).toBe("environment"); + expect(report.selectionAttested).toBe(false); + for (const stale of ["selected", "selectedVersion", "selectionSource", "selectionEvidence"]) { + expect(stale in report).toBe(false); + } + expect(readFileSync(statePath)).toEqual(before); + expect(existsSync(marker)).toBe(false); + }); + + test("published Node launcher rejects malformed updater input before any repair or candidate command", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-codex-invalid-zero-effect-")); + roots.push(root); + const launcher = join(root, process.platform === "win32" ? "codex.cmd" : "codex"); + const marker = join(root, "executed.txt"); + const home = join(root, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(launcher, process.platform === "win32" + ? `@echo off\r\necho executed>${marker}\r\n` + : `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\n`, "utf8"); + if (process.platform !== "win32") chmodSync(launcher, 0o755); + const statePath = join(home, "codex-shim.json"); + writeFileSync(statePath, "{broken", "utf8"); + const before = readFileSync(statePath); + const result = spawnSync("node", [ + join(import.meta.dir, "..", "bin", "ocx.mjs"), + "--ocx-internal-launch-proof=bad", + "system", "codex-cli-update", "invalid", + ], { + cwd: join(import.meta.dir, ".."), encoding: "utf8", timeout: 15_000, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_CLI_PATH: launcher }, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stderr).toContain("codex-cli-update action must be check"); + expect(readFileSync(statePath)).toEqual(before); + expect(existsSync(marker)).toBe(false); + }); +}); diff --git a/tests/codex-shim-autorestore.test.ts b/tests/codex-shim-autorestore.test.ts index f6bd4b2b54..d4905ac5ca 100644 --- a/tests/codex-shim-autorestore.test.ts +++ b/tests/codex-shim-autorestore.test.ts @@ -41,6 +41,11 @@ describe("Codex shim CLI auto-restore policy", () => { expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", subcommand])).toBe(true); } expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", "status"])).toBe(false); + for (const action of ["check", "future-action", "bad", undefined]) { + const args = ["system", "codex-cli-update", ...(action ? [action] : [])]; + expect(skipsCodexShimAutoRestore("system", args)).toBe(true); + } + expect(skipsCodexShimAutoRestore("system", ["system", "update", "check"])).toBe(false); expect(skipsCodexShimAutoRestore("status", ["status"])).toBe(false); }); @@ -124,6 +129,53 @@ describe("Codex shim CLI auto-restore policy", () => { } }); + test("an actionable shim replacement stays byte-identical for the updater inspection namespace", 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 statePath = join(home, "codex-shim.json"); + 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 beforeWrapper = readFileSync(wrapper); + const beforeBackup = readFileSync(backup); + const beforeState = readFileSync(statePath); + + const result = spawnSync(process.execPath, [ + join(import.meta.dir, "..", "src", "cli", "index.ts"), + "system", "codex-cli-update", "check", "--json", + ], { + encoding: "utf8", + env: { ...process.env, PATH: binDir, OPENCODEX_HOME: home }, + }); + + expect(result.status).toBe(0); + 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(statePath)).toEqual(beforeState); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, 20_000); + test("shim replaced -> next ocx command auto-restores and warns", async () => { if (process.platform === "win32") return; const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-activation-bin-")); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 7ab67f595d..cb8f8839ba 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -1,9 +1,9 @@ import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, setCodexShimRollbackRestoreHookForTests, uninstallCodexShim } from "../src/codex/shim"; +import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, inspectCodexShimBackingForCommand, installCodexShim, isLocalAbsoluteInspectionPath, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, setCodexShimRollbackRestoreHookForTests, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; @@ -2011,7 +2011,8 @@ describe("version-manager shim destruction (#2412)", () => { expect(isVersionManagerOwnedCodexPath("/home/u/.asdf/installs/codex/1.0/bin/codex")).toBe(true); expect(isVersionManagerOwnedCodexPath("/home/u/.asdf/shims/codex")).toBe(true); expect(isVersionManagerOwnedCodexPath("/home/u/.volta/bin/codex")).toBe(true); - expect(isVersionManagerOwnedCodexPath("C:\\Users\\u\\.volta\\bin\\codex.cmd")).toBe(true); + expect(isVersionManagerOwnedCodexPath("C:\\Users\\u\\.volta\\bin\\codex.cmd", "win32")).toBe(true); + expect(isVersionManagerOwnedCodexPath("/opt/plain\\.volta\\bin/codex", "linux")).toBe(false); expect(isVersionManagerOwnedCodexPath("/usr/local/bin/codex")).toBe(false); expect(isVersionManagerOwnedCodexPath("/home/u/.npm-global/bin/codex")).toBe(false); expect(isVersionManagerOwnedCodexPath("/opt/homebrew/bin/codex")).toBe(false); @@ -2031,3 +2032,107 @@ describe("version-manager shim destruction (#2412)", () => { }); }); }); + +describe("Codex shim read-only backing inspection", () => { + test("local inspection paths reject Windows remote and device namespaces", () => { + expect(isLocalAbsoluteInspectionPath("/usr/local/bin/codex", "linux")).toBe(true); + expect(isLocalAbsoluteInspectionPath("C:\\OpenCodex\\codex.cmd", "win32")).toBe(true); + for (const path of [ + "\\Windows\\codex.cmd", + "/Windows/codex.cmd", + "\\\\server\\share\\codex.cmd", + "//server/share/codex.cmd", + "\\\\?\\C:\\OpenCodex\\codex.cmd", + "//?/C:/OpenCodex/codex.cmd", + "\\\\.\\PhysicalDrive0", + ]) { + expect(isLocalAbsoluteInspectionPath(path, "win32")).toBe(false); + } + expect(isLocalAbsoluteInspectionPath("codex.cmd", "win32")).toBe(false); + }); + + test("Windows backing inspection fails closed before pathname access on every host", () => { + expect(inspectCodexShimBackingForCommand( + "C:\\remote-or-local\\codex.cmd", + "win32", + "C:\\OpenCodex", + )).toEqual({ + status: "unknown", + reason: "binding_unavailable", + }); + }); + + test.skipIf(process.platform === "win32")("selects only the recorded wrapper backing and fails closed on preserve-only state", () => { + withInstalledShim(({ wrappers, backups, statePath }) => { + expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toMatchObject({ + status: "matched", + selectedRole: "wrapper", + backingPath: backups[0]!, + backingKind: "backup", + }); + expect(inspectCodexShimBackingForCommand(backups[0]!)).toMatchObject({ + status: "matched", + selectedRole: "backing", + backingPath: backups[0]!, + backingKind: "backup", + }); + + const state = JSON.parse(readFileSync(statePath, "utf8")) as { wrappers: Array> }; + state.wrappers[0]!.preserveOnly = true; + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toEqual({ + status: "unknown", + reason: "preserve_only", + }); + }); + }); + + test.skipIf(process.platform === "win32")("matches hard-link aliases of a recorded wrapper or backing by file identity", () => { + withInstalledShim(({ wrappers, backups }) => { + const wrapperAlias = `${wrappers[0]!}.alias`; + const backingAlias = `${backups[0]!}.alias`; + linkSync(wrappers[0]!, wrapperAlias); + linkSync(backups[0]!, backingAlias); + expect(inspectCodexShimBackingForCommand(wrapperAlias)).toMatchObject({ + status: "matched", + selectedRole: "wrapper", + backingPath: backups[0]!, + }); + expect(inspectCodexShimBackingForCommand(backingAlias)).toMatchObject({ + status: "matched", + selectedRole: "backing", + backingPath: backups[0]!, + }); + }); + }); + + test.skipIf(process.platform === "win32")("distinguishes absent state from invalid state", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-shim-inspect-invalid-")); + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.OPENCODEX_HOME = home; + expect(inspectCodexShimBackingForCommand(join(home, "codex"))).toEqual({ status: "not-tracked" }); + writeFileSync(join(home, "codex-shim.json"), "{broken", "utf8"); + expect(inspectCodexShimBackingForCommand(join(home, "codex"))).toEqual({ + status: "unknown", + reason: "state_invalid", + }); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")("fails closed when the recorded backing aliases the wrapper", () => { + withInstalledShim(({ wrappers, backups }) => { + rmSync(backups[0]!); + linkSync(wrappers[0]!, backups[0]!); + expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toEqual({ + status: "unknown", + reason: "ambiguous_match", + }); + }); + }); + +}); diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index 5eca57d3d1..d44855b2da 100644 --- a/tests/ocx-launcher-source.test.ts +++ b/tests/ocx-launcher-source.test.ts @@ -28,7 +28,7 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(spawnCall).toContain("[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source"); // Path and source come from one resolution, so the marker cannot describe another binary. - expect(source).toContain("const bunRuntime = resolveBun();"); + expect(source).toContain("const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection });"); expect(source).toContain("const bun = bunRuntime.path;"); expect(source).toContain('return { path: bin, source: "bundled" };'); @@ -36,6 +36,16 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(runtimeSource).toContain('export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";'); }); + test("the updater inspection namespace rejects direct Bun execution of the Node launcher", () => { + expect(source).toContain('codexCliUpdateInspection && typeof process.versions.bun === "string"'); + expect(source).toContain("codex-cli-update inspection must use the published Node launcher"); + }); + + test("the Node launcher proof-binds the bounded version-manager root allowlist", () => { + expect(source).toContain("CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS"); + expect(source).toContain("managerRoots: preBunCodexCliManagerRoots"); + }); + test("the long-running Bun child stays hidden under a headless Windows launcher (#1236)", () => { const spawnStart = source.indexOf("const child = spawn(bun, [cliPath"); expect(spawnStart).toBeGreaterThanOrEqual(0); @@ -79,12 +89,51 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(source).toContain("typeof process.env[name] === \"string\" && process.env[name] !== \"\""); }); + /** + * Windows caps a process environment block at 32,767 characters. The inspection snapshot + * already carries PATH, PATHEXT, and the manager-root slots as proof-bound values, and + * `inspectCodexCliInstall` reads them from that snapshot rather than the live environment. + * Inheriting them again spends the budget twice, so a large-but-valid shell environment + * could stop the Bun child from spawning and fail the command before it reports anything. + */ + test("the inspection child does not inherit a duplicate copy of the snapshotted values", () => { + expect(source).toContain("const inheritedEnv = { ...process.env };"); + expect(source).toContain("...inheritedEnv,"); + // Windows spells the variable `Path` in practice, so an upper-case-only delete would + // leave the duplicate behind. The match must be on the lowercase form of every key. + expect(source).toContain("if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];"); + expect(source).toContain('["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase())'); + + // The de-duplication is scoped to the one-shot inspection launch; every other launch + // must still inherit PATH, or the long-running proxy child loses its tooling lookup. + const guard = source.indexOf("if (codexCliUpdateInspection) {", source.indexOf("const inheritedEnv")); + expect(guard).toBeGreaterThan(-1); + + // The spawn must no longer splat the raw environment, or the deletes above are pointless. + const spawnStart = source.indexOf("const child = spawn(bun, [cliPath,"); + expect(spawnStart).toBeGreaterThan(-1); + expect(source.slice(spawnStart)).not.toContain("...process.env,"); + }); + + /** + * A bare `CODEX_CLI_PATH` such as `codex` is an executable-lookup name, not a relative + * path. Resolving it against the launch cwd would make the inspector treat it as an + * explicit path and stop searching the proof-captured PATH, so a working configuration + * would report as unavailable. + */ + test("only separator-bearing configured Codex paths are resolved against the launch cwd", () => { + expect(source).toContain("const preBunCodexCliPath = configuredCodexCliPath !== null"); + expect(source).toContain('configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)'); + expect(source).toContain("? resolve(configuredCodexCliPath)"); + expect(source).toContain(": configuredCodexCliPath;"); + }); + test("valid Bun overrides are selected before the bundled runtime", () => { expect(source).toContain('const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";'); expect(source).toContain("const overridePath = resolve(override);"); expect(source).toContain('if (isRealBunBinary(overridePath)) return { path: overridePath, source: "override" };'); - const resolveStart = source.indexOf("function resolveBun() {"); + const resolveStart = source.indexOf("function resolveBun({ allowInstall = true } = {}) {"); const overrideCheck = source.indexOf("process.env[BUN_OVERRIDE_ENV]?.trim()", resolveStart); const overrideResolve = source.indexOf("resolve(override)", overrideCheck); const bundledLookup = source.indexOf("bunDir = bunBinDir()", resolveStart);