From 24d5faaa06df78fef56acabdf50c75939aefed1b Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:15:06 +0200 Subject: [PATCH 01/18] feat(cli): add ocx codex launcher mirroring ocx claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ocx codex [codex args...] gives Codex the claude/opencode launcher pattern on its config-owned wiring channel: ensure the proxy is running, then exec codex unchanged. A launch never rewrites ~/.codex/config.toml; the one injection is the shim's own — a missing OPENCODEX_API_AUTH_TOKEN is re-exported from the service token file. --help/-h, --version/-V, and Codex-internal subcommands skip the ensure with the environment passed through, matching the shim's argv grammar; its option and internal-command sets are now exported from shim-templates so both codex entry points share one source. Native routing launches untouched after an informational notice, foreign gateways pass through with a warning and are never started against, and unverifiable routing still ensures so a routed session is not stranded on a dead port. Ensure failure mirrors the shim: one stderr line, then codex launches anyway. Structure SSOT lists updated (codex-home detached starts, service-and-sidecars sibling-marker companions) and the docs-site CLI reference section is mirrored across all eight locales. --- .../content/docs/fr/reference/cli/agents.md | 19 ++ .../content/docs/ja/reference/cli/agents.md | 16 ++ .../content/docs/ko/reference/cli/agents.md | 14 ++ .../src/content/docs/reference/cli/agents.md | 17 ++ .../content/docs/ru/reference/cli/agents.md | 16 ++ .../content/docs/tr/reference/cli/agents.md | 17 ++ .../docs/zh-cn/reference/cli/agents.md | 13 + .../docs/zh-tw/reference/cli/agents.md | 13 + scripts/test-layout/layout.json | 1 + src/cli/codex-launcher.ts | 228 ++++++++++++++++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/registry.ts | 18 ++ src/codex/shim-templates.ts | 7 +- structure/codex-home.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- .../codex-integration/codex-launcher.test.ts | 187 ++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 18 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 src/cli/codex-launcher.ts create mode 100644 tests/codex-integration/codex-launcher.test.ts diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 1337a76d0ce..25346d40c99 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -169,6 +169,25 @@ Les familles sont `opus`, `fable`, `sonnet` et `haiku` ; les nouveaux itinérai seulement quand cette famille est vide. Les anciens indicateurs d'application `--static`, `--hybrid` et `--discovery-only` restent soutenus. Utilisez `ocx claude config ...` pour les réglages Claude Code. +### `ocx codex [codex args...]` + +Vérifiez que le proxy est actif, puis lancez `codex` sans modification. Le routage de Codex reste +la propriété de sa configuration — le `openai_base_url` injecté dans `~/.codex/config.toml` — +aucun lancement ne réécrit donc de fichier de configuration. Un `OPENCODEX_API_AUTH_TOKEN` absent +est renseigné depuis `~/.opencodex/service-api-token`, la même injection que celle du shim de +démarrage automatique Codex. + +`--help`/`-h`, `--version`/`-V` et les sous-commandes internes de Codex (`login`, `doctor`, `exec`, +...) sautent l'assurance du proxy et traversent l'environnement inchangé, en reprenant la +grammaire d'arguments du shim. + +Le routage est examiné avant exécution : sans routage injecté, un message informatif s'affiche et +`codex` est lancé sans assurance du proxy ; une passerelle étrangère (custom-local/custom-remote) +provoque un avertissement et un passage sans toucher à cette route ; un routage invérifiable +provoque un avertissement tout en assurant quand même le proxy, pour qu'une session routée ne +reste pas sur un port mort. Un lancement ultérieur de `codex` sans opencodex se comporte +exactement comme auparavant. + ### `ocx opencode [opencode args...]` Vérifiez que le proxy est actif, puis lancez opencode avec les blocs `provider.opencodex` et `providers.opencodex` générés dans la couche d’exécution intégrée d’OpenCode (`OPENCODE_CONFIG_CONTENT`). La configuration intégrée existante est préservée et seules ces deux clés sont remplacées pour ce lancement. Les fichiers `opencode.json` globaux ou propres au projet peuvent être lus afin de signaler une substitution existante, mais les fichiers sur disque ne sont jamais modifiés. Les modèles routés apparaissent sous la forme `opencodex//`. Un lancement ultérieur de `opencode` sans intermédiaire se comporte exactement comme auparavant. diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index ebe43edfffc..f254942d74d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -130,6 +130,22 @@ ocx claude desktop import [--apply] Validate and import JSON ファミリは `opus`、`fable`、`sonnet`、および `haiku` です。新しいルートは `opus` で始まります。 `none` は、そのファミリーが空の場合にのみ有効です。従来の適用フラグ `--static`、`--hybrid`、および `--discovery-only` は引き続きサポートされます。クロードコードの設定には`ocx claude config ...`を使用してください。 +### `ocx codex [codex args...]` + +プロキシが実行されていることを確認し、`codex` をそのまま起動します。Codex のルーティングは設定 +(`~/.codex/config.toml` に注入された `openai_base_url`) が所有しているため、この起動は設定 +ファイルを書き換えません。環境に `OPENCODEX_API_AUTH_TOKEN` がない場合は、Codex オートスタート +シムと同じ注入で `~/.opencodex/service-api-token` から補完します。 + +`--help`/`-h`、`--version`/`-V`、および Codex 内部のサブコマンド (`login`、`doctor`、`exec` な +ど) はプロキシの guarantee をスキップし、環境をそのまま渡します (シムの引数文法と一致)。 + +実行前にルーティングを判定します。注入されたルーティングがない場合は情報メッセージを出し、guarantee +なしで `codex` を起動します。外部ゲートウェイ (custom-local/custom-remote) では警告を出し、その +ルートに触れずに渡します。検証できないルーティングでは警告を添えて guarantee を実行し、ルーティング +されたセッションが死んだポートに取り残されないようにします。後でプレーン `codex` を起動すると、 +以前とまったく同じように動作します。 + ### `ocx opencode [opencode args...]` プロキシが実行されていることを確認し、OpenCode のインライン ランタイム層 (`OPENCODE_CONFIG_CONTENT`) で生成された `provider.opencodex` および `providers.opencodex` ブロックを使用してオープンコードを起動します。既存のインライン設定は保持され、今回の起動ではこの 2 つのキーのみが置き換えられます。グローバルまたはプロジェクトの `opencode.json` ファイルは、既存の上書きについて警告するために読み取られることがありますが、ディスク上のファイルは変更されません。ルーティングされたモデルは `opencodex//` として表示されます。後でプレーン `opencode` を起動すると、以前とまったく同じように動作します。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index c8bb7e53731..6d60bac6bf6 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -162,6 +162,20 @@ ocx claude desktop import [--apply] Validate and import JSON family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 시작합니다. `none`은 해당 family가 비어 있을 때만 유효합니다. 레거시 apply 플래그인 `--static`, `--hybrid`, `--discovery-only`도 계속 지원합니다. Claude Code 설정은 `ocx claude config ...`를 사용하십시오. +### `ocx codex [codex args...]` + +프록시가 실행 중인지 확인한 뒤, `codex`를 그대로 실행합니다. Codex 라우팅은 config(`~/.codex/config.toml`에 주입된 +`openai_base_url`)가 소유하므로, 이 실행은 config 파일을 재작성하지 않습니다. 환경에 `OPENCODEX_API_AUTH_TOKEN`이 +없으면 `~/.opencodex/service-api-token`에서 채웁니다. 이는 Codex 자동 시작 shim과 동일한 주입입니다. + +`--help`/`-h`, `--version`/`-V` 및 Codex 내부 하위 명령(`login`, `doctor`, `exec` 등)은 프록시 ensure를 +건너뛰고 환경을 그대로 전달합니다(shim의 argv 문법과 일치). + +실행 전에 라우팅을 확인합니다. 주입된 라우팅이 없으면 안내 메시지를 출력하고 ensure 없이 `codex`를 실행하고, +다른 로컬/원격 게이트웨이(custom-local/custom-remote)는 경고를 출력한 뒤 해당 경로를 건드리지 않고 전달하며, +확인 불가능한 라우팅은 경고하더라도 ensure를 수행해 라우팅 세션이 죽은 포트에 남지 않게 합니다. 이후 plain +`codex`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. + ### `ocx opencode [opencode args...]` 프록시가 실행 중인지 확인한 뒤, OpenCode의 인라인 런타임 계층(`OPENCODE_CONFIG_CONTENT`)에 생성된 `provider.opencodex` 및 `providers.opencodex` 블록을 넣어 opencode를 실행합니다. 기존 인라인 config는 유지되고, 이번 실행에서는 이 두 키만 교체됩니다. 전역 또는 프로젝트 `opencode.json` 파일은 기존 override가 있는지 경고하기 위해 읽을 수 있지만, 디스크상의 파일은 절대 수정하지 않습니다. 라우팅된 model은 `opencodex//`로 나타납니다. 이후 plain `opencode`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index c63aef492ca..bc2e7ddc3b6 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -935,6 +935,23 @@ native-mode flags are rejected; failed runtime save has no local fallback. Apply is a separate existing Desktop action on its machine, so verify host/context before choosing it. Rendered models are not proof of native application. +### `ocx codex [codex args...]` + +Ensure the proxy is running, then launch `codex` unchanged. Codex routing stays config-owned — +the injected `openai_base_url` in `~/.codex/config.toml` — so a launch never rewrites a config +file. A missing `OPENCODEX_API_AUTH_TOKEN` is filled from `~/.opencodex/service-api-token`, +the same injection the Codex autostart shim performs. + +`--help`/`-h`, `--version`/`-V`, and Codex-internal subcommands (`login`, `doctor`, `exec`, ...) +skip the proxy ensure and pass the environment through unchanged, matching the shim's argv +grammar. + +Routing is examined before exec: no injected routing prints an informational notice and launches +`codex` with no ensure; a foreign gateway (custom-local/custom-remote) prints a warning and passes +through without touching that route; unverifiable routing prints a warning and still ensures, so a +routed session is not stranded on a dead port. Launching plain `codex` later behaves exactly as +before. + ### `ocx opencode [opencode args...]` Ensure the proxy is running, then launch opencode with the generated `provider.opencodex` and 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 1c24581fb08..117462db788 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -151,6 +151,22 @@ ocx claude desktop import [--apply] Validate and import JSON `--hybrid` и `--discovery-only` для apply по-прежнему поддерживаются. Для настроек Claude Code используйте `ocx claude config ...`. +### `ocx codex [codex args...]` + +Убедиться, что прокси запущен, и затем запустить `codex` без изменений. Маршрутизация Codex остаётся +владением config — инжектированный `openai_base_url` в `~/.codex/config.toml` — поэтому запуск +никогда не переписывает файлы конфигурации. Отсутствующий `OPENCODEX_API_AUTH_TOKEN` берётся из +`~/.opencodex/service-api-token` — та же инъекция, что и в autostart шиме Codex. + +`--help`/`-h`, `--version`/`-V` и внутренние подкоманды Codex (`login`, `doctor`, `exec`, ...) +пропускают ensure прокси и передают окружение как есть — та же грамматика argv, что и у шима. + +Роутинг проверяется перед запуском: без инжектированного роутинга печатается информационное сообщение +и `codex` запускается без ensure; чужой шлюз (custom-local/custom-remote) — warning и passthrough без +касания этого пути; непроверяемый роутинг — warning, но ensure всё равно выполняется, чтобы +маршрутизируемая сессия не осталась на мёртвом порту. Последующий запуск обычного `codex` работает +ровно как раньше. + ### `ocx opencode [opencode args...]` Убедиться, что прокси запущен, и затем запустить opencode со сгенерированными блоками 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 132a02705d3..79a1385971c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -187,6 +187,23 @@ başlar. `none` yalnızca o aile boş olduğunda geçerlidir. Eski uygulama bayrakları `--static`, `--hybrid` ve `--discovery-only` desteklenmeye devam eder. Claude Code ayarları için `ocx claude config ...` kullanın. +### `ocx codex [codex args...]` + +Proxy'nin çalıştığından emin olun, ardından `codex`'i değiştirmeden başlatın. Codex yönlendirmesi +yapılandırmaya aittir (`~/.codex/config.toml` içine yazılan `openai_base_url`), bu yüzden bir +başlatma hiçbir zaman yapılandırma dosyalarını değiştirmez. Eksik `OPENCODEX_API_AUTH_TOKEN`, +`~/.opencodex/service-api-token` üzerinden doldurulur — Codex otomatik başlatma shim'inin yaptığı +injection ile aynı. + +`--help`/`-h`, `--version`/`-V` ve Codex'in iç alt komutları (`login`, `doctor`, `exec`, ...) proxy +garantisini atlar ve ortamı değiştirmeden geçirir — shim'in argv dilbilgisiyle aynı. + +Başlatmadan önce yönlendirme değerlendirilir. Yazılmış yönlendirme yoksa bilgilendirme yazılır ve +`codex` garanti olmadan başlatılır; yabancı bir ağ geçidi (custom-local/custom-remote) uyarı yazar +ve o yola dokunmadan geçer; doğrulanamayan yönlendirme uyarı yazar ama garanti yine de gerçekleşir, +böylece yönlendirilmiş bir oturum ölü bir porta kalmaz. Daha sonra düz `codex` başlatmak tam +olarak eskisi gibi davranır. + ### `ocx opencode [opencode argumanlari...]` Proxy'nin çalıştığından emin olun, ardından OpenCode'un satır içi çalışma zamanı 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 f3b95ff0f58..d162f60601e 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 @@ -134,6 +134,19 @@ ocx claude desktop import [--apply] Validate and import JSON 这些 family 是 `opus`、`fable`、`sonnet` 和 `haiku`;新路由默认进入 `opus`。只有在该 family 为空时,`none` 才有效。旧版 apply 标志 `--static`、`--hybrid` 和 `--discovery-only` 仍受支持。Claude Code 设置请使用 `ocx claude config ...`。 +### `ocx codex [codex args...]` + +确保代理正在运行,然后原样启动 `codex`。Codex 的路由始终归配置所有(`~/.codex/config.toml` 中注入的 +`openai_base_url`),因此启动不会改写任何配置文件。环境缺少 `OPENCODEX_API_AUTH_TOKEN` 时,会从 +`~/.opencodex/service-api-token` 补上,与 Codex 自动启动 shim 采用相同的注入方式。 + +`--help`/`-h`、`--version`/`-V` 以及 Codex 内部子命令(`login`、`doctor`、`exec` 等)会跳过代理 +ensure 并原样传递环境,与 shim 的 argv 语法保持一致。 + +执行前会先判定路由:没有注入路由时输出提示信息,并不做 ensure 直接启动 `codex`;外部网关(custom-local/custom-remote) +会输出警告,按原样通过且不触碰该路由;无法验证的路由会输出警告但仍执行 ensure,避免被路由的会话卡在死端口上。 +稍后再启动普通 `codex` 时,行为与之前完全一致。 + ### `ocx opencode [opencode args...]` 确保代理正在运行,然后在 OpenCode 的内联运行时层(`OPENCODE_CONFIG_CONTENT`)中启动 opencode,并注入生成的 `provider.opencodex` 和 `providers.opencodex` 块。现有的内联配置会被保留,仅本次启动会替换这两个键。可能会读取全局或项目级 `opencode.json` 文件以警告已有覆盖,但不会修改磁盘上的文件。路由后的模型会显示为 `opencodex//`。稍后再次启动普通 `opencode` 时,行为与之前完全一致。 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 4728a5f131b..3d3664f32d0 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 @@ -131,6 +131,19 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON 家族為 `opus`、`fable`、`sonnet` 與 `haiku`;新路由從 `opus` 開始。`none` 僅在該家族為空時有效。舊版套用旗標 `--static`、`--hybrid` 與 `--discovery-only` 仍受支援。請用 `ocx claude config ...` 管理 Claude Code 設定。 +### `ocx codex [codex args...]` + +確保代理正在執行,然後原樣啟動 `codex`。Codex 的路由始終由設定擁有(`~/.codex/config.toml` 中注入的 +`openai_base_url`),因此啟動不會改寫任何設定檔。環境缺少 `OPENCODEX_API_AUTH_TOKEN` 時,會從 +`~/.opencodex/service-api-token` 補上,與 Codex 自動啟動 shim 採用相同的注入方式。 + +`--help`/`-h`、`--version`/`-V` 以及 Codex 內部子命令(`login`、`doctor`、`exec` 等)會跳過代理 +ensure 並原樣傳遞環境,與 shim 的 argv 語法保持一致。 + +執行前會先判定路由:沒有注入路由時輸出提示訊息,並不做 ensure 直接啟動 `codex`;外部閘道(custom-local/custom-remote) +會輸出警告並原樣通過、不觸碰該路由;無法驗證的路由會輸出警告但仍執行 ensure,避免被路由的工作階段卡在失效連接埠上。 +之後啟動普通 `codex` 的行為與之前完全相同。 + ### `ocx opencode [opencode args...]` 確保代理正在執行,然後在 OpenCode 的內嵌執行階段層(`OPENCODE_CONFIG_CONTENT`)中以生成的 `provider.opencodex` 與 `providers.opencodex` 區塊啟動 opencode。既有的內嵌設定會被保留,本次啟動僅替換這兩個鍵。全域或專案的 `opencode.json` 檔案可能被讀取以警告既有的覆寫,但磁碟上的檔案永不修改。路由模型以 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d9502640531..5eab84a70d9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -705,6 +705,7 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", + "codex-launcher.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts new file mode 100644 index 00000000000..a1b8d2b0e9c --- /dev/null +++ b/src/cli/codex-launcher.ts @@ -0,0 +1,228 @@ +/** + * `ocx codex [codex args...]` — launch Codex wired to the local proxy's routing, + * passing through untouched when Codex routing is disabled or foreign. + * + * Mirrors `ocx claude` (src/cli/claude.ts) and `ocx opencode` (src/cli/opencode.ts): + * ensures the proxy is running, then execs the client with stdio inherited. The wiring + * channel differs again — Codex routing is config-owned (the injected `openai_base_url` + * and model catalog in `~/.codex/config.toml`), so a launch never rewrites a config + * file. What the launcher owns instead is the two add-ons `plain codex` lacks: + * + * - launch-time autostart: the same detached `ocx start` + health poll that + * `ocx ensure` gives service- and shim-less setups, so a routed launch is never + * stranded on a dead proxy; + * - an honest route verdict before exec: native and foreign-gateway configs are + * announced (and NOT ensured) instead of silently benefiting or fighting them, + * reusing the routing classification the shim and doctor share + * (src/codex/inject/routing-classify.ts). + * + * The admission token is re-exported from `~/.opencodex/service-api-token` when the + * environment does not carry it — the same injection the generated Codex autostart + * shim performs (src/codex/shim-templates.ts), whose argv fast-path grammar + * (--help/-h, --version/-V, internal subcommands, global options with values) is + * mirrored here so both `codex` entry points behave identically for the same argv. + */ +import { spawn } from "node:child_process"; +import { loadConfig } from "../config"; +import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; +import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; +import { withoutSiblingMarker } from "../codex/sibling-start"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { commandInvocation } from "../lib/win-exec"; +import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; + +export interface CodexLaunchEnv { + [key: string]: string | undefined; +} + +type FastPathReason = "help" | "version" | "internal"; + +/** + * Mirrors the generated shim wrapper's argv grammar (src/codex/shim-templates.ts): + * an invocation whose first positional operand is a Codex-internal subcommand, or + * which only asks for help/version, never needs the proxy. The shell scanners + * inline these sets — the exported constants here are the single source they are + * read from — while the option-value rules (`--model x`, `-c k=v`, ...) stay in + * this scanner in sync with the same templates. + */ +export function codexFastPathReason(args: readonly string[]): FastPathReason | null { + let skipNext = false; + for (const arg of args) { + if (skipNext) { skipNext = false; continue; } + if (arg === "--") return null; // the shim's `break`: rest is operands, ensure runs + const options = CODEX_GLOBAL_OPTIONS_WITH_VALUE; + if (options.includes(arg)) { skipNext = true; continue; } + if (arg === "--help" || arg === "-h") return "help"; + if (arg === "--version" || arg === "-V") return "version"; + if (arg.startsWith("-")) continue; + const value = arg; + return (CODEX_INTERNAL_COMMANDS.includes(value)) ? "internal" : null; + } + return null; +} + +export const CODEX_ROUTING_NATIVE_NOTICE = + "ℹ️ Codex does not route through OpenCodex (no injected routing in ~/.codex/config.toml). Launching `codex` unchanged. Run `ocx ensure` to start the proxy and sync Codex config."; + +export const CODEX_ROUTING_UNKNOWN_NOTICE = + "⚠️ Codex routing could not be verified. Ensuring the proxy is up, but the launch may not be redirected; run `ocx doctor` before relying on it."; + +export const CODEX_ROUTING_CUSTOM_LOCAL_NOTICE = + "⚠️ Codex routes through a user-owned local gateway. OpenCodex does not own that route; launching `codex` unchanged. Run `ocx doctor` to verify that gateway."; + +export const CODEX_ROUTING_CUSTOM_REMOTE_NOTICE = + "⚠️ Codex routes through a remote gateway. OpenCodex does not own that route; launching `codex` unchanged."; + +export type CodexLaunchPlan = + | { kind: "routed" } + | { kind: "passthrough"; notice?: string; ensureProxy: boolean }; + +/** + * Pure launch decision from the shared routing classification: + * + * - `opencodex-local` → ensure + exec (the config this launcher exists for); + * - `native` / `custom-*` → exec unchanged, no proxy side effects; + * - `unknown` (unreadable/ambiguous config) → name it out loud, still ensure, + * because a routed session that starts without its proxy falls back to the + * configured URL's dead port and every request fails. + */ +export function codexLaunchPlan(routingKind: CodexRoutingKind): CodexLaunchPlan { + switch (routingKind) { + case "opencodex-local": + return { kind: "routed" }; + case "native": + return { kind: "passthrough", ensureProxy: false, notice: CODEX_ROUTING_NATIVE_NOTICE }; + case "custom-local": + return { kind: "passthrough", ensureProxy: false, notice: CODEX_ROUTING_CUSTOM_LOCAL_NOTICE }; + case "custom-remote": + return { kind: "passthrough", ensureProxy: false, notice: CODEX_ROUTING_CUSTOM_REMOTE_NOTICE }; + case "unknown": + return { kind: "passthrough", ensureProxy: true, notice: CODEX_ROUTING_UNKNOWN_NOTICE }; + } +} + +/** Env the detached `ocx start` child inherits (service-owned admission ladder). */ +export function codexProxyStartEnv(base: CodexLaunchEnv = process.env): CodexLaunchEnv { + const stripped = withoutSiblingMarker(base) as CodexLaunchEnv; + const withTokenFile = stripped.OPENCODEX_API_AUTH_TOKEN?.trim() + ? stripped + : { ...stripped, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; + return { ...withTokenFile, OCX_SERVICE: "1" } as CodexLaunchEnv; +} + +export type CodexProxyEnsureDeps = { + findLiveProxy?: typeof findLiveProxy; +}; + +/** + * Mirrors ensureProxyForClaude (src/cli/claude.ts) — the same borrowed liveness + * retry budget (#764), detached `ocx start` on the configured port, 8 s health + * poll — so all `ocx`-launched clients share one autostart contract. + */ +export async function ensureProxyForCodex(deps: CodexProxyEnsureDeps = {}): Promise { + const probe = deps.findLiveProxy ?? findLiveProxy; + const live = await probe({ attempts: 3 }); + if (live) return live; + const cfgPort = loadConfig().port; + const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { + detached: true, + stdio: "ignore", + windowsHide: true, + // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. + env: withProcessRuntimeProvenance(codexProxyStartEnv(process.env) as NodeJS.ProcessEnv), + }); + // Without a listener an 'error' (bad argv[1], EMFILE, AV denial) throws synchronously + // and kills this process; the health poll below already reports the failure properly. + child.on("error", () => { /* handled by the deadline loop returning null */ }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = await probe(); + if (started) return started; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; +} + +export type CodexLaunchEnvDeps = { + /** Test seam; production reads the hardened service token file. */ + serviceToken?: (base: CodexLaunchEnv) => string | null; +}; + +const defaultServiceToken = (base: CodexLaunchEnv): string | null => + loadServiceTokenFromFile({ OCX_API_TOKEN_FILE: serviceApiTokenFilePath(), ...base }); + +/** + * Launch env for a routed exec. The one injection is the shim's own: a missing + * OPENCODEX_API_AUTH_TOKEN is re-exported from the hardened service token file, so + * `ocx codex` from a shell without the system-env snapshot authenticates to the + * loopback proxy like a shimmed launch does. User exports always win; nothing else + * is added or removed — Codex's routing state lives in its config files, not here. + */ +export function buildCodexLaunchEnv( + base: CodexLaunchEnv = process.env, + deps: CodexLaunchEnvDeps = {}, +): CodexLaunchEnv { + const env = { ...base }; + if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { + const token = (deps.serviceToken ?? defaultServiceToken)(env); + if (token) env.OPENCODEX_API_AUTH_TOKEN = token; + } + return env; +} + +const CODEX_INSTALL_HINT = "❌ `codex` CLI not found. Install it first: npm install -g @openai/codex"; + +/** + * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd` + * shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints. + * Same contract as claudeNotFoundHint (devlog 260715_cross_platform_audit/020). + */ +export function codexNotFoundHint( + code: number | null, + signal: NodeJS.Signals | null, + platform: NodeJS.Platform = process.platform, +): string | null { + return platform === "win32" && code === 9009 && !signal ? CODEX_INSTALL_HINT : null; +} + +function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { + return new Promise(resolve => { + const inv = commandInvocation("codex", args); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + console.error(CODEX_INSTALL_HINT); + } else { + console.error(`❌ Failed to launch codex: ${err.message}`); + } + resolve(1); + }); + child.on("exit", (code, signal) => { + const hint = codexNotFoundHint(code, signal); + if (hint) console.error(hint); + resolve(signal ? 1 : code ?? 0); + }); + }); +} + +export async function cmdCodex(args: string[]): Promise { + if (codexFastPathReason(args) !== null) return spawnCodex(args, { ...process.env }); + const plan = codexLaunchPlan(getCodexRoutingKind()); + const notice = plan.kind === "passthrough" ? plan.notice : undefined; + if (notice) console.error(notice); + const needsProxy = plan.kind === "routed" + || (plan.kind === "passthrough" && plan.ensureProxy); + if (!needsProxy) return spawnCodex(args, { ...process.env }); + const live = await ensureProxyForCodex(); + if (!live) { + // Never lock the user out of `codex`: mirror the generated shim's behavior of + // launching anyway with one stderr line. The already-injected config decides + // what the session does next; `ocx doctor` names the autostart failure. + console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); + } + return spawnCodex(args, buildCodexLaunchEnv(process.env)); +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 2870660ee7a..3bc38b6f235 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -989,6 +989,10 @@ const commandRunners: Record = { } return await cmdClaude(deps.args.slice(1)); }, + codex: async deps => { + const { cmdCodex } = await import("./codex-launcher"); + return await cmdCodex(deps.args.slice(1)); + }, opencode: async deps => { const { cmdOpencode } = await import("./opencode"); return await cmdOpencode(deps.args.slice(1)); diff --git a/src/cli/help.ts b/src/cli/help.ts index 779526d2496..a1488e20094 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -104,6 +104,7 @@ Usage: ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile + ocx codex [args...] Launch Codex through the proxy's routing (pass-through when disabled) ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) ocx mcode [args...] Launch MiniMax Code through its managed provider ocx mmx text [args] Launch MiniMax CLI text through the proxy diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 5dd435db752..0ab3f2e9595 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -589,6 +589,24 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Claude Code settings: ocx claude config ...", ], }, + { + name: "codex", + usage: "ocx codex [codex args...]", + summary: "Launch Codex through the local proxy's routing, passing through when Codex routing is disabled.", + details: [ + "Mirrors `ocx claude` on Codex's config-owned wiring channel: ensures the proxy is running,", + "then execs `codex` unchanged. Routing itself stays config-owned (the injected `openai_base_url`", + "in ~/.codex/config.toml); a launch never rewrites a config file.", + "A missing OPENCODEX_API_AUTH_TOKEN is filled from ~/.opencodex/service-api-token, the same", + "injection the `codex` autostart shim performs.", + "--help/-h, --version/-V, and Codex-internal subcommands (login, doctor, exec, ...) skip the", + "proxy ensure and pass the environment through unchanged, keeping the shim's argv grammar.", + "No injected routing: informational notice, native exec, no ensure.", + "Foreign gateway routing (custom-local/custom-remote): warning, native exec, that route untouched.", + "Unverifiable routing: warning, still ensured so a routed session is not stranded on a dead port.", + "Stop using `ocx codex` and plain `codex` behaves exactly as before.", + ], + }, { name: "opencode", usage: "ocx opencode [opencode args...]", diff --git a/src/codex/shim-templates.ts b/src/codex/shim-templates.ts index 82d67869e25..682cbe89f1d 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -28,7 +28,10 @@ const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved */ export const CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC = "opencodex: proxy autostart failed; launching Codex anyway. Run 'ocx doctor' for details, or 'ocx restore' to hand Codex back to its own account."; -const CODEX_INTERNAL_COMMANDS = [ +// Shared with launchers that speak the same argv vocabulary (`ocx codex`): the +// generated shell templates inline this grammar, so the sets are the single +// source and the shell text is the consumer, not the definition. +export const CODEX_INTERNAL_COMMANDS = [ "app-server", "archive", "apply", @@ -53,7 +56,7 @@ const CODEX_INTERNAL_COMMANDS = [ // Codex accepts global options before a subcommand. The shim must skip the value belonging to // these options before it decides which first positional token is the real subcommand. Keep this // list aligned with `codex --help`; `--option=value` and attached short forms stay one token. -const CODEX_GLOBAL_OPTIONS_WITH_VALUE = [ +export const CODEX_GLOBAL_OPTIONS_WITH_VALUE = [ "-c", "--config", "--enable", "--disable", "--remote", "--remote-auth-token-env", diff --git a/structure/codex-home.md b/structure/codex-home.md index d541ba24ab5..41ffb392d6e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -196,7 +196,7 @@ prior sibling runtime and `OPENCODEX_HOME`. Connected-client recycle issues it b the listener or removing that runtime record. `handleStart` consumes the record before any probe; a forged port env or replay grants no sibling status. A valid replacement stays a sibling while the owner is down, and its journal recovery remains skipped. -Every other detached `ocx start` (`ocx ensure`, the tray, the `ocx claude`/`opencode`/`minimax` +Every other detached `ocx start` (`ocx ensure`, the tray, the `ocx claude`/`codex`/`opencode`/`minimax` auto-start and the updater's restart) starts an ordinary owner and strips an inherited marker through `withoutSiblingMarker`. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 5127a5452a8..91001af39a4 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -235,7 +235,7 @@ deliberate stop. Existing launchd jobs retain their old keepalive definition until `ocx service repair` rewrites and reloads the changed plist. New WinSW XML stamps `OCX_SERVICE_MANAGED=1`; parent command-line inference applies only to legacy registrations whose XML lacks that marker. Bare `OCX_SERVICE=1` -is never the marker because `ocx claude` and `ocx opencode` companions carry it too; a +is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too; a marker-less Windows registration is still recognised by the parent's command line naming this install's wrapper script, launcher or WinSW host as a complete token in any position, and an unreadable parent command line is no evidence and proceeds. POSIX keeps the diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts new file mode 100644 index 00000000000..127c9bc3a50 --- /dev/null +++ b/tests/codex-integration/codex-launcher.test.ts @@ -0,0 +1,187 @@ +/** + * `ocx codex [args...]` (src/cli/codex-launcher.ts): the `ocx claude`/`ocx opencode` + * launcher pattern on Codex's config-owned wiring channel. + * + * Covers the decision surface a launch lives on: the shim-grammar fast path, the + * routing-kind launch plan, the service-token env assembly for a routed exec, and the + * detached-start environment. Each is pure; process semantics (stdio inherit, exit-code + * propagation, ENOENT hints) are the shared spawn shape already exercised by + * tests/claude-integration/claude-cli.test.ts and the opencode launcher tests. + * + * The fast-path grammar is pinned against the sets exported by + * src/codex/shim-templates.ts, because the generated shell wrapper inlines the same + * decision: both `codex` entry points must autostart (or fast-path) identically. + */ +import { describe, expect, test } from "bun:test"; +import { + buildCodexLaunchEnv, + CODEX_ROUTING_CUSTOM_LOCAL_NOTICE, + CODEX_ROUTING_CUSTOM_REMOTE_NOTICE, + CODEX_ROUTING_NATIVE_NOTICE, + CODEX_ROUTING_UNKNOWN_NOTICE, + codexFastPathReason, + codexLaunchPlan, + codexNotFoundHint, + codexProxyStartEnv, + ensureProxyForCodex, +} from "../../src/cli/codex-launcher"; +import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS } from "../../src/codex/shim-templates"; +import { SIBLING_HANDOFF_NONCE_ENV, SIBLING_OF_PORT_ENV } from "../../src/codex/sibling-start"; +import type { LiveProxy } from "../../src/server/proxy-liveness"; + +describe("codex launcher fast path", () => { + test("help/version and internal subcommands skip the proxy ensure", () => { + expect(codexFastPathReason(["--version"])).toBe("version"); + expect(codexFastPathReason(["-V"])).toBe("version"); + expect(codexFastPathReason(["--help"])).toBe("help"); + expect(codexFastPathReason(["-h"])).toBe("help"); + expect(codexFastPathReason(["login"])).toBe("internal"); + expect(codexFastPathReason(["doctor", "--json"])).toBe("internal"); + }); + + test("global options with values are skipped before the first positional, matching the shim", () => { + expect(codexFastPathReason(["-m", "opencode-go/x", "login"])).toBe("internal"); + expect(codexFastPathReason(["--model", "opencode-go/x", "doctor"])).toBe("internal"); + expect(codexFastPathReason(["--profile", "p", "--config", "k=v", "--help"])).toBe("help"); + expect(codexFastPathReason(["--config=k=v", "logout"])).toBe("internal"); + expect(codexFastPathReason(["-m", "x", "--version"])).toBe("version"); + }); + + test("session-shaped argv autostarts", () => { + expect(codexFastPathReason([])).toBeNull(); + expect(codexFastPathReason(["resume"])).toBeNull(); + expect(codexFastPathReason(["-m", "opencode-go/x"])).toBeNull(); + expect(codexFastPathReason(["--model=opencode-go/x"])).toBeNull(); + expect(codexFastPathReason(["--dangerously-bypass-approvals-and-sandbox"])).toBeNull(); + expect(codexFastPathReason(["--"])).toBeNull(); + expect(codexFastPathReason(["--", "doctor"])).toBeNull(); + }); + + test("stays aligned with the generated shim's internal-command and option sets", () => { + for (const command of ["login", "logout", "doctor", "mcp", "help", "update", "app-server"]) { + expect(CODEX_INTERNAL_COMMANDS).toContain(command); + } + expect(CODEX_INTERNAL_COMMANDS).not.toContain("resume"); + for (const option of ["-m", "--model", "-c", "--config", "-p", "--profile"]) { + expect(CODEX_GLOBAL_OPTIONS_WITH_VALUE).toContain(option); + } + }); +}); + +describe("codex launcher plan", () => { + test("opencodex-local routing ensures the proxy and execs", () => { + expect(codexLaunchPlan("opencodex-local")).toEqual({ kind: "routed" }); + }); + + test("native routing passes through untouched and says so", () => { + const plan = codexLaunchPlan("native"); + expect(plan).toEqual({ + kind: "passthrough", + ensureProxy: false, + notice: CODEX_ROUTING_NATIVE_NOTICE, + }); + }); + + test("foreign gateways pass through untouched; that route is not ours to own", () => { + expect(codexLaunchPlan("custom-local")).toEqual({ + kind: "passthrough", + ensureProxy: false, + notice: CODEX_ROUTING_CUSTOM_LOCAL_NOTICE, + }); + expect(codexLaunchPlan("custom-remote")).toEqual({ + kind: "passthrough", + ensureProxy: false, + notice: CODEX_ROUTING_CUSTOM_REMOTE_NOTICE, + }); + }); + + test("unverifiable routing still ensures so a routed session is not stranded on a dead port", () => { + expect(codexLaunchPlan("unknown")).toEqual({ + kind: "passthrough", + ensureProxy: true, + notice: CODEX_ROUTING_UNKNOWN_NOTICE, + }); + }); +}); + +describe("codex launch env assembly", () => { + test("a missing admission token comes from the service token ladder", () => { + const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { serviceToken: () => "tok-from-file" }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("tok-from-file"); + }); + + test("a user export always wins; the resolver is not even consulted", () => { + let consulted = false; + const env = buildCodexLaunchEnv( + { OPENCODEX_API_AUTH_TOKEN: "user-token" }, + { serviceToken: () => { consulted = true; return "other"; } }, + ); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("user-token"); + expect(consulted).toBe(false); + }); + + test("a blank inherited token counts as absent", () => { + const env = buildCodexLaunchEnv( + { OPENCODEX_API_AUTH_TOKEN: " " }, + { serviceToken: () => "tok-from-file" }, + ); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("tok-from-file"); + }); + + test("an absent token resolves to nothing and stays absent", () => { + const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { serviceToken: () => null }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + }); +}); + +describe("codex detached-start env", () => { + test("carries the service marker and defaults the hardened token file", () => { + const env = codexProxyStartEnv({ PATH: "/usr/bin" }); + expect(env.OCX_SERVICE).toBe("1"); + expect(env.OCX_API_TOKEN_FILE!.endsWith("service-api-token")).toBe(true); + }); + + test("an inherited admission token avoids the token-file lookup", () => { + const env = codexProxyStartEnv({ OPENCODEX_API_AUTH_TOKEN: "env-token" }); + expect(env.OCX_API_TOKEN_FILE).toBeUndefined(); + expect(env.OCX_SERVICE).toBe("1"); + }); + + test("sibling markers never leak into a detached start child", () => { + const env = codexProxyStartEnv({ + [SIBLING_HANDOFF_NONCE_ENV]: "n", + [SIBLING_OF_PORT_ENV]: "10100", + OCX_SERVICE: "0", + }); + expect(env[SIBLING_HANDOFF_NONCE_ENV]).toBeUndefined(); + expect(env[SIBLING_OF_PORT_ENV]).toBeUndefined(); + expect(env.OCX_SERVICE).toBe("1"); + }); +}); + +describe("codex launcher proxy ensure", () => { + test("borrows the claude launcher's freshly-bound retry budget (#764)", async () => { + const attempts: Array = []; + const live: LiveProxy = { pid: 4242, port: 10100, source: "runtime" }; + const result = await ensureProxyForCodex({ + findLiveProxy: async io => { + attempts.push(io?.attempts); + return live; + }, + }); + expect(result).toEqual(live); + expect(attempts).toEqual([3]); + }); +}); + +describe("codex launcher not-found hints", () => { + test("cmd.exe's 9009 on win32 names the install path", () => { + expect(codexNotFoundHint(9009, null, "win32")).toContain("npm install -g @openai/codex"); + }); + + test("POSIX exits and any signal exit are not hints", () => { + expect(codexNotFoundHint(127, null, "linux")).toBeNull(); + expect(codexNotFoundHint(1, "SIGINT", "win32")).toBeNull(); + expect(codexNotFoundHint(null, null, "linux")).toBeNull(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3a82672e452..f85d409881e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -563,6 +563,7 @@ "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", + "codex-launcher.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim-standalone.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", From 458bca2f772502f4ba0e0d68dadf818b3c1094d4 Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:50:37 +0200 Subject: [PATCH 02/18] fix(cli): launch the backing codex when ocx codex resolves to the shim The launcher already decided whether to ensure the proxy; running the autostart shim would ensure again and load the admission token even on a native or custom passthrough. Exec the recorded backing Codex instead, and set OCX_SHIM_BYPASS when the shim state cannot be read. Docs and help now say the unknown-routing path attempts the ensure and still launches with one stderr line if it fails. --- .../content/docs/fr/reference/cli/agents.md | 6 ++- .../content/docs/ja/reference/cli/agents.md | 9 ++-- .../content/docs/ko/reference/cli/agents.md | 4 +- .../src/content/docs/reference/cli/agents.md | 7 ++-- .../content/docs/ru/reference/cli/agents.md | 5 ++- .../content/docs/tr/reference/cli/agents.md | 5 ++- .../docs/zh-cn/reference/cli/agents.md | 3 +- .../docs/zh-tw/reference/cli/agents.md | 3 +- src/cli/codex-launcher.ts | 41 ++++++++++++++++--- src/cli/registry.ts | 5 ++- .../codex-integration/codex-launcher.test.ts | 36 +++++++++++++++- 11 files changed, 100 insertions(+), 24 deletions(-) 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 25346d40c99..29e0966fe1c 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -184,8 +184,10 @@ grammaire d'arguments du shim. Le routage est examiné avant exécution : sans routage injecté, un message informatif s'affiche et `codex` est lancé sans assurance du proxy ; une passerelle étrangère (custom-local/custom-remote) provoque un avertissement et un passage sans toucher à cette route ; un routage invérifiable -provoque un avertissement tout en assurant quand même le proxy, pour qu'une session routée ne -reste pas sur un port mort. Un lancement ultérieur de `codex` sans opencodex se comporte +provoque un avertissement et tente quand même d'assurer le proxy. Si cette tentative échoue, une +ligne s'affiche sur stderr et `codex` est lancé malgré tout. Lorsque `codex` correspond au shim de +démarrage automatique, le lanceur exécute directement le Codex sous-jacent pour que le shim ne +répète pas l'assurance. Un lancement ultérieur de `codex` sans opencodex se comporte exactement comme auparavant. ### `ocx opencode [opencode args...]` 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 f254942d74d..6811fa2b643 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -138,12 +138,13 @@ ocx claude desktop import [--apply] Validate and import JSON シムと同じ注入で `~/.opencodex/service-api-token` から補完します。 `--help`/`-h`、`--version`/`-V`、および Codex 内部のサブコマンド (`login`、`doctor`、`exec` な -ど) はプロキシの guarantee をスキップし、環境をそのまま渡します (シムの引数文法と一致)。 +ど) はプロキシの ensure をスキップし、環境をそのまま渡します (シムの引数文法と一致)。 -実行前にルーティングを判定します。注入されたルーティングがない場合は情報メッセージを出し、guarantee +実行前にルーティングを判定します。注入されたルーティングがない場合は情報メッセージを出し、ensure なしで `codex` を起動します。外部ゲートウェイ (custom-local/custom-remote) では警告を出し、その -ルートに触れずに渡します。検証できないルーティングでは警告を添えて guarantee を実行し、ルーティング -されたセッションが死んだポートに取り残されないようにします。後でプレーン `codex` を起動すると、 +ルートに触れずに渡します。検証できないルーティングでは警告を添えて ensure を試みます。ensure に失敗した場合は +stderr に 1 行出力し、それでも `codex` を起動します。`codex` がオートスタートシムを指す場合は、シムが +ensure を繰り返さないよう、その背後にある Codex を直接起動します。後でプレーン `codex` を起動すると、 以前とまったく同じように動作します。 ### `ocx opencode [opencode args...]` 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 6d60bac6bf6..a556eb37fbf 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -173,7 +173,9 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 실행 전에 라우팅을 확인합니다. 주입된 라우팅이 없으면 안내 메시지를 출력하고 ensure 없이 `codex`를 실행하고, 다른 로컬/원격 게이트웨이(custom-local/custom-remote)는 경고를 출력한 뒤 해당 경로를 건드리지 않고 전달하며, -확인 불가능한 라우팅은 경고하더라도 ensure를 수행해 라우팅 세션이 죽은 포트에 남지 않게 합니다. 이후 plain +확인 불가능한 라우팅은 경고를 출력하고 ensure를 시도합니다. ensure가 실패하면 stderr에 한 줄을 출력하고 +그래도 `codex`를 실행합니다. `codex`가 자동 시작 shim을 가리키면 shim이 ensure를 반복하지 않도록 그 뒤의 Codex를 +직접 실행합니다. 이후 plain `codex`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index bc2e7ddc3b6..299414483e2 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -948,9 +948,10 @@ grammar. Routing is examined before exec: no injected routing prints an informational notice and launches `codex` with no ensure; a foreign gateway (custom-local/custom-remote) prints a warning and passes -through without touching that route; unverifiable routing prints a warning and still ensures, so a -routed session is not stranded on a dead port. Launching plain `codex` later behaves exactly as -before. +through without touching that route; unverifiable routing prints a warning and still attempts the ensure. If the ensure fails, one stderr +line is printed and `codex` launches anyway. When `codex` resolves to the autostart shim, the +launcher runs the backing Codex directly so the shim does not repeat the ensure. Launching plain +`codex` later behaves exactly as before. ### `ocx opencode [opencode args...]` 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 117462db788..c7cc96ed365 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -163,8 +163,9 @@ ocx claude desktop import [--apply] Validate and import JSON Роутинг проверяется перед запуском: без инжектированного роутинга печатается информационное сообщение и `codex` запускается без ensure; чужой шлюз (custom-local/custom-remote) — warning и passthrough без -касания этого пути; непроверяемый роутинг — warning, но ensure всё равно выполняется, чтобы -маршрутизируемая сессия не осталась на мёртвом порту. Последующий запуск обычного `codex` работает +касания этого пути; непроверяемый роутинг — warning, но попытка ensure всё равно выполняется. Если она +не удалась, в stderr выводится одна строка, и `codex` всё равно запускается. Если `codex` указывает на +autostart шим, лаунчер запускает исходный Codex напрямую, чтобы шим не повторял ensure. Последующий запуск обычного `codex` работает ровно как раньше. ### `ocx opencode [opencode args...]` 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 79a1385971c..6227374ddd7 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -200,8 +200,9 @@ garantisini atlar ve ortamı değiştirmeden geçirir — shim'in argv dilbilgis Başlatmadan önce yönlendirme değerlendirilir. Yazılmış yönlendirme yoksa bilgilendirme yazılır ve `codex` garanti olmadan başlatılır; yabancı bir ağ geçidi (custom-local/custom-remote) uyarı yazar -ve o yola dokunmadan geçer; doğrulanamayan yönlendirme uyarı yazar ama garanti yine de gerçekleşir, -böylece yönlendirilmiş bir oturum ölü bir porta kalmaz. Daha sonra düz `codex` başlatmak tam +ve o yola dokunmadan geçer; doğrulanamayan yönlendirme uyarı yazar ama garanti yine de denenir. +Deneme başarısız olursa stderr'e tek satır yazılır ve `codex` yine de başlatılır. `codex` otomatik +başlatma shim'ine çözülürse, shim garantiyi tekrarlamasın diye arkasındaki Codex doğrudan başlatılır. Daha sonra düz `codex` başlatmak tam olarak eskisi gibi davranır. ### `ocx opencode [opencode argumanlari...]` 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 d162f60601e..3692ea0e94c 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 @@ -144,7 +144,8 @@ ocx claude desktop import [--apply] Validate and import JSON ensure 并原样传递环境,与 shim 的 argv 语法保持一致。 执行前会先判定路由:没有注入路由时输出提示信息,并不做 ensure 直接启动 `codex`;外部网关(custom-local/custom-remote) -会输出警告,按原样通过且不触碰该路由;无法验证的路由会输出警告但仍执行 ensure,避免被路由的会话卡在死端口上。 +会输出警告,按原样通过且不触碰该路由;无法验证的路由会输出警告但仍尝试 ensure;ensure 失败时会在 stderr 输出一行,并仍然启动 `codex`。 +当 `codex` 指向自动启动 shim 时,启动器会直接运行其背后的 Codex,避免 shim 重复 ensure。 稍后再启动普通 `codex` 时,行为与之前完全一致。 ### `ocx opencode [opencode args...]` 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 3d3664f32d0..76eed3328e0 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 @@ -141,7 +141,8 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ensure 並原樣傳遞環境,與 shim 的 argv 語法保持一致。 執行前會先判定路由:沒有注入路由時輸出提示訊息,並不做 ensure 直接啟動 `codex`;外部閘道(custom-local/custom-remote) -會輸出警告並原樣通過、不觸碰該路由;無法驗證的路由會輸出警告但仍執行 ensure,避免被路由的工作階段卡在失效連接埠上。 +會輸出警告並原樣通過、不觸碰該路由;無法驗證的路由會輸出警告但仍嘗試 ensure;ensure 失敗時會在 stderr 輸出一行,並仍然啟動 `codex`。 +當 `codex` 指向自動啟動 shim 時,啟動器會直接執行其背後的 Codex,避免 shim 重複 ensure。 之後啟動普通 `codex` 的行為與之前完全相同。 ### `ocx opencode [opencode args...]` diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index a1b8d2b0e9c..8b064c2015d 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -9,8 +9,7 @@ * file. What the launcher owns instead is the two add-ons `plain codex` lacks: * * - launch-time autostart: the same detached `ocx start` + health poll that - * `ocx ensure` gives service- and shim-less setups, so a routed launch is never - * stranded on a dead proxy; + * `ocx ensure` gives service- and shim-less setups, attempted before every routed launch; * - an honest route verdict before exec: native and foreign-gateway configs are * announced (and NOT ensured) instead of silently benefiting or fighting them, * reusing the routing classification the shim and doctor share @@ -25,12 +24,14 @@ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; +import { inspectCodexShimBackingForCommand } from "../codex/shim"; import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; import { withoutSiblingMarker } from "../codex/sibling-start"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { commandInvocation } from "../lib/win-exec"; +import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; export interface CodexLaunchEnv { @@ -84,8 +85,8 @@ export type CodexLaunchPlan = * * - `opencodex-local` → ensure + exec (the config this launcher exists for); * - `native` / `custom-*` → exec unchanged, no proxy side effects; - * - `unknown` (unreadable/ambiguous config) → name it out loud, still ensure, - * because a routed session that starts without its proxy falls back to the + * - `unknown` (unreadable/ambiguous config) → name it out loud and still attempt + * the ensure, because a routed session that starts without its proxy hits the * configured URL's dead port and every request fails. */ export function codexLaunchPlan(routingKind: CodexRoutingKind): CodexLaunchPlan { @@ -189,10 +190,38 @@ export function codexNotFoundHint( return platform === "win32" && code === 9009 && !signal ? CODEX_INSTALL_HINT : null; } +export type CodexLaunchCommandDeps = { + findOnPath?: (name: string) => string | null; + inspectShim?: typeof inspectCodexShimBackingForCommand; +}; + +export type CodexLaunchCommand = { command: string; env: CodexLaunchEnv }; + +/** + * The launcher has already made the ensure decision, so the generated autostart shim + * must not make it again: on a passthrough plan it would start the proxy and load the + * admission token anyway. When `codex` resolves to a recorded shim wrapper, exec its + * backing Codex directly. When the shim state cannot be read (always on win32, where + * inspection is deferred), keep `codex` and set OCX_SHIM_BYPASS so at least the shim's + * ensure is skipped. + */ +export function resolveCodexLaunchCommand( + env: CodexLaunchEnv, + deps: CodexLaunchCommandDeps = {}, +): CodexLaunchCommand { + const selected = (deps.findOnPath ?? findExecutableOnPath)("codex"); + if (!selected) return { command: "codex", env }; + const shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); + if (shim.status === "matched" && shim.selectedRole === "wrapper") return { command: shim.backingPath, env }; + if (shim.status === "unknown") return { command: "codex", env: { ...env, OCX_SHIM_BYPASS: "1" } }; + return { command: "codex", env }; +} + function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { return new Promise(resolve => { - const inv = commandInvocation("codex", args); - const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); + const launch = resolveCodexLaunchCommand(env); + const inv = commandInvocation(launch.command, args); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { console.error(CODEX_INSTALL_HINT); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 0ab3f2e9595..1944b56b595 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -603,7 +603,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "proxy ensure and pass the environment through unchanged, keeping the shim's argv grammar.", "No injected routing: informational notice, native exec, no ensure.", "Foreign gateway routing (custom-local/custom-remote): warning, native exec, that route untouched.", - "Unverifiable routing: warning, still ensured so a routed session is not stranded on a dead port.", + "Unverifiable routing: warning, and the proxy ensure is still attempted.", + "If the ensure fails, one stderr line is printed and `codex` launches anyway.", + "When `codex` is the autostart shim, its backing Codex is launched directly so the shim", + "never repeats the ensure or overrides the routing decision.", "Stop using `ocx codex` and plain `codex` behaves exactly as before.", ], }, diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 127c9bc3a50..3a8b49e4c3d 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -24,6 +24,7 @@ import { codexNotFoundHint, codexProxyStartEnv, ensureProxyForCodex, + resolveCodexLaunchCommand, } from "../../src/cli/codex-launcher"; import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS } from "../../src/codex/shim-templates"; import { SIBLING_HANDOFF_NONCE_ENV, SIBLING_OF_PORT_ENV } from "../../src/codex/sibling-start"; @@ -95,7 +96,7 @@ describe("codex launcher plan", () => { }); }); - test("unverifiable routing still ensures so a routed session is not stranded on a dead port", () => { + test("unverifiable routing still attempts the proxy ensure", () => { expect(codexLaunchPlan("unknown")).toEqual({ kind: "passthrough", ensureProxy: true, @@ -185,3 +186,36 @@ describe("codex launcher not-found hints", () => { expect(codexNotFoundHint(null, null, "linux")).toBeNull(); }); }); + +describe("codex launcher shim handoff", () => { + const env = { PATH: "/usr/bin" }; + const onPath = () => "/home/test/.local/bin/codex"; + + test("a recorded autostart shim is skipped in favour of its backing Codex", () => { + const launch = resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => ({ status: "matched", selectedRole: "wrapper", backingPath: "/opt/codex/bin/codex.real", backingKind: "backup" }), + }); + expect(launch).toEqual({ command: "/opt/codex/bin/codex.real", env }); + }); + + test("an unreadable shim state keeps `codex` but bypasses the shim's ensure", () => { + const launch = resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => ({ status: "unknown", reason: "binding_unavailable" }), + }); + expect(launch.command).toBe("codex"); + expect(launch.env.OCX_SHIM_BYPASS).toBe("1"); + expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); + }); + + test("an untracked or backing codex launches unchanged", () => { + expect(resolveCodexLaunchCommand(env, { findOnPath: onPath, inspectShim: () => ({ status: "not-tracked" }) })) + .toEqual({ command: "codex", env }); + expect(resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => ({ status: "matched", selectedRole: "backing", backingPath: "/home/test/.local/bin/codex", backingKind: "real" }), + })).toEqual({ command: "codex", env }); + expect(resolveCodexLaunchCommand(env, { findOnPath: () => null })).toEqual({ command: "codex", env }); + }); +}); From 0e4a1d32b3231dcd602018e55c9f99df95a15b63 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:43:11 +0900 Subject: [PATCH 03/18] fix(cli): preserve Codex launch fallback and credential boundaries Handle lookup and shim inspection failures through the existing bypass path. Keep Codex help argv intact and defer root shim repair to the launch plan. Pin client token reads to the service file, reject management credentials, and leave unknown routing free of newly injected admission credentials. Register launcher tests in an existing layout shard and sync CLI references. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 5 +- .../content/docs/ja/reference/cli/agents.md | 3 + .../content/docs/ko/reference/cli/agents.md | 3 + .../src/content/docs/reference/cli/agents.md | 5 +- .../content/docs/ru/reference/cli/agents.md | 3 + .../content/docs/tr/reference/cli/agents.md | 3 + .../docs/zh-cn/reference/cli/agents.md | 2 + .../docs/zh-tw/reference/cli/agents.md | 2 + .../test-layout/layout-codex-collision.json | 2 + scripts/test-layout/layout.json | 1 - src/cli/codex-launcher.ts | 30 ++++--- src/cli/codex-shim-autorestore.ts | 2 + src/cli/registry.ts | 3 +- src/cli/root.ts | 2 + structure/cli-management.md | 4 + structure/runtime.md | 2 +- tests/cli/cli-head.test.ts | 8 ++ .../codex-launcher-process.test.ts | 51 +++++++++++ .../codex-integration/codex-launcher.test.ts | 84 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 20 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 tests/codex-integration/codex-launcher-process.test.ts 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 29e0966fe1c..4ff6ed29ffe 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -173,7 +173,7 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag Vérifiez que le proxy est actif, puis lancez `codex` sans modification. Le routage de Codex reste la propriété de sa configuration — le `openai_base_url` injecté dans `~/.codex/config.toml` — -aucun lancement ne réécrit donc de fichier de configuration. Un `OPENCODEX_API_AUTH_TOKEN` absent +aucun lancement ne réécrit donc de fichier de configuration. Pour un routage local vérifié, un `OPENCODEX_API_AUTH_TOKEN` absent est renseigné depuis `~/.opencodex/service-api-token`, la même injection que celle du shim de démarrage automatique Codex. @@ -190,6 +190,9 @@ démarrage automatique, le lanceur exécute directement le Codex sous-jacent pou répète pas l'assurance. Un lancement ultérieur de `codex` sans opencodex se comporte exactement comme auparavant. +Si la recherche de l’exécutable ou l’inspection du shim échoue, le lanceur exécute quand même `codex` avec `OCX_SHIM_BYPASS=1`, empêchant le shim de relancer le proxy ou de charger un jeton de service. +Un routage inconnu n’ajoute jamais de jeton de service. La variable de remplacement du fichier de jeton est ignorée pour le client et les identifiants d’administration sont refusés. `ocx help codex` affiche l’aide du lanceur ; `ocx codex --help` atteint Codex. + ### `ocx opencode [opencode args...]` Vérifiez que le proxy est actif, puis lancez opencode avec les blocs `provider.opencodex` et `providers.opencodex` générés dans la couche d’exécution intégrée d’OpenCode (`OPENCODE_CONFIG_CONTENT`). La configuration intégrée existante est préservée et seules ces deux clés sont remplacées pour ce lancement. Les fichiers `opencode.json` globaux ou propres au projet peuvent être lus afin de signaler une substitution existante, mais les fichiers sur disque ne sont jamais modifiés. Les modèles routés apparaissent sous la forme `opencodex//`. Un lancement ultérieur de `opencode` sans intermédiaire se comporte exactement comme auparavant. 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 6811fa2b643..ffd757a7bd9 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -147,6 +147,9 @@ stderr に 1 行出力し、それでも `codex` を起動します。`codex` ensure を繰り返さないよう、その背後にある Codex を直接起動します。後でプレーン `codex` を起動すると、 以前とまったく同じように動作します。 +実行ファイルの検索やシムの検査に失敗しても、`OCX_SHIM_BYPASS=1` を設定して `codex` を起動し、シムによるプロキシの再起動やサービストークンの読み込みを防ぎます。 +サービストークンの補完は検証済みのローカルルーティングに限り、不明なルーティングには追加しません。クライアントへの注入ではトークンファイルの環境変数による上書きを無視し、管理用認証情報を拒否します。ランチャーのヘルプは `ocx help codex`、Codex 自体のヘルプは `ocx codex --help` で表示します。 + ### `ocx opencode [opencode args...]` プロキシが実行されていることを確認し、OpenCode のインライン ランタイム層 (`OPENCODE_CONFIG_CONTENT`) で生成された `provider.opencodex` および `providers.opencodex` ブロックを使用してオープンコードを起動します。既存のインライン設定は保持され、今回の起動ではこの 2 つのキーのみが置き換えられます。グローバルまたはプロジェクトの `opencode.json` ファイルは、既存の上書きについて警告するために読み取られることがありますが、ディスク上のファイルは変更されません。ルーティングされたモデルは `opencodex//` として表示されます。後でプレーン `opencode` を起動すると、以前とまったく同じように動作します。 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 a556eb37fbf..a6ca3704f9e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -178,6 +178,9 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 직접 실행합니다. 이후 plain `codex`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. +실행 파일 검색이나 shim 검사가 실패해도 `OCX_SHIM_BYPASS=1`로 `codex`를 실행하여 shim의 프록시 재시작과 서비스 토큰 읽기를 건너뜁니다. +서비스 토큰 보충은 확인된 로컬 라우팅에만 적용되며, 알 수 없는 라우팅에는 추가하지 않습니다. 클라이언트 주입 시 토큰 파일 환경 변수의 경로 재정의를 무시하고 관리자 자격 증명을 거부합니다. 런처 도움말은 `ocx help codex`, Codex 자체 도움말은 `ocx codex --help`를 사용합니다. + ### `ocx opencode [opencode args...]` 프록시가 실행 중인지 확인한 뒤, OpenCode의 인라인 런타임 계층(`OPENCODE_CONFIG_CONTENT`)에 생성된 `provider.opencodex` 및 `providers.opencodex` 블록을 넣어 opencode를 실행합니다. 기존 인라인 config는 유지되고, 이번 실행에서는 이 두 키만 교체됩니다. 전역 또는 프로젝트 `opencode.json` 파일은 기존 override가 있는지 경고하기 위해 읽을 수 있지만, 디스크상의 파일은 절대 수정하지 않습니다. 라우팅된 model은 `opencodex//`로 나타납니다. 이후 plain `opencode`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index fc49ba53c7b..2e16b625dc7 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -967,7 +967,7 @@ before choosing it. Rendered models are not proof of native application. Ensure the proxy is running, then launch `codex` unchanged. Codex routing stays config-owned — the injected `openai_base_url` in `~/.codex/config.toml` — so a launch never rewrites a config -file. A missing `OPENCODEX_API_AUTH_TOKEN` is filled from `~/.opencodex/service-api-token`, +file. For verified local routing, a missing `OPENCODEX_API_AUTH_TOKEN` is filled from `~/.opencodex/service-api-token`, the same injection the Codex autostart shim performs. `--help`/`-h`, `--version`/`-V`, and Codex-internal subcommands (`login`, `doctor`, `exec`, ...) @@ -981,6 +981,9 @@ line is printed and `codex` launches anyway. When `codex` resolves to the autost launcher runs the backing Codex directly so the shim does not repeat the ensure. Launching plain `codex` later behaves exactly as before. +If executable lookup or shim inspection fails, the launcher still runs `codex` with `OCX_SHIM_BYPASS=1`, preventing the shim from repeating proxy startup or loading a service token. +Unknown routing never adds a service token. The token-file environment override is ignored for client injection, and management credentials are rejected. Use `ocx help codex` for launcher help; `ocx codex --help` reaches Codex itself. + ### `ocx opencode [opencode args...]` Ensure the proxy is running, then launch opencode with the generated `provider.opencodex` and 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 c7cc96ed365..d9a77c6a094 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -168,6 +168,9 @@ ocx claude desktop import [--apply] Validate and import JSON autostart шим, лаунчер запускает исходный Codex напрямую, чтобы шим не повторял ensure. Последующий запуск обычного `codex` работает ровно как раньше. +При ошибке поиска исполняемого файла или проверки шима лаунчер всё равно запускает `codex` с `OCX_SHIM_BYPASS=1`, чтобы шим не повторял запуск прокси и не загружал сервисный токен. +Сервисный токен добавляется только при проверенном локальном роутинге, никогда при неизвестном. Переопределение пути к файлу токена через окружение игнорируется для клиента, административные токены отклоняются. Справка лаунчера: `ocx help codex`; справка самого Codex: `ocx codex --help`. + ### `ocx opencode [opencode args...]` Убедиться, что прокси запущен, и затем запустить opencode со сгенерированными блоками 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 6227374ddd7..4ca8a252be5 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -205,6 +205,9 @@ Deneme başarısız olursa stderr'e tek satır yazılır ve `codex` yine de baş başlatma shim'ine çözülürse, shim garantiyi tekrarlamasın diye arkasındaki Codex doğrudan başlatılır. Daha sonra düz `codex` başlatmak tam olarak eskisi gibi davranır. +Çalıştırılabilir dosya araması veya shim incelemesi başarısız olursa başlatıcı yine de `codex`'i `OCX_SHIM_BYPASS=1` ile çalıştırır; böylece shim proxy'yi yeniden başlatmaz ve hizmet belirtecini yüklemez. +Hizmet belirteci yalnızca doğrulanmış yerel yönlendirmede eklenir; bilinmeyen yönlendirmede eklenmez. İstemciye aktarımda belirteç dosyasının ortam değişkeniyle değiştirilmesi yok sayılır ve yönetim kimlik bilgileri reddedilir. Başlatıcı yardımı için `ocx help codex`, Codex yardımı için `ocx codex --help` kullanın. + ### `ocx opencode [opencode argumanlari...]` Proxy'nin çalıştığından emin olun, ardından OpenCode'un satır içi çalışma zamanı 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 3692ea0e94c..8b7826b1ac2 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 @@ -146,6 +146,8 @@ ensure 并原样传递环境,与 shim 的 argv 语法保持一致。 执行前会先判定路由:没有注入路由时输出提示信息,并不做 ensure 直接启动 `codex`;外部网关(custom-local/custom-remote) 会输出警告,按原样通过且不触碰该路由;无法验证的路由会输出警告但仍尝试 ensure;ensure 失败时会在 stderr 输出一行,并仍然启动 `codex`。 当 `codex` 指向自动启动 shim 时,启动器会直接运行其背后的 Codex,避免 shim 重复 ensure。 +如果查找可执行文件或检查 shim 失败,启动器仍会以 `OCX_SHIM_BYPASS=1` 运行 `codex`,跳过 shim 的代理启动和服务令牌加载。 +只有已验证的本地路由才会补充服务令牌,未知路由不会添加。客户端注入会忽略令牌文件路径的环境变量覆盖,并拒绝管理凭据。启动器帮助使用 `ocx help codex`,Codex 自身帮助使用 `ocx codex --help`。 稍后再启动普通 `codex` 时,行为与之前完全一致。 ### `ocx opencode [opencode args...]` 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 76eed3328e0..110dbb13f59 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 @@ -143,6 +143,8 @@ ensure 並原樣傳遞環境,與 shim 的 argv 語法保持一致。 執行前會先判定路由:沒有注入路由時輸出提示訊息,並不做 ensure 直接啟動 `codex`;外部閘道(custom-local/custom-remote) 會輸出警告並原樣通過、不觸碰該路由;無法驗證的路由會輸出警告但仍嘗試 ensure;ensure 失敗時會在 stderr 輸出一行,並仍然啟動 `codex`。 當 `codex` 指向自動啟動 shim 時,啟動器會直接執行其背後的 Codex,避免 shim 重複 ensure。 +若尋找執行檔或檢查 shim 失敗,啟動器仍會以 `OCX_SHIM_BYPASS=1` 執行 `codex`,略過 shim 的代理啟動與服務權杖載入。 +只有已驗證的本機路由才會補上服務權杖,未知路由不會新增。用戶端注入會忽略權杖檔案路徑的環境變數覆寫,並拒絕管理憑證。啟動器說明使用 `ocx help codex`,Codex 本身說明使用 `ocx codex --help`。 之後啟動普通 `codex` 的行為與之前完全相同。 ### `ocx opencode [opencode args...]` diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index b35d340f1f7..8a93a38aa93 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,6 @@ { + "codex-launcher.test.ts": "codex-integration", + "codex-launcher-process.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b00239b7f01..bc228afaffb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -710,7 +710,6 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", - "codex-launcher.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 8b064c2015d..1eaeb5c19cc 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -28,6 +28,7 @@ import { inspectCodexShimBackingForCommand } from "../codex/shim"; import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; import { withoutSiblingMarker } from "../codex/sibling-start"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { commandInvocation } from "../lib/win-exec"; @@ -149,12 +150,14 @@ export async function ensureProxyForCodex(deps: CodexProxyEnsureDeps = {}): Prom } export type CodexLaunchEnvDeps = { + routingKind?: CodexRoutingKind; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; }; +/** Read the client admission credential from this installation's service-token file. */ const defaultServiceToken = (base: CodexLaunchEnv): string | null => - loadServiceTokenFromFile({ OCX_API_TOKEN_FILE: serviceApiTokenFilePath(), ...base }); + loadServiceTokenFromFile({ ...base, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }); /** * Launch env for a routed exec. The one injection is the shim's own: a missing @@ -168,9 +171,10 @@ export function buildCodexLaunchEnv( deps: CodexLaunchEnvDeps = {}, ): CodexLaunchEnv { const env = { ...base }; + if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { const token = (deps.serviceToken ?? defaultServiceToken)(env); - if (token) env.OPENCODEX_API_AUTH_TOKEN = token; + if (token && !tokenCollidesWithAdmin(token, env)) env.OPENCODEX_API_AUTH_TOKEN = token; } return env; } @@ -209,14 +213,18 @@ export function resolveCodexLaunchCommand( env: CodexLaunchEnv, deps: CodexLaunchCommandDeps = {}, ): CodexLaunchCommand { - const selected = (deps.findOnPath ?? findExecutableOnPath)("codex"); - if (!selected) return { command: "codex", env }; - const shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); - if (shim.status === "matched" && shim.selectedRole === "wrapper") return { command: shim.backingPath, env }; - if (shim.status === "unknown") return { command: "codex", env: { ...env, OCX_SHIM_BYPASS: "1" } }; - return { command: "codex", env }; + try { + const selected = (deps.findOnPath ?? findExecutableOnPath)("codex"); + if (!selected) return { command: "codex", env }; + const shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); + if (shim.status === "matched" && shim.selectedRole === "wrapper") return { command: shim.backingPath, env }; + if (shim.status !== "unknown") return { command: "codex", env }; + } catch { // Inspection is optional; retain the same bypass as an unknown state. + } + return { command: "codex", env: { ...env, OCX_SHIM_BYPASS: "1" } }; } +/** Launch the selected Codex command with inherited IO and preserve its exit status. */ function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { return new Promise(resolve => { const launch = resolveCodexLaunchCommand(env); @@ -238,9 +246,11 @@ function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { if (codexFastPathReason(args) !== null) return spawnCodex(args, { ...process.env }); - const plan = codexLaunchPlan(getCodexRoutingKind()); + const routingKind = getCodexRoutingKind(); + const plan = codexLaunchPlan(routingKind); const notice = plan.kind === "passthrough" ? plan.notice : undefined; if (notice) console.error(notice); const needsProxy = plan.kind === "routed" @@ -253,5 +263,5 @@ export async function cmdCodex(args: string[]): Promise { // what the session does next; `ocx doctor` names the autostart failure. console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); } - return spawnCodex(args, buildCodexLaunchEnv(process.env)); + return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind })); } diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 257d70d1360..86750714f3b 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -19,6 +19,8 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = { export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean { if (command === "uninstall" || command === "remove") return true; if (command === "status" || command === "doctor") return true; + // The launcher owns shim handoff after classifying routing, including native fast paths. + if (command === "codex") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; // Local messaging owns its helpers; even malformed usage must not repair/start anything. diff --git a/src/cli/registry.ts b/src/cli/registry.ts index b16e3a1d42c..07c435bcb35 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -611,13 +611,14 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Mirrors `ocx claude` on Codex's config-owned wiring channel: ensures the proxy is running,", "then execs `codex` unchanged. Routing itself stays config-owned (the injected `openai_base_url`", "in ~/.codex/config.toml); a launch never rewrites a config file.", - "A missing OPENCODEX_API_AUTH_TOKEN is filled from ~/.opencodex/service-api-token, the same", + "For verified local routing, a missing OPENCODEX_API_AUTH_TOKEN is filled from ~/.opencodex/service-api-token, the same", "injection the `codex` autostart shim performs.", "--help/-h, --version/-V, and Codex-internal subcommands (login, doctor, exec, ...) skip the", "proxy ensure and pass the environment through unchanged, keeping the shim's argv grammar.", "No injected routing: informational notice, native exec, no ensure.", "Foreign gateway routing (custom-local/custom-remote): warning, native exec, that route untouched.", "Unverifiable routing: warning, and the proxy ensure is still attempted.", + "Unknown routing never adds a service token; token-file overrides and management credentials are not used for client injection.", "If the ensure fails, one stderr line is printed and `codex` launches anyway.", "When `codex` is the autostart shim, its backing Codex is launched directly so the shim", "never repeats the ensure or overrides the routing decision.", diff --git a/src/cli/root.ts b/src/cli/root.ts index 0e664880aed..f34a1cc22ab 100644 --- a/src/cli/root.ts +++ b/src/cli/root.ts @@ -38,6 +38,8 @@ export interface CliHead { export function parseCliHead(argv: string[]): CliHead { const command = argv[0]; const args = argv; + // Codex owns its forwarded grammar; wrapper documentation is `ocx help codex`. + if (command === "codex") return { kind: "command", command, args }; if (command === "--version" || command === "-v" || command === "version") { return { kind: "version", command, args }; } diff --git a/structure/cli-management.md b/structure/cli-management.md index fa5de9d7b4a..01232c2fa78 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -21,6 +21,10 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl ## Head and help navigation +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting Codex routing. Native and foreign routes pass through; local and unknown routes attempt proxy startup and still launch with a diagnostic if it fails. Fast-path arguments skip startup and token loading. A recorded wrapper launches its backing Codex directly. Unknown shim state, including PATH lookup or inspection errors, launches `codex` with `OCX_SHIM_BYPASS=1`, skipping both shim startup and token loading without printing the inspection error. A missing executable retains the normal spawn error path. Only verified OpenCodex routing fills a missing admission token from the installation's service-token file, ignoring an inherited token-file override and refusing management-token collisions. Unknown routing adds no credential. No token is added to argv or diagnostics. + +The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. + `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` The pure Bun-head parser retains command argv and derives transient help paths before shim preflight or command dispatch. Bare `help` is recognized only at root or immediately after the root command; nested paths use `--help`/`-h`, and exact `--` ends head help scanning. Later operand values such as `alias set demo help` stay commands. `runCli` exits help and rejects unknown roots before shim/stateful preflight, while preserving `parseCliHead` and its kinds. Registry names/aliases/hidden entries and the unregistered `internal` runner remain valid dispatch targets; hidden/internal names stay outside discovery and suggestions. `printUsage` renders the same compact index for bare `ocx`, `help`, `--help` and `-h`; static navigation groups reference registry names/summaries and add no configuration reads, width probes or color dependence. `printFullUsage` preserves the full reference behind `ocx help --all` and `ocx --help --all`. This contract concerns the Bun head; the Node launcher's updater interception has its own recognition rules. diff --git a/structure/runtime.md b/structure/runtime.md index ebeac303a54..8dde28774ac 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -291,7 +291,7 @@ atomic OS guarantee against unobserved PID reuse. > Decision record: [ADR-0003](decisions/ADR-0003-lifecycle.md) -An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. Changing launchers defer; failures warn without changing the requested command’s exit behavior. +An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. `ocx codex` skips this repair because its launcher owns routing-aware shim handoff. Changing launchers defer; failures warn without changing the requested command’s exit behavior. All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor. Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder. Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. Windows service/shim writes first admit the frozen selected runtime under the [write-preflight contract](ops/service-and-sidecars.md#windows-selected-runtime-write-preflight); orchestrated installs refuse Desktop supervision before selection and admit once before cleanup. Repair refuses Desktop before diagnosis, then checks ownership and auth before admission and any native repair or scheduler stop. The Node launcher’s validated PATH Bun fallback precedes CLI startup; durable admission never discovers a replacement. The fresh scheduler path claims config ownership after registration and staging removal, then admits before `prepare()`; refusal rolls back the new registration and preserves the claimed config root. Standalone admission uses the current executable in-process, without child isolation or a timeout. diff --git a/tests/cli/cli-head.test.ts b/tests/cli/cli-head.test.ts index 64e8d027a87..52875ffedd6 100644 --- a/tests/cli/cli-head.test.ts +++ b/tests/cli/cli-head.test.ts @@ -4,6 +4,14 @@ import * as autorestore from "../../src/cli/codex-shim-autorestore"; import { DEFAULT_READY_WAIT_TIMEOUT_SECONDS } from "../../src/cli/ready"; describe("parseCliHead (pure CLI head, Phase 1)", () => { + test("codex owns all forwarded help arguments; ocx help codex stays local", () => { + for (const args of [["codex", "--help"], ["codex", "help"], ["codex", "exec", "-h"], ["codex", "-m", "--help"]]) { + expect(parseCliHead(args)).toEqual({ kind: "command", command: "codex", args }); + expect(parseCliHead(args).args).toBe(args); + } + expect(parseCliHead(["help", "codex"])).toMatchObject({ kind: "help", helpTarget: "codex" }); + }); + test("preserves explicit nested help paths and original argv", () => { for (const args of [["help", "models", "context"], ["models", "context", "--help"], ["models", "context", "-h"]]) { const head = parseCliHead(args); diff --git a/tests/codex-integration/codex-launcher-process.test.ts b/tests/codex-integration/codex-launcher-process.test.ts new file mode 100644 index 00000000000..917143dfd3e --- /dev/null +++ b/tests/codex-integration/codex-launcher-process.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +describe("ocx codex real CLI handoff", () => { + for (const args of [["--help"], ["exec", "--help"], ["help"], ["--version"], ["resume", "two words"]]) { + test(`forwards ${args.join(" ")} without loading a service token`, () => { + const home = mkdtempSync(join(tmpdir(), "ocx-codex-process-")); + try { + const bin = join(home, "bin"); + const codexHome = join(home, "codex-home"); + const ocxHome = join(home, "ocx-home"); + for (const dir of [bin, codexHome, ocxHome]) mkdirSync(dir); + const probe = join(home, "probe.mjs"); + writeFileSync(probe, 'console.log(JSON.stringify({args:process.argv.slice(2),token:process.env.OPENCODEX_API_AUTH_TOKEN??null}));process.exit(23);\n'); + const command = join(bin, process.platform === "win32" ? "codex.cmd" : "codex"); + const shellQuote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`; + writeFileSync(command, process.platform === "win32" + ? `@echo off\r\n"${process.execPath}" "${probe}" %*\r\nexit /b %errorlevel%\r\n` + : `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(probe)} "$@"\n`); + if (process.platform !== "win32") chmodSync(command, 0o755); + const config = 'model = "native-fixture"\n'; + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, config); + writeFileSync(join(ocxHome, "service-api-token"), "ocx_fixture_service_token"); + const env: NodeJS.ProcessEnv = { + ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, + PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, PATHEXT: ".EXE;.CMD", + }; + delete env.OPENCODEX_API_AUTH_TOKEN; + delete env.OPENCODEX_ADMIN_AUTH_TOKEN; + delete env.OCX_API_TOKEN_FILE; + const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", ...args], { + cwd: repoRoot(), env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(23); + expect(JSON.parse(result.stdout.trim())).toEqual({ args, token: null }); + expect(result.stderr).not.toContain("ocx_fixture_service_token"); + expect(readFileSync(configPath, "utf8")).toBe(config); + } finally { + removeTreeWithRetry(home); + } + }, SPAWN_BUDGET_MS); + } +}); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 3a8b49e4c3d..463eb96fafa 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -29,6 +29,36 @@ import { import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS } from "../../src/codex/shim-templates"; import { SIBLING_HANDOFF_NONCE_ENV, SIBLING_OF_PORT_ENV } from "../../src/codex/sibling-start"; import type { LiveProxy } from "../../src/server/proxy-liveness"; +import { maybeAutoRestoreCodexShim } from "../../src/cli/codex-shim-autorestore"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function withTokenFiles(run: (home: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-codex-launch-token-")); + const prior = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { run(home); } + finally { + if (prior === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(home); + } +} + +test("codex launch defers shim handling to its routing plan without root repair", () => { + for (const args of [["codex", "--version"], ["codex", "login"], ["codex", "resume"]]) { + let restored = false; + maybeAutoRestoreCodexShim("codex", args, { + env: {}, + warn: () => {}, + readConfig: () => { throw new Error("launcher preflight must not read config"); }, + restore: () => { restored = true; return { status: "disabled" }; }, + }); + expect(restored).toBe(false); + } +}); describe("codex launcher fast path", () => { test("help/version and internal subcommands skip the proxy ensure", () => { @@ -106,6 +136,39 @@ describe("codex launcher plan", () => { }); describe("codex launch env assembly", () => { + test("an inherited token-file override cannot select the admin credential", () => { + withTokenFiles(home => { + const admin = `ocx_admin_${"a".repeat(43)}`; + writeFileSync(join(home, "admin-api-token"), admin); + writeFileSync(join(home, "service-api-token"), "ocx_fixture_service_token"); + const env = buildCodexLaunchEnv({ OCX_API_TOKEN_FILE: join(home, "admin-api-token") }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("ocx_fixture_service_token"); + }); + }); + + test("a service file containing a management credential is never injected", () => { + withTokenFiles(home => { + for (const admin of [`ocx_admin_${"a".repeat(43)}`, "ocx_fixture_custom_admin"]) { + writeFileSync(join(home, "service-api-token"), admin); + const env = buildCodexLaunchEnv({ OPENCODEX_ADMIN_AUTH_TOKEN: admin }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } + }); + }); + + test("unknown and foreign routing never read or inject the service token", () => { + for (const routingKind of ["unknown", "custom-local", "custom-remote", "native"] as const) { + let consulted = false; + const base = { PATH: "/usr/bin" }; + const env = buildCodexLaunchEnv(base, { + routingKind, + serviceToken: () => { consulted = true; return "ocx_fixture_service_token"; }, + }); + expect(env).toEqual(base); + expect(consulted).toBe(false); + } + }); + test("a missing admission token comes from the service token ladder", () => { const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { serviceToken: () => "tok-from-file" }); expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("tok-from-file"); @@ -209,6 +272,27 @@ describe("codex launcher shim handoff", () => { expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); }); + test("a PATH lookup error falls back to the unknown-state bypass", () => { + let inspected = false; + const launch = resolveCodexLaunchCommand(env, { + findOnPath: () => { throw new Error("PATH lookup unavailable"); }, + inspectShim: () => { inspected = true; return { status: "not-tracked" }; }, + }); + expect(launch).toEqual({ command: "codex", env: { ...env, OCX_SHIM_BYPASS: "1" } }); + expect(inspected).toBe(false); + expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); + }); + + test("a shim filesystem error falls back without changing the input environment", () => { + const base = { ...env, OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_inherited_token", OCX_SHIM_BYPASS: "" }; + const launch = resolveCodexLaunchCommand(base, { + findOnPath: onPath, + inspectShim: () => { throw Object.assign(new Error("shim state read failed"), { code: "EIO" }); }, + }); + expect(launch).toEqual({ command: "codex", env: { ...base, OCX_SHIM_BYPASS: "1" } }); + expect(base.OCX_SHIM_BYPASS).toBe(""); + }); + test("an untracked or backing codex launches unchanged", () => { expect(resolveCodexLaunchCommand(env, { findOnPath: onPath, inspectShim: () => ({ status: "not-tracked" }) })) .toEqual({ command: "codex", env }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c85aff0ee26..04b9a0beb89 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -587,6 +587,7 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", + "codex-launcher-process.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", "codex-shim-windows-revision.test.ts": "codex-integration", From 7bc6ac664203ea0b89db1a4caa5fafb849c7d3ae Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:05:34 +0900 Subject: [PATCH 04/18] fix(cli): omit automatic Codex credentials for routing overrides Treat forwarded config and profile overrides as unverified destinations for service-token injection, preserving argv and explicit user credentials. Add joined/equal/separate flag regressions and correct the internal-command examples in the CLI registry and all translated references. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 3 ++- .../content/docs/ja/reference/cli/agents.md | 3 ++- .../content/docs/ko/reference/cli/agents.md | 3 ++- .../src/content/docs/reference/cli/agents.md | 3 ++- .../content/docs/ru/reference/cli/agents.md | 3 ++- .../content/docs/tr/reference/cli/agents.md | 3 ++- .../content/docs/zh-cn/reference/cli/agents.md | 3 ++- .../content/docs/zh-tw/reference/cli/agents.md | 3 ++- src/cli/codex-launcher.ts | 5 ++++- src/cli/registry.ts | 3 ++- structure/cli-management.md | 1 + tests/codex-integration/codex-launcher.test.ts | 18 ++++++++++++++++++ 12 files changed, 41 insertions(+), 10 deletions(-) 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 4ff6ed29ffe..75695ab6354 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -177,7 +177,7 @@ aucun lancement ne réécrit donc de fichier de configuration. Pour un routage l est renseigné depuis `~/.opencodex/service-api-token`, la même injection que celle du shim de démarrage automatique Codex. -`--help`/`-h`, `--version`/`-V` et les sous-commandes internes de Codex (`login`, `doctor`, `exec`, +`--help`/`-h`, `--version`/`-V` et les sous-commandes internes de Codex (`login`, `doctor`, `help`, ...) sautent l'assurance du proxy et traversent l'environnement inchangé, en reprenant la grammaire d'arguments du shim. @@ -192,6 +192,7 @@ exactement comme auparavant. Si la recherche de l’exécutable ou l’inspection du shim échoue, le lanceur exécute quand même `codex` avec `OCX_SHIM_BYPASS=1`, empêchant le shim de relancer le proxy ou de charger un jeton de service. Un routage inconnu n’ajoute jamais de jeton de service. La variable de remplacement du fichier de jeton est ignorée pour le client et les identifiants d’administration sont refusés. `ocx help codex` affiche l’aide du lanceur ; `ocx codex --help` atteint Codex. +Les options `-c`/`--config` ou `-p`/`--profile` désactivent aussi l’injection automatique, car elles peuvent changer la destination. Les identifiants exportés par l’utilisateur et les arguments restent inchangés. ### `ocx opencode [opencode args...]` 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 ffd757a7bd9..cbd05ad85c6 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -137,7 +137,7 @@ ocx claude desktop import [--apply] Validate and import JSON ファイルを書き換えません。環境に `OPENCODEX_API_AUTH_TOKEN` がない場合は、Codex オートスタート シムと同じ注入で `~/.opencodex/service-api-token` から補完します。 -`--help`/`-h`、`--version`/`-V`、および Codex 内部のサブコマンド (`login`、`doctor`、`exec` な +`--help`/`-h`、`--version`/`-V`、および Codex 内部のサブコマンド (`login`、`doctor`、`help` な ど) はプロキシの ensure をスキップし、環境をそのまま渡します (シムの引数文法と一致)。 実行前にルーティングを判定します。注入されたルーティングがない場合は情報メッセージを出し、ensure @@ -149,6 +149,7 @@ ensure を繰り返さないよう、その背後にある Codex を直接起動 実行ファイルの検索やシムの検査に失敗しても、`OCX_SHIM_BYPASS=1` を設定して `codex` を起動し、シムによるプロキシの再起動やサービストークンの読み込みを防ぎます。 サービストークンの補完は検証済みのローカルルーティングに限り、不明なルーティングには追加しません。クライアントへの注入ではトークンファイルの環境変数による上書きを無視し、管理用認証情報を拒否します。ランチャーのヘルプは `ocx help codex`、Codex 自体のヘルプは `ocx codex --help` で表示します。 +`-c`/`--config` または `-p`/`--profile` は接続先を変更できるため、自動トークン注入を省略します。ユーザーがエクスポートした認証情報と元の引数は保持します。 ### `ocx opencode [opencode args...]` 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 a6ca3704f9e..d54340c7916 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -168,7 +168,7 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 `openai_base_url`)가 소유하므로, 이 실행은 config 파일을 재작성하지 않습니다. 환경에 `OPENCODEX_API_AUTH_TOKEN`이 없으면 `~/.opencodex/service-api-token`에서 채웁니다. 이는 Codex 자동 시작 shim과 동일한 주입입니다. -`--help`/`-h`, `--version`/`-V` 및 Codex 내부 하위 명령(`login`, `doctor`, `exec` 등)은 프록시 ensure를 +`--help`/`-h`, `--version`/`-V` 및 Codex 내부 하위 명령(`login`, `doctor`, `help` 등)은 프록시 ensure를 건너뛰고 환경을 그대로 전달합니다(shim의 argv 문법과 일치). 실행 전에 라우팅을 확인합니다. 주입된 라우팅이 없으면 안내 메시지를 출력하고 ensure 없이 `codex`를 실행하고, @@ -180,6 +180,7 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 실행 파일 검색이나 shim 검사가 실패해도 `OCX_SHIM_BYPASS=1`로 `codex`를 실행하여 shim의 프록시 재시작과 서비스 토큰 읽기를 건너뜁니다. 서비스 토큰 보충은 확인된 로컬 라우팅에만 적용되며, 알 수 없는 라우팅에는 추가하지 않습니다. 클라이언트 주입 시 토큰 파일 환경 변수의 경로 재정의를 무시하고 관리자 자격 증명을 거부합니다. 런처 도움말은 `ocx help codex`, Codex 자체 도움말은 `ocx codex --help`를 사용합니다. +`-c`/`--config` 또는 `-p`/`--profile`은 목적지를 바꿀 수 있으므로 자동 토큰 주입을 생략합니다. 사용자가 export한 자격 증명과 원래 인자는 보존합니다. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 2e16b625dc7..76ea6e8e691 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -970,7 +970,7 @@ the injected `openai_base_url` in `~/.codex/config.toml` — so a launch never r file. For verified local routing, a missing `OPENCODEX_API_AUTH_TOKEN` is filled from `~/.opencodex/service-api-token`, the same injection the Codex autostart shim performs. -`--help`/`-h`, `--version`/`-V`, and Codex-internal subcommands (`login`, `doctor`, `exec`, ...) +`--help`/`-h`, `--version`/`-V`, and Codex-internal subcommands (`login`, `doctor`, `help`, ...) skip the proxy ensure and pass the environment through unchanged, matching the shim's argv grammar. @@ -983,6 +983,7 @@ launcher runs the backing Codex directly so the shim does not repeat the ensure. If executable lookup or shim inspection fails, the launcher still runs `codex` with `OCX_SHIM_BYPASS=1`, preventing the shim from repeating proxy startup or loading a service token. Unknown routing never adds a service token. The token-file environment override is ignored for client injection, and management credentials are rejected. Use `ocx help codex` for launcher help; `ocx codex --help` reaches Codex itself. +Forwarding `-c`/`--config` or `-p`/`--profile` also skips automatic token injection because these options can change the destination. User-exported credentials and the original arguments are preserved. ### `ocx opencode [opencode args...]` 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 d9a77c6a094..717ae608d4b 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -158,7 +158,7 @@ ocx claude desktop import [--apply] Validate and import JSON никогда не переписывает файлы конфигурации. Отсутствующий `OPENCODEX_API_AUTH_TOKEN` берётся из `~/.opencodex/service-api-token` — та же инъекция, что и в autostart шиме Codex. -`--help`/`-h`, `--version`/`-V` и внутренние подкоманды Codex (`login`, `doctor`, `exec`, ...) +`--help`/`-h`, `--version`/`-V` и внутренние подкоманды Codex (`login`, `doctor`, `help`, ...) пропускают ensure прокси и передают окружение как есть — та же грамматика argv, что и у шима. Роутинг проверяется перед запуском: без инжектированного роутинга печатается информационное сообщение @@ -170,6 +170,7 @@ autostart шим, лаунчер запускает исходный Codex на При ошибке поиска исполняемого файла или проверки шима лаунчер всё равно запускает `codex` с `OCX_SHIM_BYPASS=1`, чтобы шим не повторял запуск прокси и не загружал сервисный токен. Сервисный токен добавляется только при проверенном локальном роутинге, никогда при неизвестном. Переопределение пути к файлу токена через окружение игнорируется для клиента, административные токены отклоняются. Справка лаунчера: `ocx help codex`; справка самого Codex: `ocx codex --help`. +Опции `-c`/`--config` и `-p`/`--profile` также отключают автоматическую передачу токена, поскольку могут изменить адрес назначения. Экспортированные пользователем учётные данные и исходные аргументы сохраняются. ### `ocx opencode [opencode args...]` 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 4ca8a252be5..14cf335aed4 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -195,7 +195,7 @@ başlatma hiçbir zaman yapılandırma dosyalarını değiştirmez. Eksik `OPENC `~/.opencodex/service-api-token` üzerinden doldurulur — Codex otomatik başlatma shim'inin yaptığı injection ile aynı. -`--help`/`-h`, `--version`/`-V` ve Codex'in iç alt komutları (`login`, `doctor`, `exec`, ...) proxy +`--help`/`-h`, `--version`/`-V` ve Codex'in iç alt komutları (`login`, `doctor`, `help`, ...) proxy garantisini atlar ve ortamı değiştirmeden geçirir — shim'in argv dilbilgisiyle aynı. Başlatmadan önce yönlendirme değerlendirilir. Yazılmış yönlendirme yoksa bilgilendirme yazılır ve @@ -207,6 +207,7 @@ olarak eskisi gibi davranır. Çalıştırılabilir dosya araması veya shim incelemesi başarısız olursa başlatıcı yine de `codex`'i `OCX_SHIM_BYPASS=1` ile çalıştırır; böylece shim proxy'yi yeniden başlatmaz ve hizmet belirtecini yüklemez. Hizmet belirteci yalnızca doğrulanmış yerel yönlendirmede eklenir; bilinmeyen yönlendirmede eklenmez. İstemciye aktarımda belirteç dosyasının ortam değişkeniyle değiştirilmesi yok sayılır ve yönetim kimlik bilgileri reddedilir. Başlatıcı yardımı için `ocx help codex`, Codex yardımı için `ocx codex --help` kullanın. +`-c`/`--config` veya `-p`/`--profile` hedefi değiştirebildiğinden otomatik belirteç aktarımını da atlar. Kullanıcının dışa aktardığı kimlik bilgileri ve özgün argümanlar korunur. ### `ocx opencode [opencode argumanlari...]` 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 8b7826b1ac2..14fe263e23b 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 @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON `openai_base_url`),因此启动不会改写任何配置文件。环境缺少 `OPENCODEX_API_AUTH_TOKEN` 时,会从 `~/.opencodex/service-api-token` 补上,与 Codex 自动启动 shim 采用相同的注入方式。 -`--help`/`-h`、`--version`/`-V` 以及 Codex 内部子命令(`login`、`doctor`、`exec` 等)会跳过代理 +`--help`/`-h`、`--version`/`-V` 以及 Codex 内部子命令(`login`、`doctor`、`help` 等)会跳过代理 ensure 并原样传递环境,与 shim 的 argv 语法保持一致。 执行前会先判定路由:没有注入路由时输出提示信息,并不做 ensure 直接启动 `codex`;外部网关(custom-local/custom-remote) @@ -148,6 +148,7 @@ ensure 并原样传递环境,与 shim 的 argv 语法保持一致。 当 `codex` 指向自动启动 shim 时,启动器会直接运行其背后的 Codex,避免 shim 重复 ensure。 如果查找可执行文件或检查 shim 失败,启动器仍会以 `OCX_SHIM_BYPASS=1` 运行 `codex`,跳过 shim 的代理启动和服务令牌加载。 只有已验证的本地路由才会补充服务令牌,未知路由不会添加。客户端注入会忽略令牌文件路径的环境变量覆盖,并拒绝管理凭据。启动器帮助使用 `ocx help codex`,Codex 自身帮助使用 `ocx codex --help`。 +`-c`/`--config` 或 `-p`/`--profile` 可能改变目标,因此也会跳过自动令牌注入。用户导出的凭据和原始参数会保留。 稍后再启动普通 `codex` 时,行为与之前完全一致。 ### `ocx opencode [opencode args...]` 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 110dbb13f59..f75484d4d76 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 @@ -137,7 +137,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON `openai_base_url`),因此啟動不會改寫任何設定檔。環境缺少 `OPENCODEX_API_AUTH_TOKEN` 時,會從 `~/.opencodex/service-api-token` 補上,與 Codex 自動啟動 shim 採用相同的注入方式。 -`--help`/`-h`、`--version`/`-V` 以及 Codex 內部子命令(`login`、`doctor`、`exec` 等)會跳過代理 +`--help`/`-h`、`--version`/`-V` 以及 Codex 內部子命令(`login`、`doctor`、`help` 等)會跳過代理 ensure 並原樣傳遞環境,與 shim 的 argv 語法保持一致。 執行前會先判定路由:沒有注入路由時輸出提示訊息,並不做 ensure 直接啟動 `codex`;外部閘道(custom-local/custom-remote) @@ -145,6 +145,7 @@ ensure 並原樣傳遞環境,與 shim 的 argv 語法保持一致。 當 `codex` 指向自動啟動 shim 時,啟動器會直接執行其背後的 Codex,避免 shim 重複 ensure。 若尋找執行檔或檢查 shim 失敗,啟動器仍會以 `OCX_SHIM_BYPASS=1` 執行 `codex`,略過 shim 的代理啟動與服務權杖載入。 只有已驗證的本機路由才會補上服務權杖,未知路由不會新增。用戶端注入會忽略權杖檔案路徑的環境變數覆寫,並拒絕管理憑證。啟動器說明使用 `ocx help codex`,Codex 本身說明使用 `ocx codex --help`。 +`-c`/`--config` 或 `-p`/`--profile` 可能變更目的地,因此也會略過自動權杖注入。使用者匯出的憑證與原始引數會保留。 之後啟動普通 `codex` 的行為與之前完全相同。 ### `ocx opencode [opencode args...]` diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 1eaeb5c19cc..7ea4f16c51d 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -151,6 +151,7 @@ export async function ensureProxyForCodex(deps: CodexProxyEnsureDeps = {}): Prom export type CodexLaunchEnvDeps = { routingKind?: CodexRoutingKind; + args?: readonly string[]; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; }; @@ -172,6 +173,8 @@ export function buildCodexLaunchEnv( ): CodexLaunchEnv { const env = { ...base }; if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; + // Config/profile overrides can replace the destination after the home was classified. + if (deps.args?.some(arg => /^(?:-c|-p|--config(?:=|$)|--profile(?:=|$))/.test(arg))) return env; if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { const token = (deps.serviceToken ?? defaultServiceToken)(env); if (token && !tokenCollidesWithAdmin(token, env)) env.OPENCODEX_API_AUTH_TOKEN = token; @@ -263,5 +266,5 @@ export async function cmdCodex(args: string[]): Promise { // what the session does next; `ocx doctor` names the autostart failure. console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); } - return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind })); + return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args })); } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 07c435bcb35..e374c472b87 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -613,12 +613,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "in ~/.codex/config.toml); a launch never rewrites a config file.", "For verified local routing, a missing OPENCODEX_API_AUTH_TOKEN is filled from ~/.opencodex/service-api-token, the same", "injection the `codex` autostart shim performs.", - "--help/-h, --version/-V, and Codex-internal subcommands (login, doctor, exec, ...) skip the", + "--help/-h, --version/-V, and Codex-internal subcommands (login, doctor, help, ...) skip the", "proxy ensure and pass the environment through unchanged, keeping the shim's argv grammar.", "No injected routing: informational notice, native exec, no ensure.", "Foreign gateway routing (custom-local/custom-remote): warning, native exec, that route untouched.", "Unverifiable routing: warning, and the proxy ensure is still attempted.", "Unknown routing never adds a service token; token-file overrides and management credentials are not used for client injection.", + "Forwarded -c/--config or -p/--profile overrides also skip automatic service-token injection.", "If the ensure fails, one stderr line is printed and `codex` launches anyway.", "When `codex` is the autostart shim, its backing Codex is launched directly so the shim", "never repeats the ensure or overrides the routing decision.", diff --git a/structure/cli-management.md b/structure/cli-management.md index 01232c2fa78..d68db73077f 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -24,6 +24,7 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting Codex routing. Native and foreign routes pass through; local and unknown routes attempt proxy startup and still launch with a diagnostic if it fails. Fast-path arguments skip startup and token loading. A recorded wrapper launches its backing Codex directly. Unknown shim state, including PATH lookup or inspection errors, launches `codex` with `OCX_SHIM_BYPASS=1`, skipping both shim startup and token loading without printing the inspection error. A missing executable retains the normal spawn error path. Only verified OpenCodex routing fills a missing admission token from the installation's service-token file, ignoring an inherited token-file override and refusing management-token collisions. Unknown routing adds no credential. No token is added to argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. +Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 463eb96fafa..a09444ec3f3 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -136,6 +136,24 @@ describe("codex launcher plan", () => { }); describe("codex launch env assembly", () => { + test("forwarded config and profile overrides never authorize a newly loaded token", () => { + for (const args of [ + ["-c", 'model_providers.opencodex.base_url="https://foreign.example/v1"', "hello"], + ['-cmodel_provider="foreign"', "hello"], ["--config", 'model_provider="foreign"'], + ['--config=model_provider="foreign"'], ["-p", "foreign"], ["-pforeign"], + ["--profile", "foreign"], ["--profile=foreign"], + ]) { + let consulted = false; + expect(buildCodexLaunchEnv({}, { + routingKind: "opencodex-local", args, + serviceToken: () => { consulted = true; return "ocx_fixture_service_token"; }, + })).toEqual({}); + expect(consulted).toBe(false); + const inherited = { OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_user_export" }; + expect(buildCodexLaunchEnv(inherited, { routingKind: "opencodex-local", args })).toEqual(inherited); + } + }); + test("an inherited token-file override cannot select the admin credential", () => { withTokenFiles(home => { const admin = `ocx_admin_${"a".repeat(43)}`; From 58286154476bdab27d9cfdeb85745f0af562a21f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:10:00 +0900 Subject: [PATCH 05/18] fix(cli): bind Codex credentials and verify Windows shim bypass Authorize automatic service-token delivery against the parsed managed provider transport and this installation's loopback listener or persisted local client connection, independently of routing names and comments. Refuse unverified profile, project, environment and argument destination overrides. Require complete generated CMD control flow before relying on shim bypass and execute the inspected selected command. Refuse obsolete or ambiguous wrappers before child creation; keep Windows backing provenance inspection deferred. Add regression-first credential and real obsolete/current CMD handoff coverage, and synchronize the launcher contracts and all eight documentation locales. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 29 +--- .../content/docs/ja/reference/cli/agents.md | 25 +--- .../content/docs/ko/reference/cli/agents.md | 24 +--- .../src/content/docs/reference/cli/agents.md | 26 +--- .../content/docs/ru/reference/cli/agents.md | 25 +--- .../content/docs/tr/reference/cli/agents.md | 26 +--- .../docs/zh-cn/reference/cli/agents.md | 21 +-- .../docs/zh-tw/reference/cli/agents.md | 21 +-- .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-launch-auth.ts | 58 ++++++++ src/cli/codex-launch-command.ts | 32 +++++ src/cli/codex-launcher.ts | 26 ++-- structure/cli-management.md | 2 +- structure/codex-home.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/runtime.md | 2 +- .../codex-launcher-security.test.ts | 134 ++++++++++++++++++ .../codex-integration/codex-launcher.test.ts | 33 +++-- tests/fixtures/test-layout-expected.json | 1 + 19 files changed, 322 insertions(+), 168 deletions(-) create mode 100644 src/cli/codex-launch-auth.ts create mode 100644 src/cli/codex-launch-command.ts create mode 100644 tests/codex-integration/codex-launcher-security.test.ts 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 75695ab6354..6a0c5e53a93 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -171,28 +171,13 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag ### `ocx codex [codex args...]` -Vérifiez que le proxy est actif, puis lancez `codex` sans modification. Le routage de Codex reste -la propriété de sa configuration — le `openai_base_url` injecté dans `~/.codex/config.toml` — -aucun lancement ne réécrit donc de fichier de configuration. Pour un routage local vérifié, un `OPENCODEX_API_AUTH_TOKEN` absent -est renseigné depuis `~/.opencodex/service-api-token`, la même injection que celle du shim de -démarrage automatique Codex. - -`--help`/`-h`, `--version`/`-V` et les sous-commandes internes de Codex (`login`, `doctor`, `help`, -...) sautent l'assurance du proxy et traversent l'environnement inchangé, en reprenant la -grammaire d'arguments du shim. - -Le routage est examiné avant exécution : sans routage injecté, un message informatif s'affiche et -`codex` est lancé sans assurance du proxy ; une passerelle étrangère (custom-local/custom-remote) -provoque un avertissement et un passage sans toucher à cette route ; un routage invérifiable -provoque un avertissement et tente quand même d'assurer le proxy. Si cette tentative échoue, une -ligne s'affiche sur stderr et `codex` est lancé malgré tout. Lorsque `codex` correspond au shim de -démarrage automatique, le lanceur exécute directement le Codex sous-jacent pour que le shim ne -répète pas l'assurance. Un lancement ultérieur de `codex` sans opencodex se comporte -exactement comme auparavant. - -Si la recherche de l’exécutable ou l’inspection du shim échoue, le lanceur exécute quand même `codex` avec `OCX_SHIM_BYPASS=1`, empêchant le shim de relancer le proxy ou de charger un jeton de service. -Un routage inconnu n’ajoute jamais de jeton de service. La variable de remplacement du fichier de jeton est ignorée pour le client et les identifiants d’administration sont refusés. `ocx help codex` affiche l’aide du lanceur ; `ocx codex --help` atteint Codex. -Les options `-c`/`--config` ou `-p`/`--profile` désactivent aussi l’injection automatique, car elles peuvent changer la destination. Les identifiants exportés par l’utilisateur et les arguments restent inchangés. +Le lanceur tente de démarrer le proxy puis transmet les arguments d’origine à Codex, sans réécrire sa configuration. Les routes natives et les passerelles tierces sautent le démarrage. Une route inconnue avertit puis tente le démarrage ; un échec avertit et continue. L’aide, la version et les commandes internes comme `login`, `doctor` et `help` sautent le démarrage et la lecture automatique du jeton. + +Un `OPENCODEX_API_AUTH_TOKEN` absent est lu dans le `service-api-token` de cette installation uniquement si toute la configuration de transport du provider sélectionné correspond au bloc géré pour son écouteur loopback ou sa connexion client locale enregistrée. Le nom et les commentaires ne suffisent pas. Destinations inconnues ou distantes, profile par défaut, configuration de projet, `OPENAI_BASE_URL` et options transmises `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` désactivent cette lecture. Les identifiants exportés et les arguments sont conservés ; les remplacements du fichier et les collisions avec les jetons d’administration sont refusés. + +Un shim dont la provenance est vérifiée lance directement son Codex sous-jacent. Sinon, les wrappers CMD Windows doivent correspondre intégralement au modèle généré actuel avant l’exécution du chemin absolu sélectionné avec `OCX_SHIM_BYPASS=1`. Les lanceurs npm ordinaires restent pris en charge. Les wrappers gérés obsolètes, illisibles ou ambigus sont refusés avec un diagnostic : actualisez-les avec `ocx codex-shim install` ou choisissez l’exécutable natif. Ce lancement ne répare pas les shims. Le contrôle du contenu ne garantit ni la provenance du fichier sous-jacent Windows ni l’absence de remplacement concurrent du chemin. + +Aide du lanceur : `ocx help codex` ; aide de Codex : `ocx codex --help`. Le comportement ultérieur de `codex` seul est conservé. ### `ocx opencode [opencode args...]` 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 cbd05ad85c6..b1b2d3984b0 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -132,24 +132,13 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` -プロキシが実行されていることを確認し、`codex` をそのまま起動します。Codex のルーティングは設定 -(`~/.codex/config.toml` に注入された `openai_base_url`) が所有しているため、この起動は設定 -ファイルを書き換えません。環境に `OPENCODEX_API_AUTH_TOKEN` がない場合は、Codex オートスタート -シムと同じ注入で `~/.opencodex/service-api-token` から補完します。 - -`--help`/`-h`、`--version`/`-V`、および Codex 内部のサブコマンド (`login`、`doctor`、`help` な -ど) はプロキシの ensure をスキップし、環境をそのまま渡します (シムの引数文法と一致)。 - -実行前にルーティングを判定します。注入されたルーティングがない場合は情報メッセージを出し、ensure -なしで `codex` を起動します。外部ゲートウェイ (custom-local/custom-remote) では警告を出し、その -ルートに触れずに渡します。検証できないルーティングでは警告を添えて ensure を試みます。ensure に失敗した場合は -stderr に 1 行出力し、それでも `codex` を起動します。`codex` がオートスタートシムを指す場合は、シムが -ensure を繰り返さないよう、その背後にある Codex を直接起動します。後でプレーン `codex` を起動すると、 -以前とまったく同じように動作します。 - -実行ファイルの検索やシムの検査に失敗しても、`OCX_SHIM_BYPASS=1` を設定して `codex` を起動し、シムによるプロキシの再起動やサービストークンの読み込みを防ぎます。 -サービストークンの補完は検証済みのローカルルーティングに限り、不明なルーティングには追加しません。クライアントへの注入ではトークンファイルの環境変数による上書きを無視し、管理用認証情報を拒否します。ランチャーのヘルプは `ocx help codex`、Codex 自体のヘルプは `ocx codex --help` で表示します。 -`-c`/`--config` または `-p`/`--profile` は接続先を変更できるため、自動トークン注入を省略します。ユーザーがエクスポートした認証情報と元の引数は保持します。 +元の引数で Codex を起動する前にプロキシの起動を試みます。ルーティングは Codex 設定が所有し、ランチャーは書き換えません。ネイティブと外部ゲートウェイでは起動を省略します。不明なルートでは警告して起動を試み、失敗しても警告後に続行します。ヘルプ、バージョン、`login`、`doctor`、`help` などの内部コマンドは起動と自動トークン読み込みを省略します。 + +選択した provider の全転送設定が、このインストールのループバックリスナーまたは保存されたローカルクライアント接続向けの管理ブロックと一致する場合だけ、不足する `OPENCODEX_API_AUTH_TOKEN` をこのインストールの `service-api-token` から読みます。名前や注入コメントだけでは許可しません。不明・リモートの接続先、既定 profile、プロジェクト設定、`OPENAI_BASE_URL`、転送した `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` は自動読み込みを省略します。ユーザーがエクスポートした認証情報と引数を保持し、トークンファイルの上書きと管理トークンの衝突を拒否します。 + +出所を検証した shim は背後の Codex を直接実行します。それ以外の Windows CMD wrapper は現在の生成テンプレート全体との一致が必要で、選択した絶対パスを `OCX_SHIM_BYPASS=1` で実行します。通常の npm ランチャーも使えます。古い、読めない、曖昧な管理 wrapper は診断を出して拒否します。`ocx codex-shim install` で更新するかネイティブ実行ファイルを選んでください。この起動で shim は修復しません。内容検査は Windows backing ファイルの出所や同時のパス置換を保証しません。 + +ランチャーのヘルプは `ocx help codex`、Codex のヘルプは `ocx codex --help` です。後で通常の `codex` を起動する動作は変わりません。 ### `ocx opencode [opencode args...]` 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 d54340c7916..c9ed95b752a 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -164,23 +164,13 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 ### `ocx codex [codex args...]` -프록시가 실행 중인지 확인한 뒤, `codex`를 그대로 실행합니다. Codex 라우팅은 config(`~/.codex/config.toml`에 주입된 -`openai_base_url`)가 소유하므로, 이 실행은 config 파일을 재작성하지 않습니다. 환경에 `OPENCODEX_API_AUTH_TOKEN`이 -없으면 `~/.opencodex/service-api-token`에서 채웁니다. 이는 Codex 자동 시작 shim과 동일한 주입입니다. - -`--help`/`-h`, `--version`/`-V` 및 Codex 내부 하위 명령(`login`, `doctor`, `help` 등)은 프록시 ensure를 -건너뛰고 환경을 그대로 전달합니다(shim의 argv 문법과 일치). - -실행 전에 라우팅을 확인합니다. 주입된 라우팅이 없으면 안내 메시지를 출력하고 ensure 없이 `codex`를 실행하고, -다른 로컬/원격 게이트웨이(custom-local/custom-remote)는 경고를 출력한 뒤 해당 경로를 건드리지 않고 전달하며, -확인 불가능한 라우팅은 경고를 출력하고 ensure를 시도합니다. ensure가 실패하면 stderr에 한 줄을 출력하고 -그래도 `codex`를 실행합니다. `codex`가 자동 시작 shim을 가리키면 shim이 ensure를 반복하지 않도록 그 뒤의 Codex를 -직접 실행합니다. 이후 plain -`codex`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. - -실행 파일 검색이나 shim 검사가 실패해도 `OCX_SHIM_BYPASS=1`로 `codex`를 실행하여 shim의 프록시 재시작과 서비스 토큰 읽기를 건너뜁니다. -서비스 토큰 보충은 확인된 로컬 라우팅에만 적용되며, 알 수 없는 라우팅에는 추가하지 않습니다. 클라이언트 주입 시 토큰 파일 환경 변수의 경로 재정의를 무시하고 관리자 자격 증명을 거부합니다. 런처 도움말은 `ocx help codex`, Codex 자체 도움말은 `ocx codex --help`를 사용합니다. -`-c`/`--config` 또는 `-p`/`--profile`은 목적지를 바꿀 수 있으므로 자동 토큰 주입을 생략합니다. 사용자가 export한 자격 증명과 원래 인자는 보존합니다. +원래 인자로 Codex를 실행하기 전에 프록시 시작을 시도합니다. 라우팅은 Codex 설정이 소유하며 런처가 재작성하지 않습니다. 네이티브 및 다른 게이트웨이 경로에서는 시작을 생략합니다. 알 수 없는 라우팅은 경고 후 시작을 시도하며, 시작 실패 시에도 경고 후 실행합니다. 도움말·버전 및 `login`, `doctor`, `help` 같은 내부 명령은 시작과 자동 토큰 읽기를 생략합니다. + +선택한 provider의 전체 전송 설정이 이 설치의 로컬 리스너 또는 저장된 로컬 클라이언트 연결에 대해 생성한 관리 블록과 일치할 때만, 누락된 `OPENCODEX_API_AUTH_TOKEN`을 이 설치의 `service-api-token`에서 읽습니다. 이름이나 주입 주석만으로는 허용하지 않습니다. 알 수 없거나 원격인 목적지, 기본 profile, 프로젝트 설정, `OPENAI_BASE_URL`, 전달된 `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote`는 자동 읽기를 생략합니다. 사용자가 export한 자격 증명과 인자는 보존하며, 토큰 파일 경로 재정의와 관리 토큰 충돌은 거부합니다. + +출처가 검증된 shim은 뒤의 Codex를 직접 실행합니다. 그 외 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 선택된 절대 경로를 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처도 지원합니다. 오래되었거나 읽을 수 없거나 모호한 관리 wrapper는 진단 후 실행을 거부합니다. `ocx codex-shim install`로 갱신하거나 네이티브 실행 파일을 선택하세요. 이 실행은 shim을 자동 복구하지 않습니다. 내용 검사는 Windows backing 파일의 출처나 동시 경로 교체를 보장하지 않습니다. + +런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 76ea6e8e691..6f533d4113c 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -965,25 +965,13 @@ before choosing it. Rendered models are not proof of native application. ### `ocx codex [codex args...]` -Ensure the proxy is running, then launch `codex` unchanged. Codex routing stays config-owned — -the injected `openai_base_url` in `~/.codex/config.toml` — so a launch never rewrites a config -file. For verified local routing, a missing `OPENCODEX_API_AUTH_TOKEN` is filled from `~/.opencodex/service-api-token`, -the same injection the Codex autostart shim performs. - -`--help`/`-h`, `--version`/`-V`, and Codex-internal subcommands (`login`, `doctor`, `help`, ...) -skip the proxy ensure and pass the environment through unchanged, matching the shim's argv -grammar. - -Routing is examined before exec: no injected routing prints an informational notice and launches -`codex` with no ensure; a foreign gateway (custom-local/custom-remote) prints a warning and passes -through without touching that route; unverifiable routing prints a warning and still attempts the ensure. If the ensure fails, one stderr -line is printed and `codex` launches anyway. When `codex` resolves to the autostart shim, the -launcher runs the backing Codex directly so the shim does not repeat the ensure. Launching plain -`codex` later behaves exactly as before. - -If executable lookup or shim inspection fails, the launcher still runs `codex` with `OCX_SHIM_BYPASS=1`, preventing the shim from repeating proxy startup or loading a service token. -Unknown routing never adds a service token. The token-file environment override is ignored for client injection, and management credentials are rejected. Use `ocx help codex` for launcher help; `ocx codex --help` reaches Codex itself. -Forwarding `-c`/`--config` or `-p`/`--profile` also skips automatic token injection because these options can change the destination. User-exported credentials and the original arguments are preserved. +Ensure the proxy is running, then launch Codex with the original arguments. Routing remains in Codex configuration; the launcher never rewrites it. Native and foreign routes skip startup. Unknown routing warns and attempts startup; a failed startup warns and continues. Help/version and internal commands such as `login`, `doctor`, and `help` skip startup and automatic token loading. + +A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-api-token` only when the selected provider's complete transport configuration matches the managed block for the configured loopback listener or persisted local client connection. Names and injection comments alone grant no credential access. Unknown or remote destinations, default profiles, project configuration, `OPENAI_BASE_URL`, and forwarded `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, or `--remote` options skip automatic loading. User-exported credentials and arguments are preserved; token-file overrides cannot select a different credential, and management-token collisions are refused. + +A provenance-verified shim uses its backing Codex directly. Otherwise Windows CMD wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Obsolete, unreadable, or ambiguous managed wrappers fail closed with a diagnostic; refresh them with `ocx codex-shim install` or select the native executable. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. + +Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. ### `ocx opencode [opencode args...]` 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 717ae608d4b..661f9aed9ef 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -153,24 +153,13 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` -Убедиться, что прокси запущен, и затем запустить `codex` без изменений. Маршрутизация Codex остаётся -владением config — инжектированный `openai_base_url` в `~/.codex/config.toml` — поэтому запуск -никогда не переписывает файлы конфигурации. Отсутствующий `OPENCODEX_API_AUTH_TOKEN` берётся из -`~/.opencodex/service-api-token` — та же инъекция, что и в autostart шиме Codex. - -`--help`/`-h`, `--version`/`-V` и внутренние подкоманды Codex (`login`, `doctor`, `help`, ...) -пропускают ensure прокси и передают окружение как есть — та же грамматика argv, что и у шима. - -Роутинг проверяется перед запуском: без инжектированного роутинга печатается информационное сообщение -и `codex` запускается без ensure; чужой шлюз (custom-local/custom-remote) — warning и passthrough без -касания этого пути; непроверяемый роутинг — warning, но попытка ensure всё равно выполняется. Если она -не удалась, в stderr выводится одна строка, и `codex` всё равно запускается. Если `codex` указывает на -autostart шим, лаунчер запускает исходный Codex напрямую, чтобы шим не повторял ensure. Последующий запуск обычного `codex` работает -ровно как раньше. - -При ошибке поиска исполняемого файла или проверки шима лаунчер всё равно запускает `codex` с `OCX_SHIM_BYPASS=1`, чтобы шим не повторял запуск прокси и не загружал сервисный токен. -Сервисный токен добавляется только при проверенном локальном роутинге, никогда при неизвестном. Переопределение пути к файлу токена через окружение игнорируется для клиента, административные токены отклоняются. Справка лаунчера: `ocx help codex`; справка самого Codex: `ocx codex --help`. -Опции `-c`/`--config` и `-p`/`--profile` также отключают автоматическую передачу токена, поскольку могут изменить адрес назначения. Экспортированные пользователем учётные данные и исходные аргументы сохраняются. +Лаунчер пытается запустить прокси и передаёт Codex исходные аргументы, не переписывая конфигурацию. Нативные маршруты и чужие шлюзы пропускают запуск прокси. Неизвестный маршрут предупреждает и пытается запустить прокси; неудача предупреждает и продолжает выполнение. Справка, версия и внутренние команды `login`, `doctor`, `help` пропускают запуск и автоматическое чтение токена. + +Отсутствующий `OPENCODEX_API_AUTH_TOKEN` читается из `service-api-token` этой установки только при полном совпадении транспортной конфигурации выбранного provider с управляемым блоком для настроенного loopback-слушателя или сохранённого локального клиентского подключения. Имя и комментарии не дают разрешения. Неизвестные или удалённые адреса, profile по умолчанию, конфигурация проекта, `OPENAI_BASE_URL` и переданные `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` отключают автоматическое чтение. Экспортированные пользователем учётные данные и аргументы сохраняются; подмена файла токена и совпадения с административными токенами отклоняются. + +Shim с проверенным происхождением запускает исходный Codex напрямую. Иначе Windows CMD wrapper должен полностью совпадать с текущим генерируемым шаблоном; выбранный абсолютный путь запускается с `OCX_SHIM_BYPASS=1`. Обычные npm-лаунчеры поддерживаются. Устаревший, нечитаемый или неоднозначный управляемый wrapper отклоняется с диагностикой: обновите его через `ocx codex-shim install` или выберите нативный исполняемый файл. Этот запуск не ремонтирует shim. Проверка содержимого не гарантирует происхождение backing-файла Windows и не предотвращает одновременную замену пути. + +Справка лаунчера: `ocx help codex`; справка Codex: `ocx codex --help`. Последующий обычный запуск `codex` сохраняет прежнее поведение. ### `ocx opencode [opencode args...]` 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 14cf335aed4..797d0604552 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -189,25 +189,13 @@ eder. Claude Code ayarları için `ocx claude config ...` kullanın ### `ocx codex [codex args...]` -Proxy'nin çalıştığından emin olun, ardından `codex`'i değiştirmeden başlatın. Codex yönlendirmesi -yapılandırmaya aittir (`~/.codex/config.toml` içine yazılan `openai_base_url`), bu yüzden bir -başlatma hiçbir zaman yapılandırma dosyalarını değiştirmez. Eksik `OPENCODEX_API_AUTH_TOKEN`, -`~/.opencodex/service-api-token` üzerinden doldurulur — Codex otomatik başlatma shim'inin yaptığı -injection ile aynı. - -`--help`/`-h`, `--version`/`-V` ve Codex'in iç alt komutları (`login`, `doctor`, `help`, ...) proxy -garantisini atlar ve ortamı değiştirmeden geçirir — shim'in argv dilbilgisiyle aynı. - -Başlatmadan önce yönlendirme değerlendirilir. Yazılmış yönlendirme yoksa bilgilendirme yazılır ve -`codex` garanti olmadan başlatılır; yabancı bir ağ geçidi (custom-local/custom-remote) uyarı yazar -ve o yola dokunmadan geçer; doğrulanamayan yönlendirme uyarı yazar ama garanti yine de denenir. -Deneme başarısız olursa stderr'e tek satır yazılır ve `codex` yine de başlatılır. `codex` otomatik -başlatma shim'ine çözülürse, shim garantiyi tekrarlamasın diye arkasındaki Codex doğrudan başlatılır. Daha sonra düz `codex` başlatmak tam -olarak eskisi gibi davranır. - -Çalıştırılabilir dosya araması veya shim incelemesi başarısız olursa başlatıcı yine de `codex`'i `OCX_SHIM_BYPASS=1` ile çalıştırır; böylece shim proxy'yi yeniden başlatmaz ve hizmet belirtecini yüklemez. -Hizmet belirteci yalnızca doğrulanmış yerel yönlendirmede eklenir; bilinmeyen yönlendirmede eklenmez. İstemciye aktarımda belirteç dosyasının ortam değişkeniyle değiştirilmesi yok sayılır ve yönetim kimlik bilgileri reddedilir. Başlatıcı yardımı için `ocx help codex`, Codex yardımı için `ocx codex --help` kullanın. -`-c`/`--config` veya `-p`/`--profile` hedefi değiştirebildiğinden otomatik belirteç aktarımını da atlar. Kullanıcının dışa aktardığı kimlik bilgileri ve özgün argümanlar korunur. +Başlatıcı proxy'yi başlatmayı dener ve özgün argümanları Codex'e aktarır; yapılandırmayı yeniden yazmaz. Yerel Codex ve yabancı ağ geçidi yolları proxy başlangıcını atlar. Bilinmeyen yol uyarıp başlangıcı dener; başarısızlık uyarır ve devam eder. Yardım, sürüm ve `login`, `doctor`, `help` gibi iç komutlar başlangıcı ve otomatik belirteç okumayı atlar. + +Eksik `OPENCODEX_API_AUTH_TOKEN`, yalnızca seçili provider'ın tüm taşıma yapılandırması bu kurulumun yapılandırılmış loopback dinleyicisi veya kayıtlı yerel istemci bağlantısı için yönetilen blokla eşleştiğinde bu kurulumun `service-api-token` dosyasından okunur. Adlar ve açıklamalar yetki vermez. Bilinmeyen veya uzak hedefler, varsayılan profile, proje yapılandırması, `OPENAI_BASE_URL` ve iletilen `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` otomatik okumayı atlar. Kullanıcının dışa aktardığı kimlik bilgileri ve argümanlar korunur; belirteç dosyası geçersiz kılmaları ve yönetim belirteci çakışmaları reddedilir. + +Kaynağı doğrulanmış shim arkasındaki Codex'i doğrudan çalıştırır. Diğer Windows CMD wrapper'ları, seçilen mutlak yol `OCX_SHIM_BYPASS=1` ile çalıştırılmadan önce güncel üretilen şablonla tamamen eşleşmelidir. Normal npm başlatıcıları desteklenir. Eski, okunamayan veya belirsiz yönetilen wrapper tanı mesajıyla reddedilir; `ocx codex-shim install` ile yenileyin veya yerel yürütülebilir dosyayı seçin. Bu başlatma shim'i onarmaz. İçerik denetimi Windows backing dosyasının kaynağını doğrulamaz ve eşzamanlı yol değişimini engellemez. + +Başlatıcı yardımı: `ocx help codex`; Codex yardımı: `ocx codex --help`. Sonraki düz `codex` çalıştırmasının davranışı korunur. ### `ocx opencode [opencode argumanlari...]` 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 14fe263e23b..80936c6bc3a 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 @@ -136,20 +136,13 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` -确保代理正在运行,然后原样启动 `codex`。Codex 的路由始终归配置所有(`~/.codex/config.toml` 中注入的 -`openai_base_url`),因此启动不会改写任何配置文件。环境缺少 `OPENCODEX_API_AUTH_TOKEN` 时,会从 -`~/.opencodex/service-api-token` 补上,与 Codex 自动启动 shim 采用相同的注入方式。 - -`--help`/`-h`、`--version`/`-V` 以及 Codex 内部子命令(`login`、`doctor`、`help` 等)会跳过代理 -ensure 并原样传递环境,与 shim 的 argv 语法保持一致。 - -执行前会先判定路由:没有注入路由时输出提示信息,并不做 ensure 直接启动 `codex`;外部网关(custom-local/custom-remote) -会输出警告,按原样通过且不触碰该路由;无法验证的路由会输出警告但仍尝试 ensure;ensure 失败时会在 stderr 输出一行,并仍然启动 `codex`。 -当 `codex` 指向自动启动 shim 时,启动器会直接运行其背后的 Codex,避免 shim 重复 ensure。 -如果查找可执行文件或检查 shim 失败,启动器仍会以 `OCX_SHIM_BYPASS=1` 运行 `codex`,跳过 shim 的代理启动和服务令牌加载。 -只有已验证的本地路由才会补充服务令牌,未知路由不会添加。客户端注入会忽略令牌文件路径的环境变量覆盖,并拒绝管理凭据。启动器帮助使用 `ocx help codex`,Codex 自身帮助使用 `ocx codex --help`。 -`-c`/`--config` 或 `-p`/`--profile` 可能改变目标,因此也会跳过自动令牌注入。用户导出的凭据和原始参数会保留。 -稍后再启动普通 `codex` 时,行为与之前完全一致。 +使用原始参数启动 Codex 前尝试启动代理。路由仍归 Codex 配置所有,启动器不会改写。本机原生及其他网关路由跳过代理启动;未知路由会警告并尝试启动,失败后仍警告并继续。帮助、版本及 `login`、`doctor`、`help` 等内部命令跳过代理启动和自动令牌读取。 + +只有所选 provider 的完整传输配置与本次安装的回环监听器或已保存的本地客户端连接对应的管理块一致时,才从本次安装的 `service-api-token` 读取缺失的 `OPENCODEX_API_AUTH_TOKEN`。名称或注入注释本身不构成授权。未知或远程目标、默认 profile、项目配置、`OPENAI_BASE_URL` 以及传入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都会跳过自动读取。保留用户导出的凭据和参数,拒绝令牌文件覆盖和管理令牌冲突。 + +来源已验证的 shim 直接运行其背后的 Codex。其他 Windows CMD wrapper 必须与当前生成模板完整一致,才以 `OCX_SHIM_BYPASS=1` 执行选定的绝对路径。普通 npm 启动器仍受支持。过时、无法读取或含糊的管理 wrapper 会输出诊断并拒绝执行;请用 `ocx codex-shim install` 更新或选择原生可执行文件。本次启动不修复 shim。内容检查不保证 Windows backing 文件来源,也不防止并发路径替换。 + +启动器帮助为 `ocx help codex`,Codex 帮助为 `ocx codex --help`。以后直接运行普通 `codex` 的行为保持不变。 ### `ocx opencode [opencode args...]` 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 f75484d4d76..a208608ef02 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 @@ -133,20 +133,13 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ### `ocx codex [codex args...]` -確保代理正在執行,然後原樣啟動 `codex`。Codex 的路由始終由設定擁有(`~/.codex/config.toml` 中注入的 -`openai_base_url`),因此啟動不會改寫任何設定檔。環境缺少 `OPENCODEX_API_AUTH_TOKEN` 時,會從 -`~/.opencodex/service-api-token` 補上,與 Codex 自動啟動 shim 採用相同的注入方式。 - -`--help`/`-h`、`--version`/`-V` 以及 Codex 內部子命令(`login`、`doctor`、`help` 等)會跳過代理 -ensure 並原樣傳遞環境,與 shim 的 argv 語法保持一致。 - -執行前會先判定路由:沒有注入路由時輸出提示訊息,並不做 ensure 直接啟動 `codex`;外部閘道(custom-local/custom-remote) -會輸出警告並原樣通過、不觸碰該路由;無法驗證的路由會輸出警告但仍嘗試 ensure;ensure 失敗時會在 stderr 輸出一行,並仍然啟動 `codex`。 -當 `codex` 指向自動啟動 shim 時,啟動器會直接執行其背後的 Codex,避免 shim 重複 ensure。 -若尋找執行檔或檢查 shim 失敗,啟動器仍會以 `OCX_SHIM_BYPASS=1` 執行 `codex`,略過 shim 的代理啟動與服務權杖載入。 -只有已驗證的本機路由才會補上服務權杖,未知路由不會新增。用戶端注入會忽略權杖檔案路徑的環境變數覆寫,並拒絕管理憑證。啟動器說明使用 `ocx help codex`,Codex 本身說明使用 `ocx codex --help`。 -`-c`/`--config` 或 `-p`/`--profile` 可能變更目的地,因此也會略過自動權杖注入。使用者匯出的憑證與原始引數會保留。 -之後啟動普通 `codex` 的行為與之前完全相同。 +使用原始引數啟動 Codex 前嘗試啟動代理。路由仍由 Codex 設定擁有,啟動器不會改寫。原生及其他閘道路由略過代理啟動;未知路由會警告並嘗試啟動,失敗後仍警告並繼續。說明、版本及 `login`、`doctor`、`help` 等內部命令略過代理啟動和自動權杖讀取。 + +只有所選 provider 的完整傳輸設定與本次安裝的回環監聽器或已儲存的本機用戶端連線對應的管理區塊一致時,才從本次安裝的 `service-api-token` 讀取缺少的 `OPENCODEX_API_AUTH_TOKEN`。名稱或注入註解本身不構成授權。未知或遠端目的地、預設 profile、專案設定、`OPENAI_BASE_URL` 及傳入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都會略過自動讀取。保留使用者匯出的憑證與引數,拒絕權杖檔案覆寫和管理權杖衝突。 + +來源已驗證的 shim 直接執行背後的 Codex。其他 Windows CMD wrapper 必須與目前產生的範本完整一致,才以 `OCX_SHIM_BYPASS=1` 執行選定的絕對路徑。一般 npm 啟動器仍受支援。過時、無法讀取或模糊的管理 wrapper 會輸出診斷並拒絕執行;請用 `ocx codex-shim install` 更新或選擇原生執行檔。本次啟動不修復 shim。內容檢查不保證 Windows backing 檔案來源,也不防止並行路徑替換。 + +啟動器說明為 `ocx help codex`,Codex 說明為 `ocx codex --help`。之後直接執行一般 `codex` 的行為保持不變。 ### `ocx opencode [opencode args...]` diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 8a93a38aa93..63530224fa1 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,6 +1,7 @@ { "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", + "codex-launcher-security.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts new file mode 100644 index 00000000000..4d46d9a8141 --- /dev/null +++ b/src/cli/codex-launch-auth.ts @@ -0,0 +1,58 @@ +import { lstatSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { readConfigDiagnostics, websocketsEnabled } from "../config"; +import { getCodexHome } from "../codex/paths"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; +import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; +import { readClientConnectionState } from "../client/state"; + +/** Config classification is a startup hint; credentials need an independently bound destination. */ +export function codexCredentialDestinationIsTrusted(): boolean { + try { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.error || diagnostics.source === "fallback") return false; + const cfg = diagnostics.config; + let target = standaloneCodexRoutingTarget(cfg.port, cfg); + if (cfg.runtimeRole === "client") { + // Direct clients bind to persisted connection state; link clients use an authless relay. + const connection = readClientConnectionState(); + if (connection.kind !== "connected" || connection.value.transport === "link") return false; + target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + } + const url = new URL(target.baseUrl); + if (!target.requiresAdmissionToken) return false; + if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return false; + const home = getCodexHome(); + const content = readBoundedCodexConfig(join(home, "config.toml")); + if (content === null) return false; + const config = Bun.TOML.parse(content) as Record; + // Codex layers project configs and profiles after the home. Without proving those layers, + // conservatively leave authentication to the user's environment. + if (config.profile !== undefined || config.model_provider !== "opencodex") return false; + for (const key of ["openai_base_url", "experimental_realtime_ws_base_url"]) { + if (config[key] !== undefined && config[key] !== target.baseUrl) return false; + } + for (let directory = process.cwd(); ; directory = dirname(directory)) { + const projectConfig = join(directory, ".codex", "config.toml"); + if (projectConfig !== join(home, "config.toml")) { + try { lstatSync(projectConfig); return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + } + // Codex's project layers stop at the repository root, including a worktree's .git file. + try { lstatSync(join(directory, ".git")); break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + if (dirname(directory) === directory) break; + } + const providers = config.model_providers as Record | undefined; + const provider = providers?.opencodex as Record | undefined; + const expected = (Bun.TOML.parse(buildProviderTableBlockForTarget(target, websocketsEnabled(cfg))) as { model_providers: { opencodex: Record } }).model_providers.opencodex; + // Display names are cosmetic; every transport and credential-bearing field must match + // the block emitted by our writer for this installation/connection. + if (!provider || typeof provider.name !== "string") return false; + const { name: _name, ...transport } = provider; + const { name: _expectedName, ...expectedTransport } = expected; + return isDeepStrictEqual(transport, expectedTransport); + } catch { return false; } +} diff --git a/src/cli/codex-launch-command.ts b/src/cli/codex-launch-command.ts new file mode 100644 index 00000000000..7cbf87e9aea --- /dev/null +++ b/src/cli/codex-launch-command.ts @@ -0,0 +1,32 @@ +import { extname, win32 } from "node:path"; +import { readBoundedProjectConfig } from "../codex/project-config-warnings"; +import { buildWindowsCodexShim, SHIM_MARKER } from "../codex/shim-templates"; +import type { BunRuntimeSource } from "../lib/bun-runtime"; + +export const CODEX_UNVERIFIED_SHIM = "opencodex: cannot verify Codex shim bypass; launch refused. Run 'ocx codex-shim install' to refresh an obsolete shim, or select the native Codex executable."; + +/** A content check for the generated CMD control flow, not Windows backing-file provenance. */ +export function codexCommandCanBypass(selected: string, env: Record, platform = process.platform): boolean { + if (platform !== "win32" || ![".cmd", ".bat"].includes(extname(selected).toLowerCase())) return true; + const content = readBoundedProjectConfig(selected); + if (content === null) return false; + // Ordinary npm launchers remain ordinary executable code. Managed/obsolete wrappers must + // prove the whole emitted control flow, including the unique terminal bypass destination. + if (!content.includes(SHIM_MARKER) && !/OCX_|OPENCODEX_API_AUTH_TOKEN|service-api-token/i.test(content)) return true; + const source = /^set "OCX_BUN_RUNTIME_SOURCE=(override|bundled|process|standalone)"\r?$/m.exec(content)?.[1] as BunRuntimeSource | undefined; + if (!source) return false; + const normalize = (text: string): string | null => { + let invalid = false; + const normalized = text.replace(/\r\n/g, "\n").replace(/^set "(OCX_REAL_CODEX|OCX_BUN|OCX_CLI|OCX_API_TOKEN_FILE|OCX_BUN_RUNTIME_PATH)=([^"\n]*)"$/gm, (_line, key: string, raw: string) => { + const expanded = raw.replace(/^%(USERPROFILE|LOCALAPPDATA|APPDATA)%/i, (_prefix, variable: string) => env[variable.toUpperCase()] ?? ""); + // Restrict substitutions to literal local paths. Refuse CMD expansion, separators, + // delayed expansion and control bytes rather than interpreting batch escaping. + if (!/^[a-z]:[\\/]/i.test(expanded) || !win32.isAbsolute(expanded) || /[%!^&|<>"\x00-\x1f\x7f]/.test(expanded)) invalid = true; + return `set "${key}="`; + }); + return invalid ? null : normalized; + }; + const expected = buildWindowsCodexShim("C:\\ocx\\codex.cmd", "C:\\ocx\\bun.exe", "C:\\ocx\\cli.ts", source); + const actual = normalize(content); + return actual !== null && actual === normalize(expected); +} diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 7ea4f16c51d..a063c631e8a 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -34,6 +34,8 @@ import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/servic import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; +import { codexCredentialDestinationIsTrusted } from "./codex-launch-auth"; +import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; export interface CodexLaunchEnv { [key: string]: string | undefined; @@ -154,6 +156,7 @@ export type CodexLaunchEnvDeps = { args?: readonly string[]; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; + credentialDestination?: () => boolean; }; /** Read the client admission credential from this installation's service-token file. */ @@ -174,8 +177,9 @@ export function buildCodexLaunchEnv( const env = { ...base }; if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; // Config/profile overrides can replace the destination after the home was classified. - if (deps.args?.some(arg => /^(?:-c|-p|--config(?:=|$)|--profile(?:=|$))/.test(arg))) return env; + if (deps.args?.some(arg => /^(?:-c|-p|-C|--config(?:=|$)|--profile(?:=|$)|--cd(?:=|$)|--remote(?:[=-]|$))/.test(arg))) return env; if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { + if (env.OPENAI_BASE_URL || !(deps.credentialDestination ?? codexCredentialDestinationIsTrusted)()) return env; const token = (deps.serviceToken ?? defaultServiceToken)(env); if (token && !tokenCollidesWithAdmin(token, env)) env.OPENCODEX_API_AUTH_TOKEN = token; } @@ -200,6 +204,7 @@ export function codexNotFoundHint( export type CodexLaunchCommandDeps = { findOnPath?: (name: string) => string | null; inspectShim?: typeof inspectCodexShimBackingForCommand; + verifyBypass?: typeof codexCommandCanBypass; }; export type CodexLaunchCommand = { command: string; env: CodexLaunchEnv }; @@ -208,9 +213,8 @@ export type CodexLaunchCommand = { command: string; env: CodexLaunchEnv }; * The launcher has already made the ensure decision, so the generated autostart shim * must not make it again: on a passthrough plan it would start the proxy and load the * admission token anyway. When `codex` resolves to a recorded shim wrapper, exec its - * backing Codex directly. When the shim state cannot be read (always on win32, where - * inspection is deferred), keep `codex` and set OCX_SHIM_BYPASS so at least the shim's - * ensure is skipped. + * backing Codex directly. Unknown state requires a separate bypass-content check; + * obsolete or unverifiable Windows wrappers fail closed before starting any child. */ export function resolveCodexLaunchCommand( env: CodexLaunchEnv, @@ -218,19 +222,23 @@ export function resolveCodexLaunchCommand( ): CodexLaunchCommand { try { const selected = (deps.findOnPath ?? findExecutableOnPath)("codex"); - if (!selected) return { command: "codex", env }; + if (!selected) throw new Error(CODEX_UNVERIFIED_SHIM); const shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); if (shim.status === "matched" && shim.selectedRole === "wrapper") return { command: shim.backingPath, env }; - if (shim.status !== "unknown") return { command: "codex", env }; - } catch { // Inspection is optional; retain the same bypass as an unknown state. + if (shim.status !== "unknown") return { command: selected, env }; + if (!(deps.verifyBypass ?? codexCommandCanBypass)(selected, env)) throw new Error(CODEX_UNVERIFIED_SHIM); + return { command: selected, env: { ...env, OCX_SHIM_BYPASS: "1" } }; + } catch { + throw new Error(CODEX_UNVERIFIED_SHIM); } - return { command: "codex", env: { ...env, OCX_SHIM_BYPASS: "1" } }; } /** Launch the selected Codex command with inherited IO and preserve its exit status. */ function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { return new Promise(resolve => { - const launch = resolveCodexLaunchCommand(env); + let launch: CodexLaunchCommand; + try { launch = resolveCodexLaunchCommand(env); } + catch { console.error(CODEX_UNVERIFIED_SHIM); resolve(1); return; } const inv = commandInvocation(launch.command, args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { diff --git a/structure/cli-management.md b/structure/cli-management.md index d68db73077f..04866e3b102 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -21,7 +21,7 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl ## Head and help navigation -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting Codex routing. Native and foreign routes pass through; local and unknown routes attempt proxy startup and still launch with a diagnostic if it fails. Fast-path arguments skip startup and token loading. A recorded wrapper launches its backing Codex directly. Unknown shim state, including PATH lookup or inspection errors, launches `codex` with `OCX_SHIM_BYPASS=1`, skipping both shim startup and token loading without printing the inspection error. A missing executable retains the normal spawn error path. Only verified OpenCodex routing fills a missing admission token from the installation's service-token file, ignoring an inherited token-file override and refusing management-token collisions. Unknown routing adds no credential. No token is added to argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A recorded wrapper uses its backing directly; unknown Windows CMD wrappers require complete generated-template equality via `src/cli/codex-launch-command.ts`, then execute the selected absolute path with bypass. Obsolete, unreadable or ambiguous wrappers and lookup/inspection errors refuse launch without printing exception contents. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, project layers, destination-changing argv and environment overrides prevent automatic loading. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. diff --git a/structure/codex-home.md b/structure/codex-home.md index 83aff87ca41..b1afd9f48a2 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -7,7 +7,7 @@ Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote- A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). - +Launcher credentials require a parsed managed provider transport matching the installation's loopback target or persisted local client connection; provider names and injection comments are not destination authority. Default profiles, project layers and destination-changing arguments suppress automatic token loading. The read-only CLI update plan follows the [isolated environment and process-observation contract](codex-cli-update.md). ## Orca source-owned account import diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index f6a0c741f27..ed0cc7c73cd 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -235,7 +235,7 @@ deliberate stop. Existing launchd jobs retain their old keepalive definition until `ocx service repair` rewrites and reloads the changed plist. New WinSW XML stamps `OCX_SERVICE_MANAGED=1`; parent command-line inference applies only to legacy registrations whose XML lacks that marker. Bare `OCX_SERVICE=1` -is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too; a +is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too; Codex's client-token loading separately requires a managed loopback destination. A marker-less Windows registration is still recognised by the parent's command line naming this install's wrapper script, launcher or WinSW host as a complete token in any position, and an unreadable parent command line is no evidence and proceeds. POSIX keeps the diff --git a/structure/runtime.md b/structure/runtime.md index 0023d7a72bf..833eb1f0f4d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -291,7 +291,7 @@ atomic OS guarantee against unobserved PID reuse. > Decision record: [ADR-0003](decisions/ADR-0003-lifecycle.md) -An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. `ocx codex` skips this repair because its launcher owns routing-aware shim handoff. Changing launchers defer; failures warn without changing the requested command’s exit behavior. +An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. `ocx codex` skips repair and owns handoff: unknown Windows CMD wrappers require complete bypass-aware template equality, otherwise launch is refused; content equality grants no Windows backing provenance. Other changing launchers defer and repair failures warn without changing their command's exit behavior. All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor. Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder. Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. Windows service/shim writes first admit the frozen selected runtime under the [write-preflight contract](ops/service-and-sidecars.md#windows-selected-runtime-write-preflight); orchestrated installs refuse Desktop supervision before selection and admit once before cleanup. Repair refuses Desktop before diagnosis, then checks ownership and auth before admission and any native repair or scheduler stop. The Node launcher’s validated PATH Bun fallback precedes CLI startup; durable admission never discovers a replacement. The fresh scheduler path claims config ownership after registration and staging removal, then admits before `prepare()`; refusal rolls back the new registration and preserves the claimed config root. Standalone admission uses the current executable in-process, without child isolation or a timeout. diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts new file mode 100644 index 00000000000..360edea61a5 --- /dev/null +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; +import { codexCommandCanBypass } from "../../src/cli/codex-launch-command"; +import { classifyCodexRouting } from "../../src/codex/inject/routing-classify"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { buildWindowsCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +const fixtureToken = "ocx_fixture_launcher_security"; +const installation = { port: 12199, hostname: "0.0.0.0" }; +const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(12199, installation)); + +function fixture(run: (home: string, codexHome: string, ocxHome: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-launch-security-")); + const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; + const codexHome = join(home, "codex-home"), ocxHome = join(home, "ocx-home"); + mkdirSync(codexHome); mkdirSync(ocxHome); + process.env.CODEX_HOME = codexHome; process.env.OPENCODEX_HOME = ocxHome; + writeFileSync(join(ocxHome, "config.json"), JSON.stringify(installation)); + writeFileSync(join(ocxHome, "service-api-token"), fixtureToken); + try { run(home, codexHome, ocxHome); } + finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + removeTreeWithRetry(home); + } +} + +describe("codex launcher credential destination", () => { + for (const [name, content] of [ + ["remote reserved provider", local.replace("http://127.0.0.1:12199/v1", "https://gateway.invalid/v1")], + ["remote root URL retaining injection comment", '# Auto-injected by opencodex\nopenai_base_url = "https://gateway.invalid/v1"\n'], + ["wrong local port", local.replace(":12199/", ":12200/")], + ["default profile redirects provider", 'profile = "foreign"\n' + local + '\n[profiles.foreign]\nmodel_provider = "foreign"\n'], + ["extra credential header", local + '\n[model_providers.opencodex.env_http_headers]\nx-key = "OPENCODEX_API_AUTH_TOKEN"\n'], + ["malformed TOML", local + '\nmodel_provider = [\n'], + ]) { + test(`does not load a token for ${name}`, () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), content!); + let reads = 0; + const env = buildCodexLaunchEnv({}, { routingKind: classifyCodexRouting(content!), serviceToken: () => { reads++; return fixtureToken; } }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(reads).toBe(0); + })); + } + test("loads the service token for the managed provider on this installation's local listener", () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + })); + test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { + let reads = 0; + expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({}); + expect(reads).toBe(0); + } + })); + test("project configuration and environment destination overrides suppress loading", () => fixture((home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + expect(buildCodexLaunchEnv({ OPENAI_BASE_URL: "https://gateway.invalid/v1" }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + const project = join(home, "project"); mkdirSync(project); + mkdirSync(join(project, ".codex")); + writeFileSync(join(project, ".git"), "gitdir: fixture\n"); + writeFileSync(join(project, ".codex", "config.toml"), 'model_provider = "foreign"\n'); + const originalDirectory = process.cwd(); + try { + process.chdir(project); + let reads = 0; + expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } })).toEqual({}); + expect(reads).toBe(0); + } finally { process.chdir(originalDirectory); } + })); +}); + +describe.skipIf(process.platform !== "win32")("codex launcher installed Windows shim handoff", () => { + for (const args of [["--help"], ["resume"], ["-c", "model_provider=foreign", "resume"]]) { + for (const managementCollision of [false, true]) for (const obsolete of [true, false]) { + test(`${obsolete ? "obsolete shim fails closed" : "current shim bypasses token loading"}: ${args.join(" ")} collision=${managementCollision}`, () => fixture((home, codexHome, ocxHome) => { + const bin = join(home, "bin"); mkdirSync(bin); + const probe = join(home, "probe.mjs"); + writeFileSync(probe, 'console.log(JSON.stringify({token:process.env.OPENCODEX_API_AUTH_TOKEN??null}));process.exit(23);\n'); + const backing = join(bin, "backing.cmd"); + writeFileSync(backing, `@echo off\r\n"${process.execPath}" "${probe}" %*\r\n`); + const config = args.includes("-c") + ? 'model_provider = "foreign"\n[model_providers.foreign]\nbase_url = "https://gateway.invalid/v1"\n' + : 'model = "native-fixture"\n'; + writeFileSync(join(codexHome, "config.toml"), config); + if (managementCollision) writeFileSync(join(ocxHome, "admin-api-token"), fixtureToken); + const current = buildWindowsCodexShim(backing, process.execPath, repoPath("src", "cli", "index.ts"), "process"); + const lines = current.split("\n").filter(line => !line.includes(WINDOWS_SHIM_REVISION_MARKER)); + const bypass = lines.findIndex(line => line.includes('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex')); + const load = lines.findIndex(line => line.includes("set /p OPENCODEX_API_AUTH_TOKEN=")); + expect(bypass).toBeGreaterThan(0); expect(load).toBeGreaterThan(bypass); + [lines[bypass], lines[load]] = [lines[load]!, lines[bypass]!]; + const legacy = lines.join("\n"); + const installed = obsolete ? legacy : current; + const wrapper = join(bin, "codex.cmd"); writeFileSync(wrapper, installed); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, PATHEXT: ".EXE;.CMD" }; + for (const key of ["OPENCODEX_API_AUTH_TOKEN", "OPENCODEX_ADMIN_AUTH_TOKEN", "OCX_API_TOKEN_FILE", "OCX_SHIM_BYPASS"]) delete env[key]; + const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", ...args], { + cwd: repoRoot(), env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.stdout).not.toContain(fixtureToken); + expect(result.status).toBe(obsolete ? 1 : 23); + if (obsolete) expect(result.stderr).toContain("shim"); + else expect(JSON.parse(result.stdout.trim())).toEqual({ token: null }); + expect(readFileSync(wrapper, "utf8")).toBe(installed); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(config); + }), SPAWN_BUDGET_MS); + } + } + test("a revision marker cannot bless inserted commands, duplicate labels or trailing code", () => fixture((home) => { + const wrapper = join(home, "codex.cmd"); + const current = buildWindowsCodexShim(join(home, "backing.cmd"), process.execPath, repoPath("src", "cli", "index.ts"), "process"); + for (const text of [ + current.replace("setlocal", "echo inserted\r\nsetlocal"), + current.replace(":run_codex", ":run_codex\r\necho inserted\r\n:run_codex"), + current + "echo trailing\r\n", + current.replace('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex', "rem bypass disabled"), + ]) { + writeFileSync(wrapper, text); + expect(codexCommandCanBypass(wrapper, process.env)).toBe(false); + } + })); +}); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index a09444ec3f3..9b2bc5bf2df 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -14,7 +14,7 @@ */ import { describe, expect, test } from "bun:test"; import { - buildCodexLaunchEnv, + buildCodexLaunchEnv as buildEnv, CODEX_ROUTING_CUSTOM_LOCAL_NOTICE, CODEX_ROUTING_CUSTOM_REMOTE_NOTICE, CODEX_ROUTING_NATIVE_NOTICE, @@ -35,6 +35,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +// These env-assembly tests supply destination authority explicitly; real config authority +// (including the positive managed block) is covered in codex-launcher-security.test.ts. +const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv(base, { + credentialDestination: () => true, ...deps, +}); + function withTokenFiles(run: (home: string) => void): void { const home = mkdtempSync(join(tmpdir(), "ocx-codex-launch-token-")); const prior = process.env.OPENCODEX_HOME; @@ -280,44 +286,43 @@ describe("codex launcher shim handoff", () => { expect(launch).toEqual({ command: "/opt/codex/bin/codex.real", env }); }); - test("an unreadable shim state keeps `codex` but bypasses the shim's ensure", () => { + test("an unknown shim state uses the verified selected path with bypass", () => { const launch = resolveCodexLaunchCommand(env, { findOnPath: onPath, inspectShim: () => ({ status: "unknown", reason: "binding_unavailable" }), + verifyBypass: () => true, }); - expect(launch.command).toBe("codex"); + expect(launch.command).toBe(onPath()); expect(launch.env.OCX_SHIM_BYPASS).toBe("1"); expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); }); - test("a PATH lookup error falls back to the unknown-state bypass", () => { + test("a PATH lookup error fails closed before any child handoff", () => { let inspected = false; - const launch = resolveCodexLaunchCommand(env, { + expect(() => resolveCodexLaunchCommand(env, { findOnPath: () => { throw new Error("PATH lookup unavailable"); }, inspectShim: () => { inspected = true; return { status: "not-tracked" }; }, - }); - expect(launch).toEqual({ command: "codex", env: { ...env, OCX_SHIM_BYPASS: "1" } }); + })).toThrow("cannot verify Codex shim bypass"); expect(inspected).toBe(false); expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); }); - test("a shim filesystem error falls back without changing the input environment", () => { + test("a shim filesystem error fails closed without changing the input environment", () => { const base = { ...env, OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_inherited_token", OCX_SHIM_BYPASS: "" }; - const launch = resolveCodexLaunchCommand(base, { + expect(() => resolveCodexLaunchCommand(base, { findOnPath: onPath, inspectShim: () => { throw Object.assign(new Error("shim state read failed"), { code: "EIO" }); }, - }); - expect(launch).toEqual({ command: "codex", env: { ...base, OCX_SHIM_BYPASS: "1" } }); + })).toThrow("cannot verify Codex shim bypass"); expect(base.OCX_SHIM_BYPASS).toBe(""); }); test("an untracked or backing codex launches unchanged", () => { expect(resolveCodexLaunchCommand(env, { findOnPath: onPath, inspectShim: () => ({ status: "not-tracked" }) })) - .toEqual({ command: "codex", env }); + .toEqual({ command: onPath(), env }); expect(resolveCodexLaunchCommand(env, { findOnPath: onPath, inspectShim: () => ({ status: "matched", selectedRole: "backing", backingPath: "/home/test/.local/bin/codex", backingKind: "real" }), - })).toEqual({ command: "codex", env }); - expect(resolveCodexLaunchCommand(env, { findOnPath: () => null })).toEqual({ command: "codex", env }); + })).toEqual({ command: onPath(), env }); + expect(() => resolveCodexLaunchCommand(env, { findOnPath: () => null })).toThrow("cannot verify Codex shim bypass"); }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 881ad47279c..c5bc2c997c0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -588,6 +588,7 @@ "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", + "codex-launcher-security.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", "codex-shim-windows-revision.test.ts": "codex-integration", From 96603bfa4c81a9d27c792554379c3867f424229a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:19:22 +0900 Subject: [PATCH 06/18] fix(cli): inspect outer project layers before Codex token delivery Scan ancestor Codex project configuration through the filesystem root instead of assuming a nested .git is the effective project root. Custom root markers can select an outer workspace with a different provider destination. Add a regression that was red with one unauthorized service-token reader call, retain the positive managed-local case in a context without project overrides, and document the conservative ancestor boundary. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- src/cli/codex-launch-auth.ts | 5 ++-- structure/cli-management.md | 2 +- .../codex-launcher-security.test.ts | 29 +++++++++++++++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 4d46d9a8141..22a8f0be2ad 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -40,9 +40,8 @@ export function codexCredentialDestinationIsTrusted(): boolean { try { lstatSync(projectConfig); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } } - // Codex's project layers stop at the repository root, including a worktree's .git file. - try { lstatSync(join(directory, ".git")); break; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + // A nested .git need not be Codex's root: custom project_root_markers can select + // an outer workspace. Without proving that selection, inspect every ancestor. if (dirname(directory) === directory) break; } const providers = config.model_providers as Record | undefined; diff --git a/structure/cli-management.md b/structure/cli-management.md index 04866e3b102..283d4ac53c5 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -21,7 +21,7 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl ## Head and help navigation -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A recorded wrapper uses its backing directly; unknown Windows CMD wrappers require complete generated-template equality via `src/cli/codex-launch-command.ts`, then execute the selected absolute path with bypass. Obsolete, unreadable or ambiguous wrappers and lookup/inspection errors refuse launch without printing exception contents. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, project layers, destination-changing argv and environment overrides prevent automatic loading. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A recorded wrapper uses its backing directly; unknown Windows CMD wrappers require complete generated-template equality via `src/cli/codex-launch-command.ts`, then execute the selected absolute path with bypass. Obsolete, unreadable or ambiguous wrappers and lookup/inspection errors refuse launch without printing exception contents. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 360edea61a5..3a5b2f49bb4 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; +import { delimiter, join, parse } from "node:path"; import { spawnSync } from "node:child_process"; import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; import { codexCommandCanBypass } from "../../src/cli/codex-launch-command"; @@ -51,9 +51,15 @@ describe("codex launcher credential destination", () => { expect(reads).toBe(0); })); } - test("loads the service token for the managed provider on this installation's local listener", () => fixture((_home, codexHome) => { + test("loads the service token for the managed provider on this installation's local listener", () => fixture((home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); - expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + // This isolated Codex home must not inherit project configs beside the real user's + // home/worktree. The filesystem root has no caller ancestor configuration layers. + const originalDirectory = process.cwd(); + try { + process.chdir(parse(home).root); + expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + } finally { process.chdir(originalDirectory); } })); test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); @@ -78,6 +84,23 @@ describe("codex launcher credential destination", () => { expect(reads).toBe(0); } finally { process.chdir(originalDirectory); } })); + test("custom project roots cannot hide an outer override behind a nested .git", () => fixture((home, codexHome) => { + const workspace = join(home, "workspace"), nested = join(workspace, "nested"), cwd = join(nested, "src"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(join(workspace, ".codex")); + writeFileSync(join(workspace, ".workspace-root"), ""); + writeFileSync(join(nested, ".git"), "gitdir: fixture\n"); + writeFileSync(join(workspace, ".codex", "config.toml"), '[model_providers.opencodex]\nbase_url = "https://gateway.invalid/v1"\n'); + writeFileSync(join(codexHome, "config.toml"), 'project_root_markers = [".workspace-root"]\n' + local); + const originalDirectory = process.cwd(); + try { + process.chdir(cwd); + let reads = 0; + const env = buildCodexLaunchEnv({}, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + expect(reads).toBe(0); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } finally { process.chdir(originalDirectory); } + })); }); describe.skipIf(process.platform !== "win32")("codex launcher installed Windows shim handoff", () => { From c91d596e5e6c54b1a90ef4b11deaef8d939f16c7 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:51:31 +0900 Subject: [PATCH 07/18] fix(cli): bind Codex credentials to child home and transport Resolve explicit CODEX_HOME with native literal semantics and bind the verified canonical directory to the child only alongside a fresh service credential. Refuse unreadable or ambiguous Windows home values before reading credentials. Withhold fresh credentials for inherited transport proxies and system-proxy opt-ins. Normalize both child bypass spellings for loopback to cover the default transport's OS proxy discovery while preserving existing bypass entries. Add regression-first home/proxy fixtures, strengthen earlier destination tests with positive controls, and synchronize the launcher documentation and locales. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 2 + .../content/docs/ja/reference/cli/agents.md | 2 + .../content/docs/ko/reference/cli/agents.md | 2 + .../src/content/docs/reference/cli/agents.md | 2 + .../content/docs/ru/reference/cli/agents.md | 2 + .../content/docs/tr/reference/cli/agents.md | 2 + .../docs/zh-cn/reference/cli/agents.md | 2 + .../docs/zh-tw/reference/cli/agents.md | 2 + .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-launch-auth.ts | 28 ++- src/cli/codex-launcher.ts | 30 ++- structure/cli-management.md | 2 + .../codex-launcher-env.test.ts | 191 ++++++++++++++++++ .../codex-launcher-security.test.ts | 32 ++- .../codex-integration/codex-launcher.test.ts | 9 +- tests/fixtures/codex-launch-native-env.ts | 23 +++ tests/fixtures/test-layout-expected.json | 1 + 17 files changed, 308 insertions(+), 25 deletions(-) create mode 100644 tests/codex-integration/codex-launcher-env.test.ts create mode 100644 tests/fixtures/codex-launch-native-env.ts 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 6a0c5e53a93..f0a5f2a4d31 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -171,6 +171,8 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag ### `ocx codex [codex args...]` +Le chargement automatique interprète `CODEX_HOME` comme un chemin littéral, y compris `~` et les espaces, le vérifie et transmet le même chemin absolu canonique au processus enfant. Un répertoire illisible empêche le chargement. Toute valeur non vide de `HTTP_PROXY`, `HTTPS_PROXY` ou `ALL_PROXY`, quelle que soit la casse, empêche aussi le chargement indépendamment de `NO_PROXY` ; les lancements refusés et les identifiants exportés par l’utilisateur restent inchangés. Avec un nouveau jeton, `NO_PROXY` et `no_proxy` du processus enfant incluent `127.0.0.1` et `::1` en conservant les exclusions existantes. Activer `features.respect_system_proxy` ou transmettre `--enable`/`--disable` empêche le chargement automatique. + Le lanceur tente de démarrer le proxy puis transmet les arguments d’origine à Codex, sans réécrire sa configuration. Les routes natives et les passerelles tierces sautent le démarrage. Une route inconnue avertit puis tente le démarrage ; un échec avertit et continue. L’aide, la version et les commandes internes comme `login`, `doctor` et `help` sautent le démarrage et la lecture automatique du jeton. Un `OPENCODEX_API_AUTH_TOKEN` absent est lu dans le `service-api-token` de cette installation uniquement si toute la configuration de transport du provider sélectionné correspond au bloc géré pour son écouteur loopback ou sa connexion client locale enregistrée. Le nom et les commentaires ne suffisent pas. Destinations inconnues ou distantes, profile par défaut, configuration de projet, `OPENAI_BASE_URL` et options transmises `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` désactivent cette lecture. Les identifiants exportés et les arguments sont conservés ; les remplacements du fichier et les collisions avec les jetons d’administration sont refusés. 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 b1b2d3984b0..6613d6a9091 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -132,6 +132,8 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +自動認証情報の読み込みでは `CODEX_HOME` の `~` と空白をリテラルなパスとして解釈して検証し、同じ正規絶対パスを子プロセスに渡します。ホームを読めない場合は読み込みません。大文字小文字を問わず、空でない `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` があれば `NO_PROXY` に関係なく自動読み込みを省略します。拒否した起動とユーザーがエクスポートした認証情報は変更しません。新しいトークンを渡す場合、既存の除外項目を保持し、子の `NO_PROXY` と `no_proxy` に `127.0.0.1` と `::1` を追加します。`features.respect_system_proxy` の有効化や `--enable`/`--disable` の転送では自動読み込みを省略します。 + 元の引数で Codex を起動する前にプロキシの起動を試みます。ルーティングは Codex 設定が所有し、ランチャーは書き換えません。ネイティブと外部ゲートウェイでは起動を省略します。不明なルートでは警告して起動を試み、失敗しても警告後に続行します。ヘルプ、バージョン、`login`、`doctor`、`help` などの内部コマンドは起動と自動トークン読み込みを省略します。 選択した provider の全転送設定が、このインストールのループバックリスナーまたは保存されたローカルクライアント接続向けの管理ブロックと一致する場合だけ、不足する `OPENCODEX_API_AUTH_TOKEN` をこのインストールの `service-api-token` から読みます。名前や注入コメントだけでは許可しません。不明・リモートの接続先、既定 profile、プロジェクト設定、`OPENAI_BASE_URL`、転送した `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` は自動読み込みを省略します。ユーザーがエクスポートした認証情報と引数を保持し、トークンファイルの上書きと管理トークンの衝突を拒否します。 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 c9ed95b752a..e4e5f8c0820 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -164,6 +164,8 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 ### `ocx codex [codex args...]` +자동 자격 증명 전달은 `CODEX_HOME`의 `~`와 공백을 리터럴 경로로 해석하고 검증한 뒤, 동일한 정규 절대 경로를 자식에게 전달합니다. 홈을 읽을 수 없으면 토큰을 읽지 않습니다. 대소문자와 관계없이 `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` 중 비어 있지 않은 값이 있으면 `NO_PROXY`와 무관하게 자동 로딩을 건너뜁니다. 거부된 실행과 사용자가 내보낸 자격 증명은 변경하지 않습니다. 새 토큰을 전달할 때 자식의 `NO_PROXY`와 `no_proxy`에 기존 우회 항목을 보존하면서 `127.0.0.1`과 `::1`을 추가합니다. `features.respect_system_proxy`를 켜거나 `--enable`/`--disable`을 전달하면 자동 로딩을 건너뜁니다. + 원래 인자로 Codex를 실행하기 전에 프록시 시작을 시도합니다. 라우팅은 Codex 설정이 소유하며 런처가 재작성하지 않습니다. 네이티브 및 다른 게이트웨이 경로에서는 시작을 생략합니다. 알 수 없는 라우팅은 경고 후 시작을 시도하며, 시작 실패 시에도 경고 후 실행합니다. 도움말·버전 및 `login`, `doctor`, `help` 같은 내부 명령은 시작과 자동 토큰 읽기를 생략합니다. 선택한 provider의 전체 전송 설정이 이 설치의 로컬 리스너 또는 저장된 로컬 클라이언트 연결에 대해 생성한 관리 블록과 일치할 때만, 누락된 `OPENCODEX_API_AUTH_TOKEN`을 이 설치의 `service-api-token`에서 읽습니다. 이름이나 주입 주석만으로는 허용하지 않습니다. 알 수 없거나 원격인 목적지, 기본 profile, 프로젝트 설정, `OPENAI_BASE_URL`, 전달된 `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote`는 자동 읽기를 생략합니다. 사용자가 export한 자격 증명과 인자는 보존하며, 토큰 파일 경로 재정의와 관리 토큰 충돌은 거부합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 6f533d4113c..e435b28ace7 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -965,6 +965,8 @@ before choosing it. Rendered models are not proof of native application. ### `ocx codex [codex args...]` +Automatic credential delivery resolves `CODEX_HOME` as a literal path (including `~` and whitespace), verifies that home, and passes its canonical absolute path to the child. An unreadable home skips loading. Any nonempty `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` variable, in any letter case, also skips loading regardless of `NO_PROXY`; denied launches and user-exported credentials remain unchanged. With a newly loaded token, both child `NO_PROXY` and `no_proxy` include `127.0.0.1` and `::1`, preserving existing bypass entries. Enabling `features.respect_system_proxy` or forwarding `--enable`/`--disable` skips automatic loading. + Ensure the proxy is running, then launch Codex with the original arguments. Routing remains in Codex configuration; the launcher never rewrites it. Native and foreign routes skip startup. Unknown routing warns and attempts startup; a failed startup warns and continues. Help/version and internal commands such as `login`, `doctor`, and `help` skip startup and automatic token loading. A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-api-token` only when the selected provider's complete transport configuration matches the managed block for the configured loopback listener or persisted local client connection. Names and injection comments alone grant no credential access. Unknown or remote destinations, default profiles, project configuration, `OPENAI_BASE_URL`, and forwarded `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, or `--remote` options skip automatic loading. User-exported credentials and arguments are preserved; token-file overrides cannot select a different credential, and management-token collisions are refused. 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 661f9aed9ef..7ca1d4b6372 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -153,6 +153,8 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +При автоматическом чтении учётных данных `CODEX_HOME` трактуется как буквальный путь, включая `~` и пробелы. После проверки дочерний процесс получает тот же канонический абсолютный путь. Недоступный каталог отключает чтение. Любое непустое значение `HTTP_PROXY`, `HTTPS_PROXY` или `ALL_PROXY` без учёта регистра также отключает чтение независимо от `NO_PROXY`; отклонённые запуски и экспортированные пользователем учётные данные не меняются. При передаче нового токена в обе переменные дочернего процесса `NO_PROXY` и `no_proxy` добавляются `127.0.0.1` и `::1` с сохранением прежних исключений. Включение `features.respect_system_proxy` или передача `--enable`/`--disable` отключает автоматическое чтение. + Лаунчер пытается запустить прокси и передаёт Codex исходные аргументы, не переписывая конфигурацию. Нативные маршруты и чужие шлюзы пропускают запуск прокси. Неизвестный маршрут предупреждает и пытается запустить прокси; неудача предупреждает и продолжает выполнение. Справка, версия и внутренние команды `login`, `doctor`, `help` пропускают запуск и автоматическое чтение токена. Отсутствующий `OPENCODEX_API_AUTH_TOKEN` читается из `service-api-token` этой установки только при полном совпадении транспортной конфигурации выбранного provider с управляемым блоком для настроенного loopback-слушателя или сохранённого локального клиентского подключения. Имя и комментарии не дают разрешения. Неизвестные или удалённые адреса, profile по умолчанию, конфигурация проекта, `OPENAI_BASE_URL` и переданные `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` отключают автоматическое чтение. Экспортированные пользователем учётные данные и аргументы сохраняются; подмена файла токена и совпадения с административными токенами отклоняются. 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 797d0604552..548c340a6a7 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -189,6 +189,8 @@ eder. Claude Code ayarları için `ocx claude config ...` kullanın ### `ocx codex [codex args...]` +Otomatik kimlik bilgisi yükleme, `CODEX_HOME` içindeki `~` ve boşlukları değişmez yol olarak yorumlar, doğrular ve aynı kurallı mutlak yolu alt sürece aktarır. Okunamayan dizinde yükleme atlanır. Büyük/küçük harfe bakılmaksızın boş olmayan `HTTP_PROXY`, `HTTPS_PROXY` veya `ALL_PROXY` değeri varsa `NO_PROXY` ne olursa olsun yükleme atlanır; reddedilen başlatmalar ve kullanıcının dışa aktardığı kimlik bilgileri değiştirilmez. Yeni token aktarılırken alt sürecin `NO_PROXY` ve `no_proxy` değerlerine mevcut istisnalar korunarak `127.0.0.1` ve `::1` eklenir. `features.respect_system_proxy` etkinleştirilirse veya `--enable`/`--disable` aktarılırsa otomatik yükleme atlanır. + Başlatıcı proxy'yi başlatmayı dener ve özgün argümanları Codex'e aktarır; yapılandırmayı yeniden yazmaz. Yerel Codex ve yabancı ağ geçidi yolları proxy başlangıcını atlar. Bilinmeyen yol uyarıp başlangıcı dener; başarısızlık uyarır ve devam eder. Yardım, sürüm ve `login`, `doctor`, `help` gibi iç komutlar başlangıcı ve otomatik belirteç okumayı atlar. Eksik `OPENCODEX_API_AUTH_TOKEN`, yalnızca seçili provider'ın tüm taşıma yapılandırması bu kurulumun yapılandırılmış loopback dinleyicisi veya kayıtlı yerel istemci bağlantısı için yönetilen blokla eşleştiğinde bu kurulumun `service-api-token` dosyasından okunur. Adlar ve açıklamalar yetki vermez. Bilinmeyen veya uzak hedefler, varsayılan profile, proje yapılandırması, `OPENAI_BASE_URL` ve iletilen `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` otomatik okumayı atlar. Kullanıcının dışa aktardığı kimlik bilgileri ve argümanlar korunur; belirteç dosyası geçersiz kılmaları ve yönetim belirteci çakışmaları reddedilir. 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 80936c6bc3a..9aed25e1476 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 @@ -136,6 +136,8 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +自动凭证读取会将 `CODEX_HOME` 中的 `~` 与空白视为字面路径,验证后将同一个规范绝对路径传给子进程。无法读取主目录时不读取令牌。只要存在非空的 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY`(不区分大小写),就会跳过自动读取,不受 `NO_PROXY` 影响;拒绝的启动和用户导出的凭证保持不变。传递新令牌时,子进程的 `NO_PROXY` 与 `no_proxy` 会保留已有排除项并加入 `127.0.0.1` 和 `::1`。启用 `features.respect_system_proxy` 或传入 `--enable`/`--disable` 会跳过自动读取。 + 使用原始参数启动 Codex 前尝试启动代理。路由仍归 Codex 配置所有,启动器不会改写。本机原生及其他网关路由跳过代理启动;未知路由会警告并尝试启动,失败后仍警告并继续。帮助、版本及 `login`、`doctor`、`help` 等内部命令跳过代理启动和自动令牌读取。 只有所选 provider 的完整传输配置与本次安装的回环监听器或已保存的本地客户端连接对应的管理块一致时,才从本次安装的 `service-api-token` 读取缺失的 `OPENCODEX_API_AUTH_TOKEN`。名称或注入注释本身不构成授权。未知或远程目标、默认 profile、项目配置、`OPENAI_BASE_URL` 以及传入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都会跳过自动读取。保留用户导出的凭据和参数,拒绝令牌文件覆盖和管理令牌冲突。 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 a208608ef02..663dd9079e8 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 @@ -133,6 +133,8 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ### `ocx codex [codex args...]` +自動憑證讀取會將 `CODEX_HOME` 中的 `~` 與空白視為字面路徑,驗證後將同一個正規絕對路徑傳給子程序。無法讀取主目錄時不讀取權杖。只要存在非空的 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY`(不分大小寫),就會略過自動讀取,不受 `NO_PROXY` 影響;拒絕的啟動和使用者匯出的憑證保持不變。傳遞新權杖時,子程序的 `NO_PROXY` 與 `no_proxy` 會保留既有排除項目並加入 `127.0.0.1` 和 `::1`。啟用 `features.respect_system_proxy` 或傳入 `--enable`/`--disable` 會略過自動讀取。 + 使用原始引數啟動 Codex 前嘗試啟動代理。路由仍由 Codex 設定擁有,啟動器不會改寫。原生及其他閘道路由略過代理啟動;未知路由會警告並嘗試啟動,失敗後仍警告並繼續。說明、版本及 `login`、`doctor`、`help` 等內部命令略過代理啟動和自動權杖讀取。 只有所選 provider 的完整傳輸設定與本次安裝的回環監聽器或已儲存的本機用戶端連線對應的管理區塊一致時,才從本次安裝的 `service-api-token` 讀取缺少的 `OPENCODEX_API_AUTH_TOKEN`。名稱或注入註解本身不構成授權。未知或遠端目的地、預設 profile、專案設定、`OPENAI_BASE_URL` 及傳入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都會略過自動讀取。保留使用者匯出的憑證與引數,拒絕權杖檔案覆寫和管理權杖衝突。 diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 63530224fa1..7ffacbc9bac 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -2,6 +2,7 @@ "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", "codex-launcher-security.test.ts": "codex-integration", + "codex-launcher-env.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 22a8f0be2ad..2449764fef6 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -1,15 +1,31 @@ -import { lstatSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { readConfigDiagnostics, websocketsEnabled } from "../config"; -import { getCodexHome } from "../codex/paths"; import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; import { readClientConnectionState } from "../client/state"; +/** Resolve once; the credential verifier and child must use this exact directory. */ +export function resolveCodexLaunchHome(env: Record): string | null { + try { + // Object spread loses Windows process.env's case-insensitive lookup. Refuse + // noncanonical aliases rather than pass conflicting names to CreateProcess. + if (process.platform === "win32" && Object.keys(env).some(key => key !== "CODEX_HOME" && key.toUpperCase() === "CODEX_HOME")) return null; + // Explicit values are native literal paths: no tilde expansion or whitespace trim. + // An implicit managed home is safe only when also made explicit for the child. + const userHome = (process.platform === "win32" ? env.USERPROFILE : env.HOME) || homedir(); + const raw = env.CODEX_HOME === undefined ? join(userHome, ".codex") : env.CODEX_HOME; + if (!raw.trim()) return null; + const home = realpathSync(resolve(raw)); + return statSync(home).isDirectory() ? home : null; + } catch { return null; } +} + /** Config classification is a startup hint; credentials need an independently bound destination. */ -export function codexCredentialDestinationIsTrusted(): boolean { +export function codexCredentialDestinationIsTrusted(home: string): boolean { try { const diagnostics = readConfigDiagnostics(); if (diagnostics.error || diagnostics.source === "fallback") return false; @@ -24,10 +40,12 @@ export function codexCredentialDestinationIsTrusted(): boolean { const url = new URL(target.baseUrl); if (!target.requiresAdmissionToken) return false; if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return false; - const home = getCodexHome(); const content = readBoundedCodexConfig(join(home, "config.toml")); if (content === null) return false; const config = Bun.TOML.parse(content) as Record; + // This native feature resolves OS/PAC routes without honoring NO_PROXY. + const features = config.features as Record | undefined; + if (features?.respect_system_proxy !== undefined && features.respect_system_proxy !== false) return false; // Codex layers project configs and profiles after the home. Without proving those layers, // conservatively leave authentication to the user's environment. if (config.profile !== undefined || config.model_provider !== "opencodex") return false; diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index a063c631e8a..91e1b264884 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -34,7 +34,7 @@ import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/servic import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; -import { codexCredentialDestinationIsTrusted } from "./codex-launch-auth"; +import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; export interface CodexLaunchEnv { @@ -156,7 +156,7 @@ export type CodexLaunchEnvDeps = { args?: readonly string[]; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; - credentialDestination?: () => boolean; + credentialDestination?: (home: string) => boolean; }; /** Read the client admission credential from this installation's service-token file. */ @@ -167,8 +167,8 @@ const defaultServiceToken = (base: CodexLaunchEnv): string | null => * Launch env for a routed exec. The one injection is the shim's own: a missing * OPENCODEX_API_AUTH_TOKEN is re-exported from the hardened service token file, so * `ocx codex` from a shell without the system-env snapshot authenticates to the - * loopback proxy like a shimmed launch does. User exports always win; nothing else - * is added or removed — Codex's routing state lives in its config files, not here. + * loopback proxy like a shimmed launch does. Fresh credentials require a bound home + * and no inherited transport proxy. User exports always win. */ export function buildCodexLaunchEnv( base: CodexLaunchEnv = process.env, @@ -177,11 +177,27 @@ export function buildCodexLaunchEnv( const env = { ...base }; if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; // Config/profile overrides can replace the destination after the home was classified. - if (deps.args?.some(arg => /^(?:-c|-p|-C|--config(?:=|$)|--profile(?:=|$)|--cd(?:=|$)|--remote(?:[=-]|$))/.test(arg))) return env; + if (deps.args?.some(arg => /^(?:-c|-p|-C|--config(?:=|$)|--profile(?:=|$)|--cd(?:=|$)|--remote(?:[=-]|$)|--(?:enable|disable)(?:=|$))/.test(arg))) return env; if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { - if (env.OPENAI_BASE_URL || !(deps.credentialDestination ?? codexCredentialDestinationIsTrusted)()) return env; + // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. + // Without proving bypass, preserve the user's transport but never load a secret. + if (Object.entries(env).some(([key, value]) => /^(?:https?|all)_proxy$/i.test(key) && value)) return env; + const home = resolveCodexLaunchHome(env); + if (!home || env.OPENAI_BASE_URL || !(deps.credentialDestination ?? codexCredentialDestinationIsTrusted)(home)) return env; const token = (deps.serviceToken ?? defaultServiceToken)(env); - if (token && !tokenCollidesWithAdmin(token, env)) env.OPENCODEX_API_AUTH_TOKEN = token; + if (token && !tokenCollidesWithAdmin(token, env)) { + // Reqwest's default transport can discover OS proxies even without proxy + // env. Bind both bypass spellings to the allowed loopback hosts, preserving + // existing entries and removing conflicting case aliases before handoff. + const bypass = ["127.0.0.1", "::1"]; + for (const key of Object.keys(env)) if (/^no_proxy$/i.test(key)) { + if (env[key]) bypass.push(env[key]!); + delete env[key]; + } + env.NO_PROXY = env.no_proxy = bypass.join(","); + env.CODEX_HOME = home; + env.OPENCODEX_API_AUTH_TOKEN = token; + } } return env; } diff --git a/structure/cli-management.md b/structure/cli-management.md index 283d4ac53c5..7b67695237f 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -21,6 +21,8 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl ## Head and help navigation +For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. + `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A recorded wrapper uses its backing directly; unknown Windows CMD wrappers require complete generated-template equality via `src/cli/codex-launch-command.ts`, then execute the selected absolute path with bypass. Obsolete, unreadable or ambiguous wrappers and lookup/inspection errors refuse launch without printing exception contents. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts new file mode 100644 index 00000000000..70b11031436 --- /dev/null +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fixturePath } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +const token = "ocx_fixture_launch_env"; +const installation = { port: 12199, hostname: "0.0.0.0" }; +const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(12199, installation)); +const foreign = local.replace("http://127.0.0.1:12199/v1", "https://foreign.invalid/v1"); +const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "Http_Proxy", "All_Proxy"]; + +async function fixture(run: (root: string, home: string) => void | Promise): Promise { + // Avoid the real user's ancestor .codex layer, which deliberately denies injection. + const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); + const root = mkdtempSync(join(temp, "ocx-launch-env-")); + const saved = { ...process.env }, cwd = process.cwd(); + const user = join(root, "user"), home = join(root, "codex-home"), ocx = join(root, "ocx"); + for (const path of [user, home, ocx]) mkdirSync(path); + process.env.HOME = user; process.env.USERPROFILE = user; + process.env.CODEX_HOME = home; process.env.OPENCODEX_HOME = ocx; + for (const key of Object.keys(process.env)) { + if (/^(?:(?:https?|all|no)_proxy|OPENCODEX_API_AUTH_TOKEN|OPENCODEX_ADMIN_AUTH_TOKEN|OPENAI_BASE_URL|OCX_API_TOKEN_FILE)$/i.test(key)) delete process.env[key]; + } + writeFileSync(join(ocx, "config.json"), JSON.stringify(installation)); + writeFileSync(join(home, "config.toml"), local); + process.chdir(root); + try { await run(root, home); } + finally { + process.chdir(cwd); + for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key]; + Object.assign(process.env, saved); + removeTreeWithRetry(root); + } +} + +function launch(base = { ...process.env }) { + let reads = 0; + const env = buildCodexLaunchEnv(base, { routingKind: "opencodex-local", serviceToken: () => { reads++; return token; } }); + return { env, reads }; +} + +describe("Codex launcher native home and proxy environment", () => { + test("literal tilde home cannot borrow authority from the expanded user home", () => fixture((root) => { + const expanded = join(root, "user", "trusted"), literal = join(root, "~", "trusted"); + mkdirSync(expanded); mkdirSync(literal, { recursive: true }); + writeFileSync(join(expanded, "config.toml"), local); + writeFileSync(join(literal, "config.toml"), foreign); + process.env.CODEX_HOME = join("~", "trusted"); + const result = launch(); + expect(result.reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + })); + + test("whitespace in a native literal home is not trimmed for authorization", () => fixture((root) => { + for (const name of ["trusted", " trusted"]) mkdirSync(join(root, name)); + writeFileSync(join(root, "trusted", "config.toml"), local); + writeFileSync(join(root, " trusted", "config.toml"), foreign); + process.env.CODEX_HOME = " trusted"; + expect(launch().reads).toBe(0); + })); + + test("the actual child env home, rather than the parent home, owns authorization", () => fixture((root) => { + const other = join(root, "other"); mkdirSync(other); + writeFileSync(join(other, "config.toml"), foreign); + expect(launch({ ...process.env, CODEX_HOME: other }).reads).toBe(0); + })); + + test.skipIf(process.platform !== "win32")("ambiguous Windows home casing withholds credentials", () => fixture((_root, home) => { + const base = { ...process.env, CODEX_HOME: home, codex_home: "unverified-home" }; + const result = launch(base); + expect(result.reads).toBe(0); + expect(result.env).toEqual(base); + })); + + test("relative and literal tilde local homes are bound to the verified absolute directory", () => fixture((root) => { + const literal = join(root, "~", "local"); mkdirSync(literal, { recursive: true }); + writeFileSync(join(literal, "config.toml"), local); + for (const raw of ["codex-home", join("~", "local")]) { + process.env.CODEX_HOME = raw; + const result = launch(); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + // Native-semantics fixture: the child opens CODEX_HOME literally under cwd. + expect(result.env.CODEX_HOME).toBe(realpathSync(join(root, raw))); + const child = spawnSync(process.execPath, [fixturePath("codex-launch-native-env.ts")], { + cwd: root, env: result.env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout)).toEqual({ home: result.env.CODEX_HOME, baseUrl: "http://127.0.0.1:12199/v1", hasToken: true }); + } + })); + + test("an implicit home is resolved from the child environment and bound explicitly", () => fixture((root) => { + const implicit = join(root, "user", ".codex"); mkdirSync(implicit); + writeFileSync(join(implicit, "config.toml"), local); + const base = { ...process.env }; delete base.CODEX_HOME; + const result = launch(base); + expect(result.reads).toBe(1); + expect(result.env.CODEX_HOME).toBe(realpathSync(implicit)); + })); + + test("missing and non-directory homes withhold credentials", () => fixture((root) => { + const file = join(root, "file"); writeFileSync(file, "fixture"); + for (const raw of [join(root, "absent"), file, " "]) { + process.env.CODEX_HOME = raw; + expect(launch().reads).toBe(0); + } + })); + + test("junction or symlink homes bind to the physical verified directory", () => fixture((root, home) => { + const link = join(root, "linked"); symlinkSync(home, link, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEX_HOME = link; + const result = launch(); + expect(result.reads).toBe(1); + expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + writeFileSync(join(home, "config.toml"), foreign); + expect(launch().reads).toBe(0); + })); + + for (const key of proxyKeys) for (const bypass of [undefined, "", "localhost", "*"]) { + test(`${key} with NO_PROXY=${String(bypass)} withholds new credentials`, () => fixture(() => { + const base = { ...process.env, [key]: "http://127.0.0.1:19999", NO_PROXY: bypass, no_proxy: "127.0.0.1" }; + const result = launch(base); + expect(result.reads).toBe(0); + expect(result.env).toEqual(base); + })); + } + + test("empty proxy variables preserve normal local credential delivery", () => fixture((_root, home) => { + const result = launch({ ...process.env, HTTP_PROXY: "", HTTPS_PROXY: "", ALL_PROXY: "" }); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + expect(result.env.NO_PROXY).toContain("127.0.0.1"); + expect(result.env.no_proxy).toContain("::1"); + })); + + test("system proxy opt-in without proxy env withholds credentials", () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local + "\n[features]\nrespect_system_proxy = true\nsystem_proxy_fallback = true\n"); + expect(launch().reads).toBe(0); + })); + + test("feature argv cannot enable a bypass-ignoring system proxy", () => fixture(() => { + for (const args of [["--enable", "respect_system_proxy"], ["--enable=respect_system_proxy"]]) { + let reads = 0; + buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; } }); + expect(reads).toBe(0); + } + })); + + test("default transport gets identical loopback bypasses and preserves existing entries", () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local + "\n[features]\nrespect_system_proxy = false\nsystem_proxy_fallback = true\n"); + const result = launch({ ...process.env, NO_PROXY: "example.invalid", no_proxy: "other.invalid", No_Proxy: "third.invalid" }); + expect(result.reads).toBe(1); + expect(result.env.NO_PROXY).toBe(result.env.no_proxy); + for (const entry of ["127.0.0.1", "::1", "example.invalid", "other.invalid", "third.invalid"]) expect(result.env.NO_PROXY!.split(",")).toContain(entry); + expect(result.env.No_Proxy).toBeUndefined(); + })); + + test("an observing proxy receives no freshly loaded credential", () => fixture(async (root) => { + const received: boolean[] = []; + const proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + received.push(request.headers.has("authorization")); + return new Response("fixture refusal", { status: 401 }); + } }); + try { + const result = launch({ ...process.env, HTTP_PROXY: `http://127.0.0.1:${proxy.port}`, NO_PROXY: "", OCX_FIXTURE_REQUEST: "1" }); + expect(result.reads).toBe(0); + const child = Bun.spawn([process.execPath, fixturePath("codex-launch-native-env.ts")], { cwd: root, env: result.env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, status] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(stderr).toBe(""); expect(status).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ hasToken: false, status: 401 }); + expect(received).toEqual([false]); + } finally { await proxy.stop(true); } + }), SPAWN_BUDGET_MS); + + test("a user-exported token and proxy settings remain untouched", () => fixture(() => { + const base = { ...process.env, OPENCODEX_API_AUTH_TOKEN: token, HTTP_PROXY: "http://127.0.0.1:19999" }; + const result = launch(base); + expect(result.reads).toBe(0); + expect(result.env).toEqual(base); + })); +}); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 3a5b2f49bb4..2275de53782 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -18,15 +18,19 @@ const installation = { port: 12199, hostname: "0.0.0.0" }; const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(12199, installation)); function fixture(run: (home: string, codexHome: string, ocxHome: string) => void): void { - const home = mkdtempSync(join(tmpdir(), "ocx-launch-security-")); + const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); + const home = mkdtempSync(join(temp, "ocx-launch-security-")); + const cwd = process.cwd(); const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; const codexHome = join(home, "codex-home"), ocxHome = join(home, "ocx-home"); mkdirSync(codexHome); mkdirSync(ocxHome); process.env.CODEX_HOME = codexHome; process.env.OPENCODEX_HOME = ocxHome; writeFileSync(join(ocxHome, "config.json"), JSON.stringify(installation)); writeFileSync(join(ocxHome, "service-api-token"), fixtureToken); + process.chdir(home); try { run(home, codexHome, ocxHome); } finally { + process.chdir(cwd); for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } @@ -34,6 +38,14 @@ function fixture(run: (home: string, codexHome: string, ocxHome: string) => void } } +function expectManagedHome(codexHome: string): void { + writeFileSync(join(codexHome, "config.toml"), local); + let reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + expect(reads).toBe(1); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); +} + describe("codex launcher credential destination", () => { for (const [name, content] of [ ["remote reserved provider", local.replace("http://127.0.0.1:12199/v1", "https://gateway.invalid/v1")], @@ -44,9 +56,10 @@ describe("codex launcher credential destination", () => { ["malformed TOML", local + '\nmodel_provider = [\n'], ]) { test(`does not load a token for ${name}`, () => fixture((_home, codexHome) => { + expectManagedHome(codexHome); writeFileSync(join(codexHome, "config.toml"), content!); let reads = 0; - const env = buildCodexLaunchEnv({}, { routingKind: classifyCodexRouting(content!), serviceToken: () => { reads++; return fixtureToken; } }); + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: classifyCodexRouting(content!), serviceToken: () => { reads++; return fixtureToken; } }); expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); expect(reads).toBe(0); })); @@ -58,20 +71,20 @@ describe("codex launcher credential destination", () => { const originalDirectory = process.cwd(); try { process.chdir(parse(home).root); - expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); } finally { process.chdir(originalDirectory); } })); test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { - writeFileSync(join(codexHome, "config.toml"), local); + expectManagedHome(codexHome); for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { let reads = 0; - expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({}); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } })); test("project configuration and environment destination overrides suppress loading", () => fixture((home, codexHome) => { - writeFileSync(join(codexHome, "config.toml"), local); - expect(buildCodexLaunchEnv({ OPENAI_BASE_URL: "https://gateway.invalid/v1" }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expectManagedHome(codexHome); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome, OPENAI_BASE_URL: "https://gateway.invalid/v1" }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); const project = join(home, "project"); mkdirSync(project); mkdirSync(join(project, ".codex")); writeFileSync(join(project, ".git"), "gitdir: fixture\n"); @@ -80,11 +93,12 @@ describe("codex launcher credential destination", () => { try { process.chdir(project); let reads = 0; - expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } })).toEqual({}); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } finally { process.chdir(originalDirectory); } })); test("custom project roots cannot hide an outer override behind a nested .git", () => fixture((home, codexHome) => { + expectManagedHome(codexHome); const workspace = join(home, "workspace"), nested = join(workspace, "nested"), cwd = join(nested, "src"); mkdirSync(cwd, { recursive: true }); mkdirSync(join(workspace, ".codex")); @@ -96,7 +110,7 @@ describe("codex launcher credential destination", () => { try { process.chdir(cwd); let reads = 0; - const env = buildCodexLaunchEnv({}, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); expect(reads).toBe(0); expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); } finally { process.chdir(originalDirectory); } diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 9b2bc5bf2df..4864a5cdafb 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -34,10 +34,11 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot } from "../helpers/repo-root"; // These env-assembly tests supply destination authority explicitly; real config authority // (including the positive managed block) is covered in codex-launcher-security.test.ts. -const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv(base, { +const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv({ CODEX_HOME: repoRoot(), ...base }, { credentialDestination: () => true, ...deps, }); @@ -153,9 +154,9 @@ describe("codex launch env assembly", () => { expect(buildCodexLaunchEnv({}, { routingKind: "opencodex-local", args, serviceToken: () => { consulted = true; return "ocx_fixture_service_token"; }, - })).toEqual({}); + })).toEqual({ CODEX_HOME: repoRoot() }); expect(consulted).toBe(false); - const inherited = { OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_user_export" }; + const inherited = { CODEX_HOME: repoRoot(), OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_user_export" }; expect(buildCodexLaunchEnv(inherited, { routingKind: "opencodex-local", args })).toEqual(inherited); } }); @@ -183,7 +184,7 @@ describe("codex launch env assembly", () => { test("unknown and foreign routing never read or inject the service token", () => { for (const routingKind of ["unknown", "custom-local", "custom-remote", "native"] as const) { let consulted = false; - const base = { PATH: "/usr/bin" }; + const base = { CODEX_HOME: repoRoot(), PATH: "/usr/bin" }; const env = buildCodexLaunchEnv(base, { routingKind, serviceToken: () => { consulted = true; return "ocx_fixture_service_token"; }, diff --git a/tests/fixtures/codex-launch-native-env.ts b/tests/fixtures/codex-launch-native-env.ts new file mode 100644 index 00000000000..32cc7d07ecc --- /dev/null +++ b/tests/fixtures/codex-launch-native-env.ts @@ -0,0 +1,23 @@ +// Native-semantics consumer: CODEX_HOME is a literal path, never shell-expanded. +// This fixture observes the child boundary without requiring an installed Codex. +import { readFileSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const home = realpathSync(resolve(process.env.CODEX_HOME!)); +const config = Bun.TOML.parse(readFileSync(join(home, "config.toml"), "utf8")) as { + model_provider: string; + model_providers: Record; +}; +const provider = config.model_providers[config.model_provider]!; +const token = process.env[provider.env_key]; +let status: number | undefined; +if (process.env.OCX_FIXTURE_REQUEST === "1") { + // Mirror the native transport's relevant HTTP_PROXY behavior for the observer. + const proxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy; + const response = await fetch(`${provider.base_url}/responses`, { + method: "POST", proxy, headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: "{}", + }); + status = response.status; +} +console.log(JSON.stringify({ home, baseUrl: provider.base_url, hasToken: Boolean(token), status })); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c5bc2c997c0..344b205d673 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -589,6 +589,7 @@ "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", "codex-launcher-security.test.ts": "codex-integration", + "codex-launcher-env.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", "codex-shim-windows-revision.test.ts": "codex-integration", From 281b70887793941bd0a48b64ad8898e0a9a26dcc Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:43:42 +0900 Subject: [PATCH 08/18] fix(cli): deny unverified Codex token consumers by default Authorize fresh service credentials only for audited argv, environment and configuration shapes using the exact managed loopback provider. Refuse unknown flags, provider/config/profile selectors, restored sessions, alternate providers, MCP and other credential consumers before reading the token, with a value-free stderr notice while preserving forwarded arguments and explicit user exports. Require current-user ownership and protected filesystem mutation permissions for the canonical Codex home and config. Verify the machine configuration namespace cannot be created by another user; inspect Windows owner SIDs/raw DACLs and POSIX ownership/modes, Darwin extended ACLs and forced preferences. Add regression-first admission cases, preserve earlier shim/home/proxy controls, and synchronize structure documentation and all eight documentation locales. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 15 ++ .../content/docs/ja/reference/cli/agents.md | 15 ++ .../content/docs/ko/reference/cli/agents.md | 15 ++ .../src/content/docs/reference/cli/agents.md | 15 ++ .../content/docs/ru/reference/cli/agents.md | 15 ++ .../content/docs/tr/reference/cli/agents.md | 15 ++ .../docs/zh-cn/reference/cli/agents.md | 15 ++ .../docs/zh-tw/reference/cli/agents.md | 15 ++ .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-launch-auth.ts | 13 +- src/cli/codex-launch-ownership.ts | 188 ++++++++++++++++++ src/cli/codex-launch-policy.ts | 84 ++++++++ src/cli/codex-launcher.ts | 16 +- structure/cli-management.md | 7 +- .../codex-launcher-admission.test.ts | 168 ++++++++++++++++ .../codex-launcher-env.test.ts | 6 +- .../codex-launcher-security.test.ts | 3 +- tests/fixtures/test-layout-expected.json | 1 + tests/helpers/codex-launch-private-fixture.ts | 30 +++ 19 files changed, 626 insertions(+), 11 deletions(-) create mode 100644 src/cli/codex-launch-ownership.ts create mode 100644 src/cli/codex-launch-policy.ts create mode 100644 tests/codex-integration/codex-launcher-admission.test.ts create mode 100644 tests/helpers/codex-launch-private-fixture.ts 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 f0a5f2a4d31..41d3f29e096 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -171,6 +171,21 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag ### `ocx codex [codex args...]` +Placez `--` avant une invite d’un seul mot pour éviter la sélection d’une sous-commande Codex inconnue. + +L’espace de configuration machine doit empêcher un autre utilisateur de créer les fichiers système absents. Un espace Windows ProgramData/OpenAI/Codex non protégé bloque l’injection sans créer de répertoire. macOS refuse les ACL étendues, même avec des bits de mode sûrs. + +L’injection d’un nouveau jeton de service est refusée par défaut. Elle exige le fournisseur loopback géré exact, uniquement les clés de configuration auditées et un lancement interactif ou `exec`. Les options ou clés inconnues, `--oss`, `--local-provider`, toute surcharge `-c`/`--config` ou de profil, les sessions restaurées (`resume`/`fork`), les fournisseurs supplémentaires, serveurs MCP, hooks, commandes de notification, configurations de projet et couches supplémentaires system/managed/requirements/authentification empêchent l’injection. Les variables non vides `CODEX_*` (sauf `CODEX_HOME` canonique), `OPENAI_*` et `AZURE_OPENAI_*` l’empêchent aussi. Les arguments et identifiants explicitement exportés sont conservés ; stderr explique le refus. + +Le répertoire canonique et `config.toml` doivent appartenir à l’utilisateur courant. POSIX refuse les écritures group/other ; Windows vérifie le SID propriétaire et refuse les droits d’écriture DACL hors utilisateur courant, SYSTEM et Administrators. Les droits de remplacement des ancêtres sont vérifiés. macOS exige aussi l’absence de préférences Codex forcées. Ces contrôles n’isolent pas un processus malveillant du même utilisateur ou d’un administrateur. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + Le chargement automatique interprète `CODEX_HOME` comme un chemin littéral, y compris `~` et les espaces, le vérifie et transmet le même chemin absolu canonique au processus enfant. Un répertoire illisible empêche le chargement. Toute valeur non vide de `HTTP_PROXY`, `HTTPS_PROXY` ou `ALL_PROXY`, quelle que soit la casse, empêche aussi le chargement indépendamment de `NO_PROXY` ; les lancements refusés et les identifiants exportés par l’utilisateur restent inchangés. Avec un nouveau jeton, `NO_PROXY` et `no_proxy` du processus enfant incluent `127.0.0.1` et `::1` en conservant les exclusions existantes. Activer `features.respect_system_proxy` ou transmettre `--enable`/`--disable` empêche le chargement automatique. Le lanceur tente de démarrer le proxy puis transmet les arguments d’origine à Codex, sans réécrire sa configuration. Les routes natives et les passerelles tierces sautent le démarrage. Une route inconnue avertit puis tente le démarrage ; un échec avertit et continue. L’aide, la version et les commandes internes comme `login`, `doctor` et `help` sautent le démarrage et la lecture automatique du jeton. 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 6613d6a9091..6c670af6ed7 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -132,6 +132,21 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +単語一つのプロンプトの前に `--` を置き、未知の Codex サブコマンド選択を防いでください。 + +設定ファイルがなくても、machine 設定パスに他の利用者が作成できない必要があります。Windows ProgramData/OpenAI/Codex の保護を確認できなければ注入を保留し、ディレクトリを作成しません。macOS は mode が安全でも拡張 ACL があれば拒否します。 + +新しいサービストークンは既定で注入しません。検証済みの管理 loopback provider、監査済みキーのみの設定、対話実行または `exec` が必要です。未知のフラグ・設定キー、`--oss`、`--local-provider`、すべての `-c`/`--config` と profile 上書き、復元セッション(`resume`/`fork`)、追加 provider、MCP サーバー、hook、通知コマンド、プロジェクト設定、追加の system/managed/requirements/認証設定層があると注入を保留します。空でない `CODEX_*`(正規の `CODEX_HOME` を除く)、`OPENAI_*`、`AZURE_OPENAI_*` も同様です。元の引数と利用者が明示した資格情報は維持し、stderr で通知します。 + +正規ホームと `config.toml` は現在の利用者所有である必要があります。POSIX は group/other の書き込みを、Windows は現在の利用者・SYSTEM・Administrators 以外への DACL 書き込み許可を拒否し、所有者 SID を確認します。祖先パスの置換権限も確認し、macOS は強制 Codex 設定 preference がないことも要求します。同じ利用者や管理者の悪意あるプロセスを隔離するものではありません。 + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + 自動認証情報の読み込みでは `CODEX_HOME` の `~` と空白をリテラルなパスとして解釈して検証し、同じ正規絶対パスを子プロセスに渡します。ホームを読めない場合は読み込みません。大文字小文字を問わず、空でない `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` があれば `NO_PROXY` に関係なく自動読み込みを省略します。拒否した起動とユーザーがエクスポートした認証情報は変更しません。新しいトークンを渡す場合、既存の除外項目を保持し、子の `NO_PROXY` と `no_proxy` に `127.0.0.1` と `::1` を追加します。`features.respect_system_proxy` の有効化や `--enable`/`--disable` の転送では自動読み込みを省略します。 元の引数で Codex を起動する前にプロキシの起動を試みます。ルーティングは Codex 設定が所有し、ランチャーは書き換えません。ネイティブと外部ゲートウェイでは起動を省略します。不明なルートでは警告して起動を試み、失敗しても警告後に続行します。ヘルプ、バージョン、`login`、`doctor`、`help` などの内部コマンドは起動と自動トークン読み込みを省略します。 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 e4e5f8c0820..a4492d586af 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -164,6 +164,21 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 ### `ocx codex [codex args...]` +한 단어 프롬프트 앞에는 `--`를 넣어 알 수 없는 Codex 하위 명령 선택을 막으세요. + +설정 파일이 없어도 machine 설정 경로에 다른 사용자가 파일을 만들 수 없어야 합니다. Windows ProgramData/OpenAI/Codex 경로의 보호를 증명할 수 없으면 자동 토큰을 보류하며 디렉터리를 만들지 않습니다. macOS에서는 mode 비트가 안전해도 확장 ACL이 있으면 거부합니다. + +새 서비스 토큰은 기본적으로 주입하지 않습니다. 정확한 관리 loopback provider, 검증된 키만 포함하는 설정, 대화형 실행 또는 `exec`가 모두 확인되어야 합니다. 알 수 없는 플래그·설정 키, `--oss`, `--local-provider`, 모든 `-c`/`--config` 및 profile 재정의, 세션 복원(`resume`/`fork`), 추가 provider, MCP 서버, hook, 알림 명령, 프로젝트 설정, 추가 system/managed/requirements/인증 설정층이 있으면 토큰을 보류합니다. 비어 있지 않은 `CODEX_*`(정규 `CODEX_HOME` 제외), `OPENAI_*`, `AZURE_OPENAI_*` 환경 변수도 주입을 막습니다. 원래 인자와 명시적으로 내보낸 사용자 토큰은 유지하며 stderr에 이유를 안내합니다. + +정규 홈 디렉터리와 `config.toml`은 현재 사용자 소유여야 합니다. POSIX에서는 group/other 쓰기를, Windows에서는 현재 사용자·SYSTEM·Administrators 이외의 DACL 쓰기 권한을 거부하고 소유자 SID를 확인합니다. 상위 경로 교체 권한도 확인합니다. macOS에서는 강제 Codex 설정 preference가 없어야 합니다. 같은 사용자나 관리자의 악성 프로세스로부터 격리하는 기능은 아닙니다. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + 자동 자격 증명 전달은 `CODEX_HOME`의 `~`와 공백을 리터럴 경로로 해석하고 검증한 뒤, 동일한 정규 절대 경로를 자식에게 전달합니다. 홈을 읽을 수 없으면 토큰을 읽지 않습니다. 대소문자와 관계없이 `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` 중 비어 있지 않은 값이 있으면 `NO_PROXY`와 무관하게 자동 로딩을 건너뜁니다. 거부된 실행과 사용자가 내보낸 자격 증명은 변경하지 않습니다. 새 토큰을 전달할 때 자식의 `NO_PROXY`와 `no_proxy`에 기존 우회 항목을 보존하면서 `127.0.0.1`과 `::1`을 추가합니다. `features.respect_system_proxy`를 켜거나 `--enable`/`--disable`을 전달하면 자동 로딩을 건너뜁니다. 원래 인자로 Codex를 실행하기 전에 프록시 시작을 시도합니다. 라우팅은 Codex 설정이 소유하며 런처가 재작성하지 않습니다. 네이티브 및 다른 게이트웨이 경로에서는 시작을 생략합니다. 알 수 없는 라우팅은 경고 후 시작을 시도하며, 시작 실패 시에도 경고 후 실행합니다. 도움말·버전 및 `login`, `doctor`, `help` 같은 내부 명령은 시작과 자동 토큰 읽기를 생략합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e435b28ace7..a4971f52719 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -965,6 +965,21 @@ before choosing it. Rendered models are not proof of native application. ### `ocx codex [codex args...]` +Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. + +The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS refuses extended filesystem ACLs even with safe mode bits. + +Fresh service-token injection is denied by default. It requires the exact managed loopback provider, a configuration containing only the audited keys, and a launch restricted to interactive mode or `exec`. Unknown flags and configuration keys, `--oss`, `--local-provider`, every `-c`/`--config` or profile override, restored sessions (`resume`/`fork`), additional providers, MCP servers, hooks, notification commands, project configuration, and additional system/managed/requirements/authentication layers withhold the token. Nonempty `CODEX_*` (except canonical `CODEX_HOME`), `OPENAI_*`, and `AZURE_OPENAI_*` environment controls also withhold it. Codex still receives the original arguments and a clear stderr note; explicit user credentials are preserved. + +The canonical home directory and `config.toml` must belong to the current user. POSIX denies group/other writes; Windows checks owner SID and rejects DACL write grants outside the current user, SYSTEM and Administrators. Ancestor replacement permissions are checked too. macOS additionally requires no forced Codex configuration preferences. These checks exclude other-user writers; they do not isolate a malicious process running as the same user or an administrator. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + Automatic credential delivery resolves `CODEX_HOME` as a literal path (including `~` and whitespace), verifies that home, and passes its canonical absolute path to the child. An unreadable home skips loading. Any nonempty `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` variable, in any letter case, also skips loading regardless of `NO_PROXY`; denied launches and user-exported credentials remain unchanged. With a newly loaded token, both child `NO_PROXY` and `no_proxy` include `127.0.0.1` and `::1`, preserving existing bypass entries. Enabling `features.respect_system_proxy` or forwarding `--enable`/`--disable` skips automatic loading. Ensure the proxy is running, then launch Codex with the original arguments. Routing remains in Codex configuration; the launcher never rewrites it. Native and foreign routes skip startup. Unknown routing warns and attempts startup; a failed startup warns and continues. Help/version and internal commands such as `login`, `doctor`, and `help` skip startup and automatic token loading. 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 7ca1d4b6372..0d3a7260743 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -153,6 +153,21 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +Перед подсказкой из одного слова укажите `--`, чтобы исключить выбор неизвестной подкоманды Codex. + +Пространство машинной конфигурации должно запрещать другим пользователям создание отсутствующих системных файлов. Непроверенный Windows ProgramData/OpenAI/Codex запрещает передачу токена; каталог не создаётся. macOS запрещает расширенные ACL даже при безопасных битах режима. + +Новый служебный токен по умолчанию не передаётся. Нужны точный управляемый loopback-провайдер, только проверенные ключи конфигурации и интерактивный запуск либо `exec`. Неизвестные флаги и ключи, `--oss`, `--local-provider`, любые переопределения `-c`/`--config` и профиля, восстановление сессий (`resume`/`fork`), дополнительные провайдеры, MCP-серверы, hooks, команды уведомлений, конфигурация проекта и дополнительные слои system/managed/requirements/аутентификации запрещают передачу. Непустые `CODEX_*` (кроме канонического `CODEX_HOME`), `OPENAI_*` и `AZURE_OPENAI_*` также запрещают её. Исходные аргументы и явно экспортированные пользователем токены сохраняются; причина выводится в stderr. + +Канонический каталог и `config.toml` должны принадлежать текущему пользователю. POSIX запрещает запись group/other; Windows проверяет SID владельца и запрещает запись DACL всем, кроме текущего пользователя, SYSTEM и Administrators. Проверяются права замены родительских путей. На macOS также не должно быть принудительных настроек Codex. Эти проверки не изолируют вредоносный процесс того же пользователя или администратора. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + При автоматическом чтении учётных данных `CODEX_HOME` трактуется как буквальный путь, включая `~` и пробелы. После проверки дочерний процесс получает тот же канонический абсолютный путь. Недоступный каталог отключает чтение. Любое непустое значение `HTTP_PROXY`, `HTTPS_PROXY` или `ALL_PROXY` без учёта регистра также отключает чтение независимо от `NO_PROXY`; отклонённые запуски и экспортированные пользователем учётные данные не меняются. При передаче нового токена в обе переменные дочернего процесса `NO_PROXY` и `no_proxy` добавляются `127.0.0.1` и `::1` с сохранением прежних исключений. Включение `features.respect_system_proxy` или передача `--enable`/`--disable` отключает автоматическое чтение. Лаунчер пытается запустить прокси и передаёт Codex исходные аргументы, не переписывая конфигурацию. Нативные маршруты и чужие шлюзы пропускают запуск прокси. Неизвестный маршрут предупреждает и пытается запустить прокси; неудача предупреждает и продолжает выполнение. Справка, версия и внутренние команды `login`, `doctor`, `help` пропускают запуск и автоматическое чтение токена. 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 548c340a6a7..c932b5a301b 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -189,6 +189,21 @@ eder. Claude Code ayarları için `ocx claude config ...` kullanın ### `ocx codex [codex args...]` +Bilinmeyen bir Codex alt komutu seçilmesini önlemek için tek sözcüklü istemden önce `--` kullanın. + +Makine yapılandırma alanı, diğer kullanıcıların eksik sistem dosyalarını oluşturmasını da engellemelidir. Korunmayan Windows ProgramData/OpenAI/Codex alanı otomatik belirteci engeller; dizin oluşturulmaz. macOS güvenli mod bitlerinde bile genişletilmiş ACL’leri reddeder. + +Yeni hizmet belirteci varsayılan olarak eklenmez. Tam doğrulanmış yönetilen loopback sağlayıcısı, yalnızca denetlenmiş yapılandırma anahtarları ve etkileşimli başlatma veya `exec` gerekir. Bilinmeyen bayraklar ve anahtarlar, `--oss`, `--local-provider`, tüm `-c`/`--config` ve profil geçersiz kılmaları, geri yüklenen oturumlar (`resume`/`fork`), ek sağlayıcılar, MCP sunucuları, hook ve bildirim komutları, proje yapılandırması ve ek system/managed/requirements/kimlik doğrulama katmanları belirteci engeller. Boş olmayan `CODEX_*` (kanonik `CODEX_HOME` hariç), `OPENAI_*` ve `AZURE_OPENAI_*` da engeller. Özgün argümanlar ve kullanıcının açıkça dışa aktardığı kimlik bilgileri korunur; stderr açıklama verir. + +Kanonik dizin ve `config.toml` geçerli kullanıcıya ait olmalıdır. POSIX group/other yazmasını reddeder; Windows sahip SID’sini doğrular ve geçerli kullanıcı, SYSTEM ve Administrators dışındaki DACL yazma izinlerini reddeder. Üst yol değiştirme izinleri de denetlenir. macOS ayrıca zorunlu Codex tercihlerinin bulunmamasını gerektirir. Bu kontroller aynı kullanıcı veya yönetici olarak çalışan kötü amaçlı işlemleri yalıtmaz. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + Otomatik kimlik bilgisi yükleme, `CODEX_HOME` içindeki `~` ve boşlukları değişmez yol olarak yorumlar, doğrular ve aynı kurallı mutlak yolu alt sürece aktarır. Okunamayan dizinde yükleme atlanır. Büyük/küçük harfe bakılmaksızın boş olmayan `HTTP_PROXY`, `HTTPS_PROXY` veya `ALL_PROXY` değeri varsa `NO_PROXY` ne olursa olsun yükleme atlanır; reddedilen başlatmalar ve kullanıcının dışa aktardığı kimlik bilgileri değiştirilmez. Yeni token aktarılırken alt sürecin `NO_PROXY` ve `no_proxy` değerlerine mevcut istisnalar korunarak `127.0.0.1` ve `::1` eklenir. `features.respect_system_proxy` etkinleştirilirse veya `--enable`/`--disable` aktarılırsa otomatik yükleme atlanır. Başlatıcı proxy'yi başlatmayı dener ve özgün argümanları Codex'e aktarır; yapılandırmayı yeniden yazmaz. Yerel Codex ve yabancı ağ geçidi yolları proxy başlangıcını atlar. Bilinmeyen yol uyarıp başlangıcı dener; başarısızlık uyarır ve devam eder. Yardım, sürüm ve `login`, `doctor`, `help` gibi iç komutlar başlangıcı ve otomatik belirteç okumayı atlar. 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 9aed25e1476..978847ba1a6 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 @@ -136,6 +136,21 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +单词提示前请加 `--`,以免选择未知的 Codex 子命令。 + +即使系统配置文件不存在,也必须阻止其他用户创建这些文件。无法验证 Windows ProgramData/OpenAI/Codex 路径保护时不自动注入令牌,也不创建目录。macOS 即使 mode 位安全,也会拒绝扩展 ACL。 + +新服务令牌默认不注入。必须确认使用准确的托管 loopback provider、仅含已审核键的配置,以及交互启动或 `exec`。未知标志或配置键、`--oss`、`--local-provider`、任何 `-c`/`--config` 或 profile 覆盖、恢复会话(`resume`/`fork`)、额外 provider、MCP 服务器、hook、通知命令、项目配置,以及额外 system/managed/requirements/认证配置层都会阻止注入。非空 `CODEX_*`(规范 `CODEX_HOME` 除外)、`OPENAI_*`、`AZURE_OPENAI_*` 环境变量也会阻止注入。原始参数和用户明确导出的凭据保持不变,stderr 会说明原因。 + +规范主目录和 `config.toml` 必须属于当前用户。POSIX 拒绝 group/other 写权限;Windows 验证所有者 SID,并拒绝当前用户、SYSTEM、Administrators 之外的 DACL 写入授权。还会检查祖先路径替换权限。macOS 还要求不存在强制 Codex 配置 preference。这些检查不能隔离同一用户或管理员运行的恶意进程。 + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + 自动凭证读取会将 `CODEX_HOME` 中的 `~` 与空白视为字面路径,验证后将同一个规范绝对路径传给子进程。无法读取主目录时不读取令牌。只要存在非空的 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY`(不区分大小写),就会跳过自动读取,不受 `NO_PROXY` 影响;拒绝的启动和用户导出的凭证保持不变。传递新令牌时,子进程的 `NO_PROXY` 与 `no_proxy` 会保留已有排除项并加入 `127.0.0.1` 和 `::1`。启用 `features.respect_system_proxy` 或传入 `--enable`/`--disable` 会跳过自动读取。 使用原始参数启动 Codex 前尝试启动代理。路由仍归 Codex 配置所有,启动器不会改写。本机原生及其他网关路由跳过代理启动;未知路由会警告并尝试启动,失败后仍警告并继续。帮助、版本及 `login`、`doctor`、`help` 等内部命令跳过代理启动和自动令牌读取。 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 663dd9079e8..70f4ba62ca1 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 @@ -133,6 +133,21 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ### `ocx codex [codex args...]` +單字提示前請加 `--`,以免選取未知的 Codex 子命令。 + +即使系統設定檔不存在,也必須阻止其他使用者建立這些檔案。無法驗證 Windows ProgramData/OpenAI/Codex 路徑保護時不自動注入權杖,也不建立目錄。macOS 即使 mode 位元安全,也會拒絕擴充 ACL。 + +新服務權杖預設不注入。必須確認使用準確的受管 loopback provider、僅含已稽核鍵的設定,以及互動啟動或 `exec`。未知旗標或設定鍵、`--oss`、`--local-provider`、任何 `-c`/`--config` 或 profile 覆寫、還原工作階段(`resume`/`fork`)、額外 provider、MCP 伺服器、hook、通知命令、專案設定,以及額外 system/managed/requirements/驗證設定層都會阻止注入。非空 `CODEX_*`(標準 `CODEX_HOME` 除外)、`OPENAI_*`、`AZURE_OPENAI_*` 環境變數也會阻止注入。原始引數與使用者明確匯出的憑證維持不變,stderr 會說明原因。 + +標準主目錄與 `config.toml` 必須屬於目前使用者。POSIX 拒絕 group/other 寫入權限;Windows 驗證擁有者 SID,並拒絕目前使用者、SYSTEM、Administrators 以外的 DACL 寫入授權。也會檢查祖先路徑替換權限。macOS 還要求不存在強制 Codex 設定 preference。這些檢查無法隔離相同使用者或管理員執行的惡意程序。 + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + 自動憑證讀取會將 `CODEX_HOME` 中的 `~` 與空白視為字面路徑,驗證後將同一個正規絕對路徑傳給子程序。無法讀取主目錄時不讀取權杖。只要存在非空的 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY`(不分大小寫),就會略過自動讀取,不受 `NO_PROXY` 影響;拒絕的啟動和使用者匯出的憑證保持不變。傳遞新權杖時,子程序的 `NO_PROXY` 與 `no_proxy` 會保留既有排除項目並加入 `127.0.0.1` 和 `::1`。啟用 `features.respect_system_proxy` 或傳入 `--enable`/`--disable` 會略過自動讀取。 使用原始引數啟動 Codex 前嘗試啟動代理。路由仍由 Codex 設定擁有,啟動器不會改寫。原生及其他閘道路由略過代理啟動;未知路由會警告並嘗試啟動,失敗後仍警告並繼續。說明、版本及 `login`、`doctor`、`help` 等內部命令略過代理啟動和自動權杖讀取。 diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 7ffacbc9bac..b6872c59f76 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { + "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", "codex-launcher-security.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 2449764fef6..0ebe461bb9f 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -7,6 +7,8 @@ import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; import { readClientConnectionState } from "../client/state"; +import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; +import { codexLaunchHomeIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; /** Resolve once; the credential verifier and child must use this exact directory. */ export function resolveCodexLaunchHome(env: Record): string | null { @@ -25,8 +27,16 @@ export function resolveCodexLaunchHome(env: Record): } /** Config classification is a startup hint; credentials need an independently bound destination. */ -export function codexCredentialDestinationIsTrusted(home: string): boolean { +export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string): boolean { try { + if (!codexLaunchHomeIsOwned(home)) return false; + if (!codexLaunchSystemNamespaceIsSafe(systemNamespaceDirectory)) return false; + const additionalLayers = [join(home, "auth.json"), join(home, "managed_config.toml"), join(home, "requirements.toml")]; + if (process.platform !== "win32") additionalLayers.push("/etc/codex/config.toml", "/etc/codex/managed_config.toml", "/etc/codex/requirements.toml"); + for (const path of additionalLayers) { + try { lstatSync(path); return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + } const diagnostics = readConfigDiagnostics(); if (diagnostics.error || diagnostics.source === "fallback") return false; const cfg = diagnostics.config; @@ -43,6 +53,7 @@ export function codexCredentialDestinationIsTrusted(home: string): boolean { const content = readBoundedCodexConfig(join(home, "config.toml")); if (content === null) return false; const config = Bun.TOML.parse(content) as Record; + if (codexHasOtherTokenConsumers(config) || !codexTokenConfigShapeIsSafe(config)) return false; // This native feature resolves OS/PAC routes without honoring NO_PROXY. const features = config.features as Record | undefined; if (features?.respect_system_proxy !== undefined && features.respect_system_proxy !== false) return false; diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts new file mode 100644 index 00000000000..e625a9de884 --- /dev/null +++ b/src/cli/codex-launch-ownership.ts @@ -0,0 +1,188 @@ +import { lstatSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; + +// Paths are environment data, never interpolated PowerShell source. The token is +// not read until this read-only subprocess and the config checks have succeeded. +const SCRIPT = String.raw` +$ErrorActionPreference='Stop' +try { + $user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $trusted=@($user,'S-1-5-18','S-1-5-32-544') + $installer='S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + $sections=[System.Security.AccessControl.AccessControlSections]'Owner,Access' + $paths=@($env:OCX_CODEX_OWNED_HOME,[System.IO.Path]::Combine($env:OCX_CODEX_OWNED_HOME,'config.toml')) + $exactOwners=2 + if ($env:OCX_CODEX_NAMESPACE_CHECK -eq '1') { + $systemDir=$env:OCX_CODEX_NAMESPACE_FIXTURE + if ([string]::IsNullOrEmpty($systemDir)) { + $programData=[Environment]::GetFolderPath([Environment+SpecialFolder]::CommonApplicationData) + if ([string]::IsNullOrEmpty($programData)) { exit 1 } + $systemDir=[System.IO.Path]::Combine($programData,'OpenAI','Codex') + } + foreach ($file in @('config.toml','requirements.toml','managed_config.toml')) { + try { $null=[System.IO.File]::GetAttributes([System.IO.Path]::Combine($systemDir,$file)); exit 1 } + catch [System.IO.FileNotFoundException] { } + catch [System.IO.DirectoryNotFoundException] { } + } + $existing=$systemDir + while ($true) { + try { $attributes=[System.IO.File]::GetAttributes($existing); break } + catch [System.IO.FileNotFoundException] { } + catch [System.IO.DirectoryNotFoundException] { } + $existing=[System.IO.Path]::GetDirectoryName($existing) + if ([string]::IsNullOrEmpty($existing)) { exit 1 } + } + if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { exit 1 } + $paths=@($existing) + $exactOwners=0 + } + $parent=[System.IO.Directory]::GetParent($paths[0]) + while ($null -ne $parent) { $paths+= $parent.FullName; $parent=$parent.Parent } + for ($i=0;$i -lt $paths.Count;$i++) { + $path=$paths[$i] + if (([System.IO.File]::GetAttributes($path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { exit 1 } + if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path,$sections) } + else { $acl=[System.Security.AccessControl.FileSecurity]::new($path,$sections) } + $raw=[System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(),0) + if ($i -lt $exactOwners -and $raw.Owner.Value -ne $user) { exit 1 } + if ($i -ge $exactOwners -and $trusted -notcontains $raw.Owner.Value -and $raw.Owner.Value -ne $installer) { exit 1 } + if ($null -eq $raw.DiscretionaryAcl -or ([int]$raw.ControlFlags -band 4) -eq 0) { exit 1 } + foreach ($ace in $raw.DiscretionaryAcl) { + if ($ace -isnot [System.Security.AccessControl.CommonAce] -or $ace.IsCallback) { exit 1 } + if (([int]$ace.AceFlags -band 8) -ne 0) { continue } + if ([int]$ace.AceType -eq 1) { continue } + if ([int]$ace.AceType -ne 0) { exit 1 } + $sid=$ace.SecurityIdentifier.Value + if ($trusted -contains $sid) { continue } + # TrustedInstaller is an OS authority on ancestors only, never on the two owned entries. + if ($i -ge $exactOwners -and $sid -eq $installer) { continue } + # Include generic writes/all, DELETE, WRITE_DAC, WRITE_OWNER, and directory DELETE_CHILD. + $mask=[int64]$ace.AccessMask -band 4294967295 + $mutation=1343029590 + if ($i -ge 2 -or ($exactOwners -eq 0 -and $i -ge 1)) { $mutation=1343029312 } + if (($mask -band $mutation) -ne 0) { exit 1 } + } + } + exit 0 +} catch { exit 1 } +`; +const ENCODED_SCRIPT = Buffer.from(SCRIPT, "utf16le").toString("base64"); + +function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirectory?: string): boolean { + // Remove case aliases before constructing a Windows environment block. These + // private protocol inputs must come from this call, never the caller's shell. + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/^(?:OCX_CODEX_OWNED_HOME|OCX_CODEX_NAMESPACE_CHECK|OCX_CODEX_NAMESPACE_FIXTURE)$/i.test(key))); + const result = Bun.spawnSync([ + resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT, + ], { env: { ...inherited, OCX_CODEX_OWNED_HOME: home, OCX_CODEX_NAMESPACE_CHECK: namespace ? "1" : "0", + OCX_CODEX_NAMESPACE_FIXTURE: fixtureDirectory }, stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000, windowsHide: true }); + return result.success && !result.exitedDueToTimeout; +} + +/** A missing system layer is safe only if another user cannot create it later. */ +export function codexPosixSystemNamespace(platform: NodeJS.Platform = process.platform): string { + // Darwin's /etc is a standard root-owned symlink; inspect its physical namespace. + return platform === "darwin" ? "/private/etc/codex" : "/etc/codex"; +} + +export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string): boolean { + try { + if (process.platform === "win32") return windowsOwnershipCheck("", true, fixtureDirectory); + let path = fixtureDirectory ?? codexPosixSystemNamespace(); + for (const file of ["config.toml", "requirements.toml", "managed_config.toml"]) { + try { lstatSync(join(path, file)); return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + } + for (;;) { + try { lstatSync(path); break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + if (dirname(path) === path) return false; + path = dirname(path); + } + const paths: string[] = []; + for (;;) { + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.uid !== process.getuid?.() && stat.uid !== 0) + || ((stat.mode & 0o022) !== 0 && (paths.length === 0 || (stat.mode & 0o1000) === 0))) return false; + paths.push(path); + if (dirname(path) === path) break; + path = dirname(path); + } + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); + } catch { return false; } +} + +/** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ +export function darwinAclInspectionIsEmpty(validResult: number, entryResult: number, errorNumber: number): boolean { + // Darwin differs from POSIX: found entry => 0; a valid empty ACL => -1/EINVAL. + return validResult === 0 && entryResult === -1 && errorNumber === 22; +} + +function macHasNoManagedCodexPreferences(paths: readonly string[]): boolean { + let library: { close(): void } | undefined; + let aclLibrary: { close(): void } | undefined; + try { + const { dlopen, FFIType, ptr, read } = require("bun:ffi") as typeof import("bun:ffi"); + const acl = dlopen("/usr/lib/libSystem.B.dylib", { + acl_get_file: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr }, + acl_get_entry: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr], returns: FFIType.i32 }, + acl_valid: { args: [FFIType.ptr], returns: FFIType.i32 }, + acl_free: { args: [FFIType.ptr], returns: FFIType.i32 }, + __error: { args: [], returns: FFIType.ptr }, + }); + aclLibrary = acl; + for (const path of paths) { + const bytes = Buffer.from(path + "\0"), entry = Buffer.alloc(8); + const descriptor = acl.symbols.acl_get_file(ptr(bytes), 0x100); // ACL_TYPE_EXTENDED + if (!descriptor) return false; + try { + // Deny all extended ACLs, including unknown ACL semantics; mode bits alone are insufficient. + const valid = acl.symbols.acl_valid(descriptor); + if (valid !== 0) return false; + const found = acl.symbols.acl_get_entry(descriptor, 0, ptr(entry)); // ACL_FIRST_ENTRY + const errno = acl.symbols.__error(); + if (!errno || !darwinAclInspectionIsEmpty(valid, found, read.i32(errno))) return false; + } finally { acl.symbols.acl_free(descriptor); } + } + const core = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", { + CFStringCreateWithCString: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.ptr }, + CFPreferencesAppValueIsForced: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool }, + CFRelease: { args: [FFIType.ptr], returns: FFIType.void }, + }); + library = core; + const strings = ["com.openai.codex", "config_toml_base64", "requirements_toml_base64"].map(value => Buffer.from(value + "\0")); + const refs = strings.map(value => core.symbols.CFStringCreateWithCString(null, ptr(value), 0x08000100)); + try { + if (refs.some(value => !value)) return false; + return !core.symbols.CFPreferencesAppValueIsForced(refs[1]!, refs[0]!) + && !core.symbols.CFPreferencesAppValueIsForced(refs[2]!, refs[0]!); + } finally { for (const ref of refs) if (ref) core.symbols.CFRelease(ref); } + } catch { return false; } + finally { library?.close(); aclLibrary?.close(); } +} + +/** Excludes other-user writers; it cannot isolate processes with the same user identity. */ +export function codexLaunchHomeIsOwned(home: string): boolean { + try { + const directory = lstatSync(home), config = lstatSync(join(home, "config.toml")); + if (!directory.isDirectory() || directory.isSymbolicLink() || !config.isFile() || config.isSymbolicLink()) return false; + if (process.platform === "win32") { + return windowsOwnershipCheck(home, false); + } + const uid = process.getuid?.(); + if (uid === undefined || [directory, config].some(stat => stat.uid !== uid || (stat.mode & 0o022) !== 0)) return false; + const paths = [home, join(home, "config.toml")]; + for (let parent = dirname(home); ; parent = dirname(parent)) { + const stat = lstatSync(parent); + if (!stat.isDirectory() || stat.isSymbolicLink()) return false; + if (stat.uid !== uid && stat.uid !== 0) return false; + // Sticky directories (e.g. /tmp) protect entries owned by this user. + if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) return false; + paths.push(parent); + if (dirname(parent) === parent) break; + } + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); + } catch { return false; } +} diff --git a/src/cli/codex-launch-policy.ts b/src/cli/codex-launch-policy.ts new file mode 100644 index 00000000000..fdb190c3be9 --- /dev/null +++ b/src/cli/codex-launch-policy.ts @@ -0,0 +1,84 @@ +/** Credential authorization is deliberately narrower than Codex's evolving CLI. */ +export const CODEX_TOKEN_SAFE_SWITCHES = [ + "--no-daemon", "--json", "--ephemeral", "--skip-git-repo-check", + "--no-alt-screen", "--full-auto", "--dangerously-bypass-approvals-and-sandbox", "--search", +] as const; +export const CODEX_TOKEN_SAFE_VALUE_FLAGS = ["--model", "-m", "--sandbox", "-s", "--ask-for-approval", "-a", "--image", "-i"] as const; + +export function codexTokenArgsAreSafe(args: readonly string[]): boolean { + let command = "interactive", operands = 0, afterSeparator = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--" && !afterSeparator) { afterSeparator = true; continue; } + if (arg.startsWith("-") && arg !== "-") { + if (afterSeparator) return false; + if ((CODEX_TOKEN_SAFE_SWITCHES as readonly string[]).includes(arg)) continue; + const equals = arg.indexOf("="), flag = equals < 0 ? arg : arg.slice(0, equals); + if (!(CODEX_TOKEN_SAFE_VALUE_FLAGS as readonly string[]).includes(flag)) return false; + const value = equals < 0 ? args[++i] : arg.slice(equals + 1); + if (!value || value.startsWith("-")) return false; + if (["--sandbox", "-s"].includes(flag) && !["read-only", "workspace-write", "danger-full-access"].includes(value)) return false; + if (["--ask-for-approval", "-a"].includes(flag) && !["untrusted", "on-failure", "on-request", "never"].includes(value)) return false; + continue; + } + // Restored sessions may restore a different cwd/provider. They need a separate proof. + if (!afterSeparator && operands === 0 && ["resume", "fork", "review", "app", "app-server", "mcp", "mcp-server", "cloud", "apply", "completion", "debug", "features", "sandbox", "login", "logout", "help", "update", "doctor", "e"].includes(arg)) return false; + if (!afterSeparator && command === "interactive" && operands === 0 && arg === "exec") { + command = arg; continue; + } + // A single-word operand can become a subcommand after a native-client upgrade. + // Require an explicit separator for such prompts; whitespace cannot name a subcommand. + if (!afterSeparator && arg !== "-" && !/\s/.test(arg)) return false; + operands++; + if (operands > 1) return false; + } + return true; +} + +// Unknown configuration may introduce a command helper, hook, or new credential consumer. +// Keep this independent from the provider's exact generated-block comparison. +export const CODEX_TOKEN_SAFE_CONFIG_KEYS = [ + "model_provider", "model_providers", "model", "model_reasoning_effort", "model_reasoning_summary", + "model_verbosity", "approval_policy", "sandbox_mode", "openai_base_url", "features", +] as const; + +export function codexTokenConfigShapeIsSafe(config: Record): boolean { + if (Object.keys(config).some(key => !(CODEX_TOKEN_SAFE_CONFIG_KEYS as readonly string[]).includes(key))) return false; + const providers = config.model_providers; + if (!providers || typeof providers !== "object" || Object.keys(providers).some(key => key !== "opencodex")) return false; + for (const [key, value] of Object.entries(config)) { + if (["features", "model_providers"].includes(key)) continue; + if (typeof value !== "string") return false; + } + const features = config.features; + if (features !== undefined && (!features || typeof features !== "object" || Array.isArray(features) + || Object.entries(features).some(([key, value]) => key === "respect_system_proxy" ? value !== false + : key !== "system_proxy_fallback" || typeof value !== "boolean") + || ("system_proxy_fallback" in features && (!("respect_system_proxy" in features) || features.respect_system_proxy !== false)))) return false; + return true; +} + +/** Unknown Codex/OpenAI environment controls need an audit before receiving a fresh secret. */ +export function codexTokenEnvironmentIsSafe(env: Record): boolean { + return !Object.entries(env).some(([key, value]) => { + if (!value) return false; + if (/^(?:https?|all)_proxy$/i.test(key)) return true; + if (/^(?:CODEX_|OPENAI_|AZURE_OPENAI_)/i.test(key) && key !== "CODEX_HOME") return true; + return key !== "OPENCODEX_API_AUTH_TOKEN" && key.toUpperCase() === "OPENCODEX_API_AUTH_TOKEN"; + }); +} + +/** Check even unselected profiles/providers; new credential field names fail closed. */ +export function codexHasOtherTokenConsumers(config: Record): boolean { + function visit(value: unknown): boolean { + if (typeof value === "string") return value.toUpperCase().includes("OPENCODEX_API_AUTH_TOKEN"); + if (!value || typeof value !== "object") return false; + return Object.entries(value).some(([key, child]) => + key.toUpperCase().includes("OPENCODEX_API_AUTH_TOKEN") + // A stdio MCP process can inherit the environment without naming the variable. + || (key === "mcp_servers" && child !== null && typeof child === "object" && Object.keys(child).length > 0) + || visit(child)); + } + const providers = config.model_providers as Record | undefined; + return visit({ ...config, model_providers: { ...providers, opencodex: undefined } }); +} diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 91e1b264884..d3ce12ebc42 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -36,6 +36,7 @@ import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; +import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; export interface CodexLaunchEnv { [key: string]: string | undefined; @@ -167,8 +168,9 @@ const defaultServiceToken = (base: CodexLaunchEnv): string | null => * Launch env for a routed exec. The one injection is the shim's own: a missing * OPENCODEX_API_AUTH_TOKEN is re-exported from the hardened service token file, so * `ocx codex` from a shell without the system-env snapshot authenticates to the - * loopback proxy like a shimmed launch does. Fresh credentials require a bound home - * and no inherited transport proxy. User exports always win. + * loopback proxy like a shimmed launch does. Fresh credentials require explicit + * argv/env/config allowlists, an exclusive managed provider and protected home/system + * configuration namespaces. User exports always win. */ export function buildCodexLaunchEnv( base: CodexLaunchEnv = process.env, @@ -176,14 +178,16 @@ export function buildCodexLaunchEnv( ): CodexLaunchEnv { const env = { ...base }; if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; - // Config/profile overrides can replace the destination after the home was classified. - if (deps.args?.some(arg => /^(?:-c|-p|-C|--config(?:=|$)|--profile(?:=|$)|--cd(?:=|$)|--remote(?:[=-]|$)|--(?:enable|disable)(?:=|$))/.test(arg))) return env; if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { + const withhold = (): CodexLaunchEnv => { + console.error("OpenCodex: automatic service token withheld; Codex arguments, environment, config consumers or ownership could not be verified. Launching without a fresh token."); + return env; + }; + if (!codexTokenArgsAreSafe(deps.args ?? []) || !codexTokenEnvironmentIsSafe(env)) return withhold(); // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. // Without proving bypass, preserve the user's transport but never load a secret. - if (Object.entries(env).some(([key, value]) => /^(?:https?|all)_proxy$/i.test(key) && value)) return env; const home = resolveCodexLaunchHome(env); - if (!home || env.OPENAI_BASE_URL || !(deps.credentialDestination ?? codexCredentialDestinationIsTrusted)(home)) return env; + if (!home || !(deps.credentialDestination ?? codexCredentialDestinationIsTrusted)(home)) return withhold(); const token = (deps.serviceToken ?? defaultServiceToken)(env); if (token && !tokenCollidesWithAdmin(token, env)) { // Reqwest's default transport can discover OS proxies even without proxy diff --git a/structure/cli-management.md b/structure/cli-management.md index 7b67695237f..db93b284345 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -21,12 +21,17 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl ## Head and help navigation +`src/cli/codex-launch-policy.ts` authorizes fresh credentials by explicit argument, environment and configuration allowlists. Only interactive and `exec` modes qualify; restored sessions and every config/profile/provider-selection override withhold fresh credentials. The switches are `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search`; one-value options are `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i`. Short clusters and missing values refuse. Only model/provider selection, model reasoning/verbosity strings, approval/sandbox strings, the matching root URL and disabled system-proxy feature are accepted in the home configuration. Alternate providers, MCP servers (including stdio inheritance), hooks and unknown keys refuse. Every token-variable reference outside the verified provider is refused, even in an unselected profile. Nonempty unknown CODEX/OpenAI/Azure environment controls refuse. Additional authentication, system, managed, requirements and project layers are not authorized. Refusals preserve argv and explicit credentials and print a value-free stderr note. + +`src/cli/codex-launch-ownership.ts` checks current-user ownership of the canonical home and config, rejects POSIX group/other writes and Windows DACL mutation grants outside current user/SYSTEM/Administrators, and rejects unsafe ancestor replacement rights. Windows system paths come from the CommonApplicationData known folder; ancestor ownership also permits TrustedInstaller. macOS checks forced CoreFoundation preferences. Read/inspection errors deny. These controls exclude other-user writers, not same-user or privileged processes; they do not claim immutable startup configuration. Regression coverage is in `tests/codex-integration/codex-launcher-admission.test.ts`. + + For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A recorded wrapper uses its backing directly; unknown Windows CMD wrappers require complete generated-template equality via `src/cli/codex-launch-command.ts`, then execute the selected absolute path with bypass. Obsolete, unreadable or ambiguous wrappers and lookup/inspection errors refuse launch without printing exception contents. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. -Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. +Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS rejects every extended filesystem ACL, even when ordinary owner/mode bits look safe. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/tests/codex-integration/codex-launcher-admission.test.ts b/tests/codex-integration/codex-launcher-admission.test.ts new file mode 100644 index 00000000000..0f3a0df38a9 --- /dev/null +++ b/tests/codex-integration/codex-launcher-admission.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { makeCodexLaunchFixturePrivate, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { codexLaunchSystemNamespaceIsSafe, codexPosixSystemNamespace, darwinAclInspectionIsEmpty } from "../../src/cli/codex-launch-ownership"; + +const token = "ocx_fixture_admission"; +const cfg = { port: 12199, hostname: "0.0.0.0" }; +const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); + +function fixture(run: (root: string, home: string) => void): void { + const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); + const root = mkdtempSync(join(temp, "ocx-admission-")), home = join(root, "codex"), ocx = join(root, "ocx"); + makeCodexLaunchFixturePrivate(root); + mkdirSync(home); mkdirSync(ocx); + const prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + writeFileSync(join(home, "config.toml"), local); + process.chdir(root); + try { run(root, home); } + finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } +} + +function launch(home: string, args: string[] = [], extra: Record = {}) { + let reads = 0; + const notes: string[] = []; + // Capture stderr without introducing a policy test seam. + const original = console.error; + console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; + try { + const env = buildCodexLaunchEnv({ CODEX_HOME: home, ...extra }, { + routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; }, + }); + return { env, reads, notes }; + } finally { console.error = original; } +} + +function denied(home: string, args: string[] = [], extra: Record = {}) { + const result = launch(home, args, extra); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(result.reads).toBe(0); + expect(result.notes.join(" ")).toContain("withheld"); + expect(result.notes.join(" ")).not.toContain(token); +} + +describe("Codex fresh token admission defaults to deny", () => { + for (const args of [ + ["exec", "--oss", "--local-provider", "capture", "prompt"], + ["--oss"], ["--local-provider=capture"], + ["-c", 'model_provider="capture"'], ["--config=model_provider=opencodex"], + ["-c", "unknown_future_key=true"], ["--profile", "capture"], ["-pcapture"], + ["--unknown-future-provider", "capture"], ["exec", "--unknown-switch"], + ["--model"], ["--model", "--oss"], ["future-subcommand", "prompt"], + ["exec", "--", "--oss"], + ["resume"], ["fork"], ["exec", "resume"], ["exec", "fork"], + ["future-subcommand"], ["exec", "future-subcommand"], + ]) test(`withholds for ambiguous argv ${args.join(" ")}`, () => fixture((_root, home) => denied(home, args))); + + for (const key of ["OPENAI_BASE_URL", "openai_base_url", "CODEX_OSS_BASE_URL", "CODEX_OSS_PORT", "CODEX_MODEL_PROVIDER", "CODEX_CONFIG", "CODEX_CONFIG_FILE", "CODEX_MANAGED_CONFIG_PATH", "CODEX_REVIEW_BASE_URL", "OPENAI_API_BASE", "AZURE_OPENAI_ENDPOINT"]) { + test(`withholds for ${key}`, () => fixture((_root, home) => denied(home, [], { [key]: "fixture-override" }))); + } + + for (const [name, addition] of [ + ["MCP bearer", '\n[mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nbearer_token_env_var="OPENCODEX_API_AUTH_TOKEN"\n'], + ["MCP header", '\n[mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nenv_http_headers={Authorization="OPENCODEX_API_AUTH_TOKEN"}\n'], + ["MCP env map", '\n[mcp_servers.capture]\ncommand="fixture"\nenv={COPY="OPENCODEX_API_AUTH_TOKEN"}\n'], + ["MCP process inheritance", '\n[mcp_servers.capture]\ncommand="fixture"\n'], + ["other provider env_key", '\n[model_providers.capture]\nbase_url="https://capture.invalid/v1"\nenv_key="OPENCODEX_API_AUTH_TOKEN"\n'], + ["profile consumer", '\n[profiles.unselected.mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nbearer_token_env_var="OPENCODEX_API_AUTH_TOKEN"\n'], + ["unknown consumer", '\n[future.consumer]\ncredential="OPENCODEX_API_AUTH_TOKEN"\n'], + ]) test(`withholds for ${name}`, () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local + addition); + denied(home); + })); + + test("withholds for project-level MCP consumer", () => fixture((root, home) => { + mkdirSync(join(root, ".codex")); + writeFileSync(join(root, ".codex", "config.toml"), '[mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nbearer_token_env_var="OPENCODEX_API_AUTH_TOKEN"\n'); + denied(home); + })); + + for (const file of ["auth.json", "managed_config.toml", "requirements.toml"]) test(`withholds for additional layer ${file}`, () => fixture((_root, home) => { + writeFileSync(join(home, file), ""); + denied(home); + })); + for (const content of ['notify=["fixture"]\n', '[hooks]\ncommand="fixture"\n', '[features]\nfuture_consumer=true\n']) { + test(`withholds for unknown config ${content.trim()}`, () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), content.startsWith("[") ? local + "\n" + content : content + local); + denied(home); + })); + } + + for (const entry of ["directory", "config"] as const) test(`withholds for foreign-writable ${entry}`, () => fixture((_root, home) => { + const path = entry === "directory" ? home : join(home, "config.toml"); + if (process.platform === "win32") { + const script = String.raw`$ErrorActionPreference='Stop' +$path=$env:OCX_TEST_ACL_PATH +$sections=[System.Security.AccessControl.AccessControlSections]'Owner,Access' +if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path,$sections) } +else { $acl=[System.Security.AccessControl.FileSecurity]::new($path,$sections) } +$sid=[System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') +$rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,[System.Security.AccessControl.FileSystemRights]::Write,[System.Security.AccessControl.AccessControlType]::Allow) +$acl.AddAccessRule($rule) +if ([System.IO.Directory]::Exists($path)) { [System.IO.Directory]::SetAccessControl($path,$acl) } +else { [System.IO.File]::SetAccessControl($path,$acl) }`; + const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { + env: { ...process.env, OCX_TEST_ACL_PATH: path }, stdout: "pipe", stderr: "pipe", windowsHide: true, + }); + expect(result.exitCode).toBe(0); + } else chmodSync(path, entry === "directory" ? 0o777 : 0o666); + denied(home); + if (entry === "directory") expect(codexLaunchSystemNamespaceIsSafe(join(home, "missing-system"))).toBe(false); + })); + + test("a protected empty system namespace may authorize missing files", () => fixture((_root, home) => { + expect(codexLaunchSystemNamespaceIsSafe(join(home, "missing", "Codex"))).toBe(true); + })); + test.skipIf(process.platform !== "win32")("shell casing aliases cannot replace the system namespace protocol input", () => fixture((_root, home) => { + const actual = codexLaunchSystemNamespaceIsSafe(); + for (const key of ["OCX_CODEX_NAMESPACE_FIXTURE", "ocx_codex_namespace_fixture", "OcX_CoDeX_NaMeSpAcE_FiXtUrE"]) { + const prior = process.env[key]; + try { + process.env[key] = join(home, "missing-system"); + expect(codexLaunchSystemNamespaceIsSafe()).toBe(actual); + } finally { if (prior === undefined) delete process.env[key]; else process.env[key] = prior; } + } + })); + test("an additional system file refuses authorization", () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local); + expect(codexLaunchSystemNamespaceIsSafe(home)).toBe(false); + })); + test("Darwin ACL success, empty and inspection-error results are distinct", () => { + expect(darwinAclInspectionIsEmpty(0, 0, 22)).toBe(false); + expect(darwinAclInspectionIsEmpty(0, -1, 22)).toBe(true); + expect(darwinAclInspectionIsEmpty(0, -1, 13)).toBe(false); + expect(darwinAclInspectionIsEmpty(-1, -1, 22)).toBe(false); + }); + test("production Darwin namespace uses the physical etc path", () => { + expect(codexPosixSystemNamespace("darwin")).toBe("/private/etc/codex"); + expect(codexPosixSystemNamespace("linux")).toBe("/etc/codex"); + }); + for (const entry of ["home", "config", "ancestor"]) test.skipIf(process.platform !== "darwin")(`macOS extended ACL on ${entry} with safe mode bits refuses credentials`, () => fixture((root, home) => { + const path = entry === "config" ? join(home, "config.toml") : entry === "home" ? home : root; + chmodSync(path, entry === "config" ? 0o600 : 0o700); + const result = Bun.spawnSync(["/bin/chmod", "+a", "everyone allow write,delete,writeattr,writeextattr,writesecurity,chown", path]); + expect(result.success).toBe(true); + denied(home); + })); + + for (const args of [[], ["-m", "fixture", "--", "hello"], ["--model=fixture", "--", "hello"], ["--no-daemon", "exec", "--skip-git-repo-check", "--ephemeral", "--json", "fixture prompt"]]) { + test(`managed positive ${args.join(" ")}`, () => fixture((_root, home) => { + const result = launch(home, args); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + expect(result.notes).toEqual([]); + })); + } +}); diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 70b11031436..273a3edcbdd 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -3,12 +3,12 @@ import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { fixturePath } from "../helpers/repo-root"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { makeCodexLaunchFixturePrivate, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; const token = "ocx_fixture_launch_env"; const installation = { port: 12199, hostname: "0.0.0.0" }; @@ -20,13 +20,15 @@ async function fixture(run: (root: string, home: string) => void | Promise // Avoid the real user's ancestor .codex layer, which deliberately denies injection. const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); const root = mkdtempSync(join(temp, "ocx-launch-env-")); + makeCodexLaunchFixturePrivate(root); const saved = { ...process.env }, cwd = process.cwd(); const user = join(root, "user"), home = join(root, "codex-home"), ocx = join(root, "ocx"); for (const path of [user, home, ocx]) mkdirSync(path); process.env.HOME = user; process.env.USERPROFILE = user; process.env.CODEX_HOME = home; process.env.OPENCODEX_HOME = ocx; for (const key of Object.keys(process.env)) { - if (/^(?:(?:https?|all|no)_proxy|OPENCODEX_API_AUTH_TOKEN|OPENCODEX_ADMIN_AUTH_TOKEN|OPENAI_BASE_URL|OCX_API_TOKEN_FILE)$/i.test(key)) delete process.env[key]; + if ((key !== "CODEX_HOME" && /^(?:CODEX_|OPENAI_|AZURE_OPENAI_)/i.test(key)) + || /^(?:(?:https?|all|no)_proxy|OPENCODEX_API_AUTH_TOKEN|OPENCODEX_ADMIN_AUTH_TOKEN|OCX_API_TOKEN_FILE)$/i.test(key)) delete process.env[key]; } writeFileSync(join(ocx, "config.json"), JSON.stringify(installation)); writeFileSync(join(home, "config.toml"), local); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 2275de53782..0b851028191 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join, parse } from "node:path"; import { spawnSync } from "node:child_process"; -import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; import { codexCommandCanBypass } from "../../src/cli/codex-launch-command"; import { classifyCodexRouting } from "../../src/codex/inject/routing-classify"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; @@ -12,6 +11,7 @@ import { buildWindowsCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/c import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { makeCodexLaunchFixturePrivate, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; const fixtureToken = "ocx_fixture_launcher_security"; const installation = { port: 12199, hostname: "0.0.0.0" }; @@ -20,6 +20,7 @@ const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarge function fixture(run: (home: string, codexHome: string, ocxHome: string) => void): void { const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); const home = mkdtempSync(join(temp, "ocx-launch-security-")); + makeCodexLaunchFixturePrivate(home); const cwd = process.cwd(); const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; const codexHome = join(home, "codex-home"), ocxHome = join(home, "ocx-home"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 344b205d673..7b50cab26b3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -586,6 +586,7 @@ "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", + "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", "codex-launcher-security.test.ts": "codex-integration", diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts new file mode 100644 index 00000000000..ec56354c4a5 --- /dev/null +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -0,0 +1,30 @@ +import { chmodSync } from "node:fs"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { join } from "node:path"; +import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; + +// Model a protected, empty machine configuration namespace inside this fixture. +// Every other authority check uses real config/ownership IO. Native probes use production defaults. +export const buildCodexPrivateFixtureLaunchEnv: typeof buildCodexLaunchEnv = (base, deps) => buildCodexLaunchEnv(base, { + credentialDestination: home => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture")), ...deps, +}); + +/** Windows Temp grants Users create rights to descendants; model a private Codex home. */ +export function makeCodexLaunchFixturePrivate(path: string): void { + if (process.platform !== "win32") { chmodSync(path, 0o700); return; } + const script = String.raw`$ErrorActionPreference='Stop' +$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User +$acl=[System.Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($user) +$acl.SetAccessRuleProtection($true,$false) +foreach ($sid in @($user,[System.Security.Principal.SecurityIdentifier]::new('S-1-5-18'),[System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))) { + $rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) +} +[System.IO.Directory]::SetAccessControl($env:OCX_TEST_PRIVATE_DIRECTORY,$acl)`; + const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { + env: { ...process.env, OCX_TEST_PRIVATE_DIRECTORY: path }, stdout: "pipe", stderr: "pipe", windowsHide: true, + }); + if (!result.success) throw new Error("Cannot create private Codex launch fixture"); +} From 5258769d4a3ec9024726cd9cce9de059deb26102 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:25:38 +0900 Subject: [PATCH 09/18] fix(cli): explain Codex credential withholding reasons Keep existing credential authorization and fail-closed namespace checks while reporting one fixed, value-free stderr diagnostic for each refusal category, including missing or invalid credentials and management-token collisions. Explain default ProgramData creation rights and provide an explicit elevated Windows PowerShell command to provision protected OpenAI and Codex namespaces. Document the accepted other-principal boundary and trusted same-user processes in the owning structure doc, all eight CLI-reference locales and integration guide. Cover diagnostic non-disclosure, actual credential-file reads and real Windows missing/writable/provisioned namespace controls in the existing shard. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 10 + .../content/docs/guides/codex-integration.md | 6 + .../content/docs/ja/reference/cli/agents.md | 10 + .../content/docs/ko/reference/cli/agents.md | 10 + .../src/content/docs/reference/cli/agents.md | 10 + .../content/docs/ru/reference/cli/agents.md | 10 + .../content/docs/tr/reference/cli/agents.md | 10 + .../docs/zh-cn/reference/cli/agents.md | 10 + .../docs/zh-tw/reference/cli/agents.md | 10 + .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-launch-auth.ts | 41 ++-- src/cli/codex-launch-diagnostics.ts | 42 ++++ src/cli/codex-launch-ownership.ts | 33 ++-- src/cli/codex-launcher.ts | 42 ++-- structure/cli-management.md | 4 +- .../codex-launcher-diagnostics.test.ts | 179 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/helpers/codex-launch-private-fixture.ts | 2 +- 18 files changed, 380 insertions(+), 51 deletions(-) create mode 100644 src/cli/codex-launch-diagnostics.ts create mode 100644 tests/codex-integration/codex-launcher-diagnostics.test.ts 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 41d3f29e096..1d63d258eb4 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -171,6 +171,16 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag ### `ocx codex [codex args...]` +La clé est fournie uniquement si la configuration et ses espaces de noms sont protégés contre les modifications par d’autres principaux ; l’utilisateur courant et les autorités privilégiées du système sont de confiance. Les processus du même utilisateur sont de confiance : un acteur disposant de vos droits d’écriture peut déjà lire votre fichier de clé OpenCodex. Le remplacement de configuration par ce même utilisateur entre vérification et démarrage natif est hors de ce modèle de menace ; le lanceur ne fige pas la configuration lors du transfert. + +Sur une installation Windows normale, les droits ProgramData par défaut permettent à d’autres utilisateurs de créer des dossiers. Les identifiants automatiques sont donc retenus même si `ProgramData\OpenAI\Codex` est absent. Le contrôle reste fermé et affiche une ligne stderr précise avec la commande suivante. Un administrateur peut protéger les deux dossiers dédiés par ACL (écriture SYSTEM/Administrators, lecture/exécution Users). Vérifiez d’abord la politique existante, puis exécutez dans **Windows PowerShell 5.1 avec élévation** ; la commande remplace les ACL des dossiers existants et refuse les points de réanalyse : + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +Le lanceur n’exécute jamais cette commande et ne modifie pas les ACL. Réessayez `ocx codex` après préparation. Les fichiers de configuration système existants empêchent toujours l’injection automatique et nécessitent un examen distinct. Chaque autre refus produit une courte ligne stderr précise : arguments/environnement non audités, propriété ou inspection du dossier/configuration, couches/consommateurs/transport, identifiant de service indisponible ou collision avec un identifiant de gestion. Aucun contenu, chemin de fichier de clé ou valeur secrète n’est affiché ; les identifiants explicitement exportés sont conservés. + Placez `--` avant une invite d’un seul mot pour éviter la sélection d’une sous-commande Codex inconnue. L’espace de configuration machine doit empêcher un autre utilisateur de créer les fichiers système absents. Un espace Windows ProgramData/OpenAI/Codex non protégé bloque l’injection sans créer de répertoire. macOS refuse les ACL étendues, même avec des bits de mode sûrs. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 99e8572fde9..b6642a0addd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1032,6 +1032,12 @@ its two modes: It never substitutes a Pool or provider credential for the assertion, and it never forwards or logs the assertion. Use `dual_header`, or turn the feature off, when Codex runs through opencodex. +### Launch with `ocx codex` + +The key is supplied only when the configuration and its namespaces are protected from other principals; the current user and privileged OS authorities are trusted. Same-user processes are trusted: an actor with your write access can already read your OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the launcher does not freeze configuration at handoff. + +On Windows, default ProgramData creation rights withhold the key even when `ProgramData\OpenAI\Codex` is absent. An administrator must provision protected OpenAI and Codex namespaces. The launcher prints the specific reason and exact provisioning command on stderr, and never changes ACLs itself. See [launcher diagnostics and the elevated Windows PowerShell 5.1 command](/reference/cli/agents/#ocx-codex-codex-args). Other refusals also produce one short specific stderr line without key values, key-file contents or key-file paths; explicit credentials are preserved. + ### Codex autostart shim Run `ocx codex-shim install` to install the optional launcher wrapper. It runs 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 6c670af6ed7..fb8b48f8f99 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -132,6 +132,16 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +キーは設定とその名前空間が他の主体による変更から保護されている場合にのみ渡します。現在の利用者と特権 OS 主体を信頼します。同じ利用者のプロセスは信頼対象です。その利用者の書き込み権限を持つ主体は、既にその利用者の OpenCodex キーファイルも読めます。検証からネイティブ起動までの同一利用者による設定置換はこの脅威モデルの対象外で、引き渡し時に設定を固定しません。 + +通常の Windows の既定 ProgramData 権限では他の利用者がディレクトリを作成できます。そのため `ProgramData\OpenAI\Codex` がなくても自動認証情報を保留します。判定を緩めず、以下のコマンドを含む具体的な stderr 診断を一行表示します。管理者は両専用ディレクトリを保護 ACL(SYSTEM/Administrators は書き込み、Users は読み取り/実行)で準備できます。既存のポリシーを確認してから **管理者権限の Windows PowerShell 5.1** で実行してください。既存ディレクトリの ACL を置き換え、reparse point は拒否します: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +ランチャー自身はこのコマンドや ACL 変更を実行しません。準備後に `ocx codex` を再試行してください。既存のシステム設定ファイルは引き続き自動注入を阻止し、別途確認が必要です。他の保留理由も、未監査の引数/環境、ホーム/設定の所有権や検査、設定層/消費者/転送、サービス認証情報の欠落、管理認証情報との衝突を区別して stderr 一行で表示します。キー値、キーファイルの内容やパスは表示せず、明示した認証情報は保持します。 + 単語一つのプロンプトの前に `--` を置き、未知の Codex サブコマンド選択を防いでください。 設定ファイルがなくても、machine 設定パスに他の利用者が作成できない必要があります。Windows ProgramData/OpenAI/Codex の保護を確認できなければ注入を保留し、ディレクトリを作成しません。macOS は mode が安全でも拡張 ACL があれば拒否します。 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 a4492d586af..a6163151a46 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -164,6 +164,16 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 ### `ocx codex [codex args...]` +키는 설정과 해당 경로가 다른 보안 주체의 변경으로부터 보호될 때만 전달합니다. 현재 사용자와 권한 있는 OS 주체는 신뢰합니다. 같은 사용자로 실행되는 프로세스는 신뢰 대상입니다. 사용자의 쓰기 권한을 가진 행위자는 사용자의 OpenCodex 키 파일도 이미 읽을 수 있습니다. 검증과 네이티브 시작 사이에 같은 사용자가 설정을 교체하는 시나리오는 이 위협 모델의 범위 밖이며, 런처는 인계 시 설정을 고정하지 않습니다. + +일반적인 Windows 설치의 기본 ProgramData 권한은 다른 사용자에게 디렉터리 생성 권한을 줍니다. 따라서 `ProgramData\OpenAI\Codex`가 없어도 자동 자격 증명을 보류합니다. 검사를 완화하지 않고 아래 명령을 포함한 구체적인 stderr 진단 한 줄을 출력합니다. 관리자는 두 전용 디렉터리를 보호된 ACL(SYSTEM/Administrators 쓰기, Users 읽기/실행)로 준비할 수 있습니다. 기존 디렉터리 정책을 먼저 검토하고 **관리자 권한 Windows PowerShell 5.1**에서 실행하세요. 기존 디렉터리 ACL은 교체하며 reparse point는 거부합니다: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +런처가 이 명령을 실행하거나 ACL을 직접 변경하지는 않습니다. 준비 후 `ocx codex`를 다시 실행하세요. 기존 시스템 설정 파일은 여전히 자동 주입을 막으며 별도 검토가 필요합니다. 다른 모든 보류 사유도 짧고 구체적인 stderr 한 줄로 안내합니다. 검증되지 않은 인자·환경, 홈/설정 소유권·검사, 설정층·소비자·전송, 서비스 자격 증명 부재, 관리 자격 증명 충돌을 구분합니다. 키 값·키 파일 내용·키 파일 경로는 출력하지 않으며 명시적인 사용자 자격 증명은 유지합니다. + 한 단어 프롬프트 앞에는 `--`를 넣어 알 수 없는 Codex 하위 명령 선택을 막으세요. 설정 파일이 없어도 machine 설정 경로에 다른 사용자가 파일을 만들 수 없어야 합니다. Windows ProgramData/OpenAI/Codex 경로의 보호를 증명할 수 없으면 자동 토큰을 보류하며 디렉터리를 만들지 않습니다. macOS에서는 mode 비트가 안전해도 확장 ACL이 있으면 거부합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index a4971f52719..9174a574fb6 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -965,6 +965,16 @@ before choosing it. Rendered models are not proof of native application. ### `ocx codex [codex args...]` +The key is supplied only when the configuration and its namespaces are protected from other principals; the current user and privileged OS authorities are trusted. Same-user processes are trusted: an actor with your write access can already read your OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the launcher does not freeze configuration at handoff. + +On a normal Windows installation, default ProgramData permissions allow other users to create directories. Automatic credentials are therefore withheld even when `ProgramData\OpenAI\Codex` is absent. The launcher keeps this check closed and prints one specific stderr diagnostic, including the command below. An administrator can provision both dedicated directories with protected ACLs (SYSTEM/Administrators write, Users read/execute). Review existing directory policy first, then run this in **elevated Windows PowerShell 5.1**; it replaces the ACL on existing directories and refuses reparse points: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +The launcher never runs this command or changes ACLs itself. Retry `ocx codex` after provisioning. Existing system configuration files still withhold automatic credentials and require separate review. Every other withholding reason has one short specific stderr line, including unaudited arguments/environment, home/config ownership or inspection, configuration layers/consumers/transport, an unavailable service credential, and a management-credential collision. Diagnostics include no key values, key-file contents or key-file paths. Explicit user credentials remain unchanged. + Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS refuses extended filesystem ACLs even with safe mode bits. 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 0d3a7260743..fa769337e03 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -153,6 +153,16 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +Ключ передаётся только когда конфигурация и её пространства имён защищены от изменения другими субъектами; текущий пользователь и привилегированные субъекты ОС считаются доверенными. Процессы того же пользователя доверенные: субъект с вашими правами записи уже может прочитать ваш файл ключа OpenCodex. Замена конфигурации тем же пользователем между проверкой и нативным запуском находится вне этой модели угроз; конфигурация при передаче управления не фиксируется. + +При обычной установке Windows права ProgramData по умолчанию позволяют другим пользователям создавать каталоги. Поэтому автоматические учётные данные не передаются даже при отсутствии `ProgramData\OpenAI\Codex`. Проверка остаётся закрытой, а stderr выводит одну конкретную строку с командой ниже. Администратор может защитить оба выделенных каталога ACL (запись SYSTEM/Administrators, чтение/выполнение Users). Сначала проверьте существующую политику, затем выполните в **Windows PowerShell 5.1 с повышенными правами**; команда заменяет ACL существующих каталогов и отклоняет точки повторной обработки: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +Средство запуска само не выполняет команду и не меняет ACL. После подготовки повторите `ocx codex`. Существующие системные файлы конфигурации по-прежнему блокируют автоматическую передачу и требуют отдельной проверки. Каждая другая причина даёт короткую конкретную строку stderr: непроверенные аргументы/окружение, владение или проверка домашнего каталога/конфигурации, слои/потребители/транспорт, отсутствие служебных учётных данных или совпадение с административными. Значения ключей, содержимое и пути файлов ключей не выводятся; явно экспортированные данные сохраняются. + Перед подсказкой из одного слова укажите `--`, чтобы исключить выбор неизвестной подкоманды Codex. Пространство машинной конфигурации должно запрещать другим пользователям создание отсутствующих системных файлов. Непроверенный Windows ProgramData/OpenAI/Codex запрещает передачу токена; каталог не создаётся. macOS запрещает расширенные ACL даже при безопасных битах режима. 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 c932b5a301b..636b1b11b70 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -189,6 +189,16 @@ eder. Claude Code ayarları için `ocx claude config ...` kullanın ### `ocx codex [codex args...]` +Anahtar yalnızca yapılandırma ve ad alanları diğer güvenlik sorumlularının değişikliklerine karşı korunduğunda sağlanır; geçerli kullanıcı ve ayrıcalıklı işletim sistemi yetkilileri güvenilirdir. Aynı kullanıcının süreçlerine güvenilir: yazma yetkinize sahip bir aktör OpenCodex anahtar dosyanızı zaten okuyabilir. Doğrulama ile yerel başlatma arasında aynı kullanıcının yapılandırmayı değiştirmesi bu tehdit modelinin kapsamı dışındadır; başlatıcı aktarım sırasında yapılandırmayı dondurmaz. + +Normal Windows kurulumunda varsayılan ProgramData izinleri diğer kullanıcıların dizin oluşturmasına izin verir. Bu nedenle `ProgramData\OpenAI\Codex` yokken bile otomatik kimlik bilgileri verilmez. Denetim kapalı kalır ve aşağıdaki komutu içeren tek, belirli bir stderr tanısı gösterilir. Yönetici iki özel dizini korumalı ACL ile hazırlayabilir (SYSTEM/Administrators yazma, Users okuma/çalıştırma). Önce mevcut dizin politikasını inceleyin, sonra **yükseltilmiş Windows PowerShell 5.1** içinde çalıştırın; komut mevcut dizinlerin ACL’lerini değiştirir ve reparse point öğelerini reddeder: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +Başlatıcı bu komutu kendisi çalıştırmaz ve ACL değiştirmez. Hazırlıktan sonra `ocx codex` komutunu yeniden deneyin. Mevcut sistem yapılandırma dosyaları otomatik aktarımı hâlâ engeller ve ayrıca incelenmelidir. Diğer her engelleme nedeni de kısa ve belirli tek stderr satırı üretir: denetlenmemiş argüman/ortam, ana dizin/yapılandırma sahipliği veya incelemesi, katmanlar/tüketiciler/taşıma, kullanılamayan hizmet kimlik bilgisi veya yönetim kimlik bilgisi çakışması. Anahtar değerleri, anahtar dosyası içeriği ve yolları yazdırılmaz; açıkça dışa aktarılan kimlik bilgileri korunur. + Bilinmeyen bir Codex alt komutu seçilmesini önlemek için tek sözcüklü istemden önce `--` kullanın. Makine yapılandırma alanı, diğer kullanıcıların eksik sistem dosyalarını oluşturmasını da engellemelidir. Korunmayan Windows ProgramData/OpenAI/Codex alanı otomatik belirteci engeller; dizin oluşturulmaz. macOS güvenli mod bitlerinde bile genişletilmiş ACL’leri reddeder. 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 978847ba1a6..2bf32a70395 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 @@ -136,6 +136,16 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx codex [codex args...]` +仅当配置及其命名空间受到保护、其他主体无法修改时才提供密钥;当前用户和特权 OS 主体被信任。同一用户的进程被信任:拥有该用户写入权限的主体已经能读取该用户的 OpenCodex 密钥文件。验证与原生启动之间由同一用户替换配置不在此威胁模型内,启动器不会在交接时冻结配置。 + +正常 Windows 安装的默认 ProgramData 权限允许其他用户创建目录。因此即使 `ProgramData\OpenAI\Codex` 不存在,也会暂缓自动凭据。检查保持拒绝,并向 stderr 输出一条包含以下命令的具体诊断。管理员可为两个专用目录配置受保护 ACL(SYSTEM/Administrators 可写,Users 只读/执行)。请先审核现有目录策略,再在 **提升权限的 Windows PowerShell 5.1** 中运行;命令会替换现有目录的 ACL,并拒绝 reparse point: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +启动器不会自行运行命令或修改 ACL。配置后重试 `ocx codex`。已有系统配置文件仍会阻止自动注入,需另行审核。其他暂缓原因也各有一条简短明确的 stderr 提示,区分未审核的参数/环境、主目录/配置所有权或检查、配置层/消费者/传输、服务凭据缺失以及管理凭据冲突。不会打印密钥值、密钥文件内容或路径;明确导出的用户凭据保持不变。 + 单词提示前请加 `--`,以免选择未知的 Codex 子命令。 即使系统配置文件不存在,也必须阻止其他用户创建这些文件。无法验证 Windows ProgramData/OpenAI/Codex 路径保护时不自动注入令牌,也不创建目录。macOS 即使 mode 位安全,也会拒绝扩展 ACL。 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 70f4ba62ca1..674c4f62986 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 @@ -133,6 +133,16 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ### `ocx codex [codex args...]` +只有設定及其命名空間受到保護、其他主體無法修改時才提供金鑰;目前使用者和特權 OS 主體受到信任。同一使用者的程序受到信任:具有該使用者寫入權限的主體已能讀取該使用者的 OpenCodex 金鑰檔案。驗證與原生啟動之間由同一使用者替換設定不在此威脅模型內,啟動器不會在交接時凍結設定。 + +一般 Windows 安裝的預設 ProgramData 權限允許其他使用者建立目錄。因此即使 `ProgramData\OpenAI\Codex` 不存在,也會暫緩自動認證資料。檢查維持拒絕,並向 stderr 輸出一行包含以下命令的具體診斷。管理員可為兩個專用目錄配置受保護 ACL(SYSTEM/Administrators 可寫入,Users 僅讀取/執行)。先審查現有目錄原則,再於 **提升權限的 Windows PowerShell 5.1** 執行;命令會取代現有目錄 ACL,並拒絕 reparse point: + +```powershell +& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } +``` + +啟動器不會自行執行命令或變更 ACL。完成後重試 `ocx codex`。既有系統設定檔仍會阻止自動注入,須另行審查。其他暫緩原因也各有一行簡短明確的 stderr 訊息,區分未審核的引數/環境、主目錄/設定擁有權或檢查、設定層/消費者/傳輸、服務認證資料缺失及管理認證資料衝突。不會列印金鑰值、金鑰檔案內容或路徑;明確匯出的使用者認證資料維持不變。 + 單字提示前請加 `--`,以免選取未知的 Codex 子命令。 即使系統設定檔不存在,也必須阻止其他使用者建立這些檔案。無法驗證 Windows ProgramData/OpenAI/Codex 路徑保護時不自動注入權杖,也不建立目錄。macOS 即使 mode 位元安全,也會拒絕擴充 ACL。 diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index b6872c59f76..0d7a64c3174 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { + "codex-launcher-diagnostics.test.ts": "codex-integration", "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 0ebe461bb9f..980f3b40898 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -9,6 +9,7 @@ import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../cod import { readClientConnectionState } from "../client/state"; import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; import { codexLaunchHomeIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; +import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; /** Resolve once; the credential verifier and child must use this exact directory. */ export function resolveCodexLaunchHome(env: Record): string | null { @@ -27,47 +28,49 @@ export function resolveCodexLaunchHome(env: Record): } /** Config classification is a startup hint; credentials need an independently bound destination. */ -export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string): boolean { +export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string, report?: CodexTokenRefusalReporter): boolean { + const refuse = (reason: CodexTokenRefusal): false => { report?.(reason); return false; }; try { - if (!codexLaunchHomeIsOwned(home)) return false; - if (!codexLaunchSystemNamespaceIsSafe(systemNamespaceDirectory)) return false; + if (!codexLaunchHomeIsOwned(home)) return refuse("ownership"); + if (!codexLaunchSystemNamespaceIsSafe(systemNamespaceDirectory, report)) return false; const additionalLayers = [join(home, "auth.json"), join(home, "managed_config.toml"), join(home, "requirements.toml")]; if (process.platform !== "win32") additionalLayers.push("/etc/codex/config.toml", "/etc/codex/managed_config.toml", "/etc/codex/requirements.toml"); for (const path of additionalLayers) { - try { lstatSync(path); return false; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + try { lstatSync(path); return refuse("additionalLayer"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } } const diagnostics = readConfigDiagnostics(); - if (diagnostics.error || diagnostics.source === "fallback") return false; + if (diagnostics.error || diagnostics.source === "fallback") return refuse("installationConfig"); const cfg = diagnostics.config; let target = standaloneCodexRoutingTarget(cfg.port, cfg); if (cfg.runtimeRole === "client") { // Direct clients bind to persisted connection state; link clients use an authless relay. const connection = readClientConnectionState(); - if (connection.kind !== "connected" || connection.value.transport === "link") return false; + if (connection.kind !== "connected" || connection.value.transport === "link") return refuse("connection"); target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); } const url = new URL(target.baseUrl); - if (!target.requiresAdmissionToken) return false; - if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return false; + if (!target.requiresAdmissionToken) return refuse("admission"); + if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return refuse("loopback"); const content = readBoundedCodexConfig(join(home, "config.toml")); - if (content === null) return false; + if (content === null) return refuse("configRead"); const config = Bun.TOML.parse(content) as Record; - if (codexHasOtherTokenConsumers(config) || !codexTokenConfigShapeIsSafe(config)) return false; + if (codexHasOtherTokenConsumers(config)) return refuse("consumers"); + if (!codexTokenConfigShapeIsSafe(config)) return refuse("configShape"); // This native feature resolves OS/PAC routes without honoring NO_PROXY. const features = config.features as Record | undefined; - if (features?.respect_system_proxy !== undefined && features.respect_system_proxy !== false) return false; + if (features?.respect_system_proxy !== undefined && features.respect_system_proxy !== false) return refuse("systemProxy"); // Codex layers project configs and profiles after the home. Without proving those layers, // conservatively leave authentication to the user's environment. - if (config.profile !== undefined || config.model_provider !== "opencodex") return false; + if (config.profile !== undefined || config.model_provider !== "opencodex") return refuse("provider"); for (const key of ["openai_base_url", "experimental_realtime_ws_base_url"]) { - if (config[key] !== undefined && config[key] !== target.baseUrl) return false; + if (config[key] !== undefined && config[key] !== target.baseUrl) return refuse("rootUrl"); } for (let directory = process.cwd(); ; directory = dirname(directory)) { const projectConfig = join(directory, ".codex", "config.toml"); if (projectConfig !== join(home, "config.toml")) { - try { lstatSync(projectConfig); return false; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + try { lstatSync(projectConfig); return refuse("project"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } } // A nested .git need not be Codex's root: custom project_root_markers can select // an outer workspace. Without proving that selection, inspect every ancestor. @@ -78,9 +81,9 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac const expected = (Bun.TOML.parse(buildProviderTableBlockForTarget(target, websocketsEnabled(cfg))) as { model_providers: { opencodex: Record } }).model_providers.opencodex; // Display names are cosmetic; every transport and credential-bearing field must match // the block emitted by our writer for this installation/connection. - if (!provider || typeof provider.name !== "string") return false; + if (!provider || typeof provider.name !== "string") return refuse("providerTransport"); const { name: _name, ...transport } = provider; const { name: _expectedName, ...expectedTransport } = expected; - return isDeepStrictEqual(transport, expectedTransport); - } catch { return false; } + return isDeepStrictEqual(transport, expectedTransport) || refuse("providerTransport"); + } catch { return refuse("inspection"); } } diff --git a/src/cli/codex-launch-diagnostics.ts b/src/cli/codex-launch-diagnostics.ts new file mode 100644 index 00000000000..65f36cf738b --- /dev/null +++ b/src/cli/codex-launch-diagnostics.ts @@ -0,0 +1,42 @@ +/** Fixed reasons only: never interpolate configuration, exceptions or credential paths. */ +export const CODEX_TOKEN_REFUSALS = { + args: "arguments are outside the audited allowlist", + environment: "environment contains a proxy or unaudited credential control", + home: "Codex home cannot be resolved unambiguously", + ownership: "Codex home/config ownership or mutation protection could not be verified", + systemConfig: "machine Codex configuration adds an unaudited system layer", + systemNamespace: "machine Codex namespace protection could not be verified", + systemNamespaceWritable: "machine Codex namespace allows other principals to create or replace configuration", + additionalLayer: "home or system configuration adds an unaudited authentication/managed/requirements layer", + layerInspection: "additional configuration layers could not be inspected", + installationConfig: "OpenCodex configuration could not be verified", + connection: "client connection is unavailable or uses an authless link relay", + admission: "configured route does not require an admission credential", + loopback: "configured destination is not an HTTP loopback listener", + configRead: "Codex configuration could not be read within the inspection limit", + consumers: "configuration has additional credential consumers", + configShape: "configuration contains unaudited keys or values", + systemProxy: "configuration enables an unaudited system proxy transport", + provider: "configuration selects a profile or a different provider", + rootUrl: "configuration overrides the managed loopback URL", + project: "project configuration adds an unaudited layer", + providerTransport: "provider transport does not match the managed loopback provider", + inspection: "credential destination inspection failed", + destination: "credential destination could not be verified", + credential: "installation service credential is unavailable or invalid", + collision: "service credential matches a management credential", +} as const; +export type CodexTokenRefusal = keyof typeof CODEX_TOKEN_REFUSALS; +export type CodexTokenRefusalReporter = (reason: CodexTokenRefusal) => void; + +// Run only by an administrator in Windows PowerShell 5.1. Create missing +// directories with the protected DACL already installed; also protect existing +// dedicated directories. SYSTEM/Administrators write; Users only read/traverse. +export const CODEX_WINDOWS_NAMESPACE_COMMAND = String.raw`& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } }`; + +export function codexTokenWithheldDiagnostic(reason: CodexTokenRefusal): string { + const remedy = process.platform === "win32" && reason === "systemNamespaceWritable" + ? `; default ProgramData creation rights are insufficient even when OpenAI\\Codex is absent. Ask an administrator to review existing directory policy and run in elevated Windows PowerShell 5.1: ${CODEX_WINDOWS_NAMESPACE_COMMAND}` + : ". Launching without a fresh token."; + return `OpenCodex: automatic service token withheld; ${CODEX_TOKEN_REFUSALS[reason]}${remedy}`; +} diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts index e625a9de884..3657af7964f 100644 --- a/src/cli/codex-launch-ownership.ts +++ b/src/cli/codex-launch-ownership.ts @@ -1,6 +1,7 @@ import { lstatSync } from "node:fs"; import { dirname, join } from "node:path"; import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; +import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; // Paths are environment data, never interpolated PowerShell source. The token is // not read until this read-only subprocess and the config checks have succeeded. @@ -21,7 +22,7 @@ try { $systemDir=[System.IO.Path]::Combine($programData,'OpenAI','Codex') } foreach ($file in @('config.toml','requirements.toml','managed_config.toml')) { - try { $null=[System.IO.File]::GetAttributes([System.IO.Path]::Combine($systemDir,$file)); exit 1 } + try { $null=[System.IO.File]::GetAttributes([System.IO.Path]::Combine($systemDir,$file)); exit 3 } catch [System.IO.FileNotFoundException] { } catch [System.IO.DirectoryNotFoundException] { } } @@ -61,7 +62,10 @@ try { $mask=[int64]$ace.AccessMask -band 4294967295 $mutation=1343029590 if ($i -ge 2 -or ($exactOwners -eq 0 -and $i -ge 1)) { $mutation=1343029312 } - if (($mask -band $mutation) -ne 0) { exit 1 } + if (($mask -band $mutation) -ne 0) { + if ($exactOwners -eq 0) { exit 2 } + exit 1 + } } } exit 0 @@ -69,7 +73,7 @@ try { `; const ENCODED_SCRIPT = Buffer.from(SCRIPT, "utf16le").toString("base64"); -function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirectory?: string): boolean { +function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirectory?: string, report?: CodexTokenRefusalReporter): boolean { // Remove case aliases before constructing a Windows environment block. These // private protocol inputs must come from this call, never the caller's shell. const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => @@ -78,7 +82,9 @@ function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirector resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT, ], { env: { ...inherited, OCX_CODEX_OWNED_HOME: home, OCX_CODEX_NAMESPACE_CHECK: namespace ? "1" : "0", OCX_CODEX_NAMESPACE_FIXTURE: fixtureDirectory }, stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000, windowsHide: true }); - return result.success && !result.exitedDueToTimeout; + const safe = result.success && !result.exitedDueToTimeout; + if (!safe && namespace) report?.(result.exitCode === 2 ? "systemNamespaceWritable" : result.exitCode === 3 ? "systemConfig" : "systemNamespace"); + return safe; } /** A missing system layer is safe only if another user cannot create it later. */ @@ -87,31 +93,32 @@ export function codexPosixSystemNamespace(platform: NodeJS.Platform = process.pl return platform === "darwin" ? "/private/etc/codex" : "/etc/codex"; } -export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string): boolean { +export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string, report?: CodexTokenRefusalReporter): boolean { + const refuse = (reason: CodexTokenRefusal): false => { report?.(reason); return false; }; try { - if (process.platform === "win32") return windowsOwnershipCheck("", true, fixtureDirectory); + if (process.platform === "win32") return windowsOwnershipCheck("", true, fixtureDirectory, report); let path = fixtureDirectory ?? codexPosixSystemNamespace(); for (const file of ["config.toml", "requirements.toml", "managed_config.toml"]) { - try { lstatSync(join(path, file)); return false; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } + try { lstatSync(join(path, file)); return refuse("systemConfig"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("systemNamespace"); } } for (;;) { try { lstatSync(path); break; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } - if (dirname(path) === path) return false; + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("systemNamespace"); } + if (dirname(path) === path) return refuse("systemNamespace"); path = dirname(path); } const paths: string[] = []; for (;;) { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.uid !== process.getuid?.() && stat.uid !== 0) - || ((stat.mode & 0o022) !== 0 && (paths.length === 0 || (stat.mode & 0o1000) === 0))) return false; + || ((stat.mode & 0o022) !== 0 && (paths.length === 0 || (stat.mode & 0o1000) === 0))) return refuse("systemNamespace"); paths.push(path); if (dirname(path) === path) break; path = dirname(path); } - return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); - } catch { return false; } + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths) || refuse("systemNamespace"); + } catch { return refuse("systemNamespace"); } } /** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index d3ce12ebc42..2d98e4d9507 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -37,6 +37,7 @@ import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; +import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; export interface CodexLaunchEnv { [key: string]: string | undefined; @@ -157,7 +158,7 @@ export type CodexLaunchEnvDeps = { args?: readonly string[]; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; - credentialDestination?: (home: string) => boolean; + credentialDestination?: (home: string, report?: CodexTokenRefusalReporter) => boolean; }; /** Read the client admission credential from this installation's service-token file. */ @@ -179,29 +180,36 @@ export function buildCodexLaunchEnv( const env = { ...base }; if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { - const withhold = (): CodexLaunchEnv => { - console.error("OpenCodex: automatic service token withheld; Codex arguments, environment, config consumers or ownership could not be verified. Launching without a fresh token."); + const withhold = (reason: CodexTokenRefusal): CodexLaunchEnv => { + console.error(codexTokenWithheldDiagnostic(reason)); return env; }; - if (!codexTokenArgsAreSafe(deps.args ?? []) || !codexTokenEnvironmentIsSafe(env)) return withhold(); + if (!codexTokenArgsAreSafe(deps.args ?? [])) return withhold("args"); + if (!codexTokenEnvironmentIsSafe(env)) return withhold("environment"); // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. // Without proving bypass, preserve the user's transport but never load a secret. const home = resolveCodexLaunchHome(env); - if (!home || !(deps.credentialDestination ?? codexCredentialDestinationIsTrusted)(home)) return withhold(); + if (!home) return withhold("home"); + let refusal: CodexTokenRefusal = "destination"; + const report: CodexTokenRefusalReporter = reason => { refusal = reason; }; + const trusted = deps.credentialDestination + ? deps.credentialDestination(home, report) + : codexCredentialDestinationIsTrusted(home, undefined, report); + if (!trusted) return withhold(refusal); const token = (deps.serviceToken ?? defaultServiceToken)(env); - if (token && !tokenCollidesWithAdmin(token, env)) { - // Reqwest's default transport can discover OS proxies even without proxy - // env. Bind both bypass spellings to the allowed loopback hosts, preserving - // existing entries and removing conflicting case aliases before handoff. - const bypass = ["127.0.0.1", "::1"]; - for (const key of Object.keys(env)) if (/^no_proxy$/i.test(key)) { - if (env[key]) bypass.push(env[key]!); - delete env[key]; - } - env.NO_PROXY = env.no_proxy = bypass.join(","); - env.CODEX_HOME = home; - env.OPENCODEX_API_AUTH_TOKEN = token; + if (!token) return withhold("credential"); + if (tokenCollidesWithAdmin(token, env)) return withhold("collision"); + // Reqwest's default transport can discover OS proxies even without proxy + // env. Bind both bypass spellings to the allowed loopback hosts, preserving + // existing entries and removing conflicting case aliases before handoff. + const bypass = ["127.0.0.1", "::1"]; + for (const key of Object.keys(env)) if (/^no_proxy$/i.test(key)) { + if (env[key]) bypass.push(env[key]!); + delete env[key]; } + env.NO_PROXY = env.no_proxy = bypass.join(","); + env.CODEX_HOME = home; + env.OPENCODEX_API_AUTH_TOKEN = token; } return env; } diff --git a/structure/cli-management.md b/structure/cli-management.md index db93b284345..15b2cca5c6a 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -23,7 +23,9 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl `src/cli/codex-launch-policy.ts` authorizes fresh credentials by explicit argument, environment and configuration allowlists. Only interactive and `exec` modes qualify; restored sessions and every config/profile/provider-selection override withhold fresh credentials. The switches are `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search`; one-value options are `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i`. Short clusters and missing values refuse. Only model/provider selection, model reasoning/verbosity strings, approval/sandbox strings, the matching root URL and disabled system-proxy feature are accepted in the home configuration. Alternate providers, MCP servers (including stdio inheritance), hooks and unknown keys refuse. Every token-variable reference outside the verified provider is refused, even in an unselected profile. Nonempty unknown CODEX/OpenAI/Azure environment controls refuse. Additional authentication, system, managed, requirements and project layers are not authorized. Refusals preserve argv and explicit credentials and print a value-free stderr note. -`src/cli/codex-launch-ownership.ts` checks current-user ownership of the canonical home and config, rejects POSIX group/other writes and Windows DACL mutation grants outside current user/SYSTEM/Administrators, and rejects unsafe ancestor replacement rights. Windows system paths come from the CommonApplicationData known folder; ancestor ownership also permits TrustedInstaller. macOS checks forced CoreFoundation preferences. Read/inspection errors deny. These controls exclude other-user writers, not same-user or privileged processes; they do not claim immutable startup configuration. Regression coverage is in `tests/codex-integration/codex-launcher-admission.test.ts`. +`src/cli/codex-launch-ownership.ts` checks current-user ownership of the canonical home and config, rejects POSIX group/other writes and Windows DACL mutation grants outside current user/SYSTEM/Administrators, and rejects unsafe ancestor replacement rights. Windows system paths come from the CommonApplicationData known folder; ancestor ownership also permits TrustedInstaller. macOS checks forced CoreFoundation preferences. Read/inspection errors deny. The key is supplied only when configuration and its namespaces are protected from other principals; the current user, SYSTEM/Administrators and the documented OS authorities are trusted. Same-user processes are trusted: an actor with this user's write access can already read this user's OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the check does not establish immutable startup configuration or isolate privileged processes. Regression coverage is in `tests/codex-integration/codex-launcher-admission.test.ts`. + +`src/cli/codex-launch-diagnostics.ts` maps each fresh-key withholding reason to one fixed stderr line without credential values, key-file paths, config contents or exception text. Windows namespace creation/mutation rights have a specific administrator remedy: default ProgramData rights remain unsafe even when OpenAI/Codex is absent. The printed elevated Windows PowerShell 5.1 command creates/protects both OpenAI and Codex with a fresh protected DACL per operation (SYSTEM/Administrators full control, Users read/execute), refuses existing reparse points, and changes no ACL automatically. Existing system configuration still refuses; provisioning only satisfies the namespace boundary. Tests in `tests/codex-integration/codex-launcher-diagnostics.test.ts` cover one-line non-disclosure and real missing/writable/protected Windows namespace controls. Public provisioning instructions live in the [CLI reference](../docs-site/src/content/docs/reference/cli/agents.md#ocx-codex-codex-args). For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts new file mode 100644 index 00000000000..9686c110eb3 --- /dev/null +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCodexLaunchEnv, type CodexLaunchEnvDeps } from "../../src/cli/codex-launcher"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { CODEX_WINDOWS_NAMESPACE_COMMAND } from "../../src/cli/codex-launch-diagnostics"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { makeCodexLaunchFixturePrivate } from "../helpers/codex-launch-private-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const secret = "ocx_fixture_diagnostic_secret", contents = "fixture-key-file-content-never-log"; + +function capture(base: Record, deps: CodexLaunchEnvDeps) { + const notes: string[] = [], original = console.error; + console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; + try { return { env: buildCodexLaunchEnv(base, deps), notes }; } + finally { console.error = original; } +} + +function diagnostic(notes: string[], reason: string) { + expect(notes).toHaveLength(1); + expect(notes[0]).toContain("automatic service token withheld; " + reason); + expect(notes[0]).not.toMatch(/[\r\n]/); + for (const forbidden of [secret, contents, "service-api-token", "admin-api-token"]) expect(notes[0]).not.toContain(forbidden); +} + +describe("Codex credential withholding diagnostics", () => { + for (const [base, deps, reason] of [ + [{}, { args: ["--oss"] }, "arguments are outside the audited allowlist"], + [{ HTTPS_PROXY: contents }, {}, "environment contains a proxy or unaudited credential control"], + [{ CODEX_HOME: "" }, {}, "Codex home cannot be resolved unambiguously"], + ] as const) test(reason, () => { + let reads = 0; + const result = capture(base, { routingKind: "opencodex-local", ...deps, serviceToken: () => { reads++; return secret; } }); + diagnostic(result.notes, reason); + expect(reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + }); + + test("missing and colliding service credentials have distinct safe reasons", () => { + for (const [token, extra, reason] of [ + [null, {}, "installation service credential is unavailable or invalid"], + [secret, { OPENCODEX_ADMIN_AUTH_TOKEN: secret }, "service credential matches a management credential"], + ] as const) { + const result = capture({ CODEX_HOME: process.cwd(), ...extra }, { credentialDestination: () => true, serviceToken: () => token }); + diagnostic(result.notes, reason); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } + }); + + test("explicit credentials and passthrough launches do not warn or read", () => { + const serviceToken = () => { throw new Error("must not read"); }; + for (const result of [ + capture({ OPENCODEX_API_AUTH_TOKEN: secret }, { args: ["--oss"], serviceToken }), + capture({}, { routingKind: "native", serviceToken }), + ]) expect(result.notes).toEqual([]); + }); + + test.skipIf(process.platform !== "win32")("missing or writable Windows namespace explains admin provisioning, which restores admission", () => { + const root = mkdtempSync(join(process.env.SystemRoot ?? "C:/Windows", "Temp", "ocx-namespace-diagnostic-")); + makeCodexLaunchFixturePrivate(root); + const home = join(root, "codex"), ocx = join(root, "ocx"), machine = join(root, "machine"); + for (const path of [home, ocx, machine]) mkdirSync(path); + const system = join(machine, "OpenAI", "Codex"), prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; + process.chdir(root); + const cfg = { port: 12199, hostname: "0.0.0.0" }; + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + writeFileSync(join(home, "config.toml"), 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg))); + const ps = (script: string) => { + const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from("$ErrorActionPreference='Stop'\n" + script, "utf16le").toString("base64")], { + env: { ...process.env, OCX_TEST_MACHINE_ROOT: machine, OCX_TEST_NAMESPACE: system }, stdout: "pipe", stderr: "pipe", windowsHide: true, + }); + expect(result.exitCode).toBe(0); + }; + const grantCreation = String.raw`$p=$env:OCX_TEST_MACHINE_ROOT +$acl=[System.IO.Directory]::GetAccessControl($p) +$rule=[System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[System.Security.AccessControl.FileSystemRights]::CreateDirectories,[System.Security.AccessControl.AccessControlType]::Allow) +$acl.AddAccessRule($rule) +[System.IO.Directory]::SetAccessControl($p,$acl)`; + let reads = 0; + const launch = () => capture({ CODEX_HOME: home }, { + credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + serviceToken: () => { reads++; return secret; }, + }); + try { + ps(grantCreation); + // Exercise the documented command in scratch only. The fixture user stands + // in for Administrators so this test also runs without an elevated token. + const provision = CODEX_WINDOWS_NAMESPACE_COMMAND + .replace("[Environment]::GetFolderPath('CommonApplicationData')", "$env:OCX_TEST_MACHINE_ROOT") + .replace("if ([System.IO.Directory]::Exists", String.raw`$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $acl.SetOwner($user); $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)); if ([System.IO.Directory]::Exists`); + for (const existing of [false, true]) { + if (existing) ps(String.raw`[System.IO.Directory]::CreateDirectory($env:OCX_TEST_NAMESPACE) | Out-Null +$p=$env:OCX_TEST_NAMESPACE +$acl=[System.IO.Directory]::GetAccessControl($p) +$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[System.Security.AccessControl.FileSystemRights]::CreateFiles,[System.Security.AccessControl.AccessControlType]::Allow)) +[System.IO.Directory]::SetAccessControl($p,$acl)`); + const result = launch(); + diagnostic(result.notes, "machine Codex namespace allows other principals to create or replace configuration"); + expect(result.notes[0]).toContain("default ProgramData creation rights"); + expect(result.notes[0]).toContain("elevated Windows PowerShell 5.1: " + CODEX_WINDOWS_NAMESPACE_COMMAND); + expect(result.notes[0]).not.toContain(root); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(reads).toBe(existing ? 1 : 0); + ps(provision); + const protectedResult = launch(); + expect(protectedResult.notes).toEqual([]); + expect(protectedResult.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); + } + expect(reads).toBe(2); + } finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } + }); + + test("configuration reasons reach stderr without file contents or credential paths", () => { + const root = mkdtempSync(join(process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(), "ocx-diagnostic-")); + makeCodexLaunchFixturePrivate(root); + const home = join(root, "codex"), ocx = join(root, "ocx"), system = join(root, "system"); + for (const path of [home, ocx, system]) mkdirSync(path); + const cfg = { port: 12199, hostname: "0.0.0.0" }, prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; + process.chdir(root); + const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + try { + const keyFile = join(ocx, "service-api-token"); + const keyRead = (extra = {}) => capture({ CODEX_HOME: home, ...extra }, { credentialDestination: () => true }); + diagnostic(keyRead().notes, "installation service credential is unavailable or invalid"); + writeFileSync(keyFile, contents + "\n" + secret); + const malformed = keyRead(); + diagnostic(malformed.notes, "installation service credential is unavailable or invalid"); + expect(malformed.notes[0]).not.toContain(keyFile); + expect(malformed.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + writeFileSync(keyFile, secret + "\n"); + const collision = keyRead({ OPENCODEX_ADMIN_AUTH_TOKEN: secret }); + diagnostic(collision.notes, "service credential matches a management credential"); + expect(collision.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + const loaded = keyRead(); + expect(loaded.notes).toEqual([]); + expect(loaded.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); + for (const [config, reason] of [ + [local + '\n[mcp_servers.fixture]\ncommand="' + contents + '"\n', "configuration has additional credential consumers"], + ['unknown="' + contents + '"\n' + local, "configuration contains unaudited keys or values"], + [local.replace("127.0.0.1", "capture.invalid"), "provider transport does not match the managed loopback provider"], + ['broken = "' + contents, "credential destination inspection failed"], + ]) { + writeFileSync(join(home, "config.toml"), config!); + let reads = 0; + const result = capture({ CODEX_HOME: home }, { + credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + serviceToken: () => { reads++; return secret; }, + }); + diagnostic(result.notes, reason!); + expect(reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(result.notes[0]).not.toContain(root); + } + writeFileSync(join(home, "config.toml"), local); + // The production namespace is deliberately not modified by this test. + writeFileSync(join(system, "requirements.toml"), contents); + const result = capture({ CODEX_HOME: home }, { + credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + serviceToken: () => { throw new Error("must not read"); }, + }); + diagnostic(result.notes, "machine Codex configuration adds an unaudited system layer"); + } finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7b50cab26b3..1754b8969ef 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -586,6 +586,7 @@ "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", + "codex-launcher-diagnostics.test.ts": "codex-integration", "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts index ec56354c4a5..419e6895551 100644 --- a/tests/helpers/codex-launch-private-fixture.ts +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -7,7 +7,7 @@ import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch- // Model a protected, empty machine configuration namespace inside this fixture. // Every other authority check uses real config/ownership IO. Native probes use production defaults. export const buildCodexPrivateFixtureLaunchEnv: typeof buildCodexLaunchEnv = (base, deps) => buildCodexLaunchEnv(base, { - credentialDestination: home => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture")), ...deps, + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), report), ...deps, }); /** Windows Temp grants Users create rights to descendants; model a private Codex home. */ From b5a18251b470eed1b0da15bd63c89edffed1fa6f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:39:20 +0900 Subject: [PATCH 10/18] fix(cli): remove Codex ACL provisioning instructions Replace the Windows namespace remedy with the required administrator policy and an explicit user-supplied token alternative. Remove the provisioning command from the diagnostic and all eight reference locales, and synchronize the integration guide and owning structure document. Authorization predicates and credential loading behavior are unchanged. Cover command-free, value-free and path-free diagnostics and preservation of explicit credentials for missing and writable Windows namespaces. Keep the existing layout registrations and all file-size and structure budgets. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .../content/docs/fr/reference/cli/agents.md | 8 +--- .../content/docs/guides/codex-integration.md | 2 +- .../content/docs/ja/reference/cli/agents.md | 8 +--- .../content/docs/ko/reference/cli/agents.md | 8 +--- .../src/content/docs/reference/cli/agents.md | 8 +--- .../content/docs/ru/reference/cli/agents.md | 8 +--- .../content/docs/tr/reference/cli/agents.md | 8 +--- .../docs/zh-cn/reference/cli/agents.md | 8 +--- .../docs/zh-tw/reference/cli/agents.md | 8 +--- src/cli/codex-launch-diagnostics.ts | 7 +--- structure/cli-management.md | 2 +- .../codex-launcher-diagnostics.test.ts | 37 +++++++++++-------- 12 files changed, 41 insertions(+), 71 deletions(-) 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 1d63d258eb4..140b8a85028 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -173,13 +173,9 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag La clé est fournie uniquement si la configuration et ses espaces de noms sont protégés contre les modifications par d’autres principaux ; l’utilisateur courant et les autorités privilégiées du système sont de confiance. Les processus du même utilisateur sont de confiance : un acteur disposant de vos droits d’écriture peut déjà lire votre fichier de clé OpenCodex. Le remplacement de configuration par ce même utilisateur entre vérification et démarrage natif est hors de ce modèle de menace ; le lanceur ne fige pas la configuration lors du transfert. -Sur une installation Windows normale, les droits ProgramData par défaut permettent à d’autres utilisateurs de créer des dossiers. Les identifiants automatiques sont donc retenus même si `ProgramData\OpenAI\Codex` est absent. Le contrôle reste fermé et affiche une ligne stderr précise avec la commande suivante. Un administrateur peut protéger les deux dossiers dédiés par ACL (écriture SYSTEM/Administrators, lecture/exécution Users). Vérifiez d’abord la politique existante, puis exécutez dans **Windows PowerShell 5.1 avec élévation** ; la commande remplace les ACL des dossiers existants et refuse les points de réanalyse : +Sur une installation Windows normale, les droits ProgramData par défaut permettent à d’autres utilisateurs de créer des dossiers. Les identifiants automatiques sont donc retenus même si `ProgramData\OpenAI\Codex` est absent. Le dossier de configuration système de Codex sous ProgramData doit exister et être accessible en écriture uniquement à Administrators/SYSTEM ; ses dossiers ancêtres doivent empêcher leur remplacement par d’autres utilisateurs. Demandez à un administrateur de vérifier la politique des dossiers. opencodex ne modifie jamais lui-même les ACL et ne fournit aucune commande de préparation. -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -Le lanceur n’exécute jamais cette commande et ne modifie pas les ACL. Réessayez `ocx codex` après préparation. Les fichiers de configuration système existants empêchent toujours l’injection automatique et nécessitent un examen distinct. Chaque autre refus produit une courte ligne stderr précise : arguments/environnement non audités, propriété ou inspection du dossier/configuration, couches/consommateurs/transport, identifiant de service indisponible ou collision avec un identifiant de gestion. Aucun contenu, chemin de fichier de clé ou valeur secrète n’est affiché ; les identifiants explicitement exportés sont conservés. +Vous pouvez aussi fournir la clé vous-même, par exemple en exportant `OPENCODEX_API_AUTH_TOKEN` ; `ocx codex` conserve sa valeur. Les fichiers de configuration système existants empêchent toujours les identifiants automatiques et nécessitent un examen séparé. Chaque motif de refus produit une courte ligne stderr précise, sans valeur de clé, contenu ni chemin de fichier de clé. Placez `--` avant une invite d’un seul mot pour éviter la sélection d’une sous-commande Codex inconnue. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index b6642a0addd..8f4ba3216cb 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1036,7 +1036,7 @@ its two modes: The key is supplied only when the configuration and its namespaces are protected from other principals; the current user and privileged OS authorities are trusted. Same-user processes are trusted: an actor with your write access can already read your OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the launcher does not freeze configuration at handoff. -On Windows, default ProgramData creation rights withhold the key even when `ProgramData\OpenAI\Codex` is absent. An administrator must provision protected OpenAI and Codex namespaces. The launcher prints the specific reason and exact provisioning command on stderr, and never changes ACLs itself. See [launcher diagnostics and the elevated Windows PowerShell 5.1 command](/reference/cli/agents/#ocx-codex-codex-args). Other refusals also produce one short specific stderr line without key values, key-file contents or key-file paths; explicit credentials are preserved. +On Windows, default ProgramData creation rights withhold the key even when `ProgramData\OpenAI\Codex` is absent. The system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with its ancestors protected against replacement by other users. Ask an administrator to review the directory policy. opencodex never changes ACLs itself and provides no provisioning command. Alternatively, supply your own key by exporting `OPENCODEX_API_AUTH_TOKEN`; `ocx codex` leaves it untouched. See [launcher requirements and diagnostics](/reference/cli/agents/#ocx-codex-codex-args). Other refusals also produce one short specific stderr line without key values, key-file contents or key-file paths. ### Codex autostart shim 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 fb8b48f8f99..b449d550656 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -134,13 +134,9 @@ ocx claude desktop import [--apply] Validate and import JSON キーは設定とその名前空間が他の主体による変更から保護されている場合にのみ渡します。現在の利用者と特権 OS 主体を信頼します。同じ利用者のプロセスは信頼対象です。その利用者の書き込み権限を持つ主体は、既にその利用者の OpenCodex キーファイルも読めます。検証からネイティブ起動までの同一利用者による設定置換はこの脅威モデルの対象外で、引き渡し時に設定を固定しません。 -通常の Windows の既定 ProgramData 権限では他の利用者がディレクトリを作成できます。そのため `ProgramData\OpenAI\Codex` がなくても自動認証情報を保留します。判定を緩めず、以下のコマンドを含む具体的な stderr 診断を一行表示します。管理者は両専用ディレクトリを保護 ACL(SYSTEM/Administrators は書き込み、Users は読み取り/実行)で準備できます。既存のポリシーを確認してから **管理者権限の Windows PowerShell 5.1** で実行してください。既存ディレクトリの ACL を置き換え、reparse point は拒否します: +通常の Windows の既定 ProgramData 権限では他の利用者がディレクトリを作成できます。そのため `ProgramData\OpenAI\Codex` がなくても自動認証情報を保留します。ProgramData 配下のシステム Codex 設定フォルダーは存在し、Administrators/SYSTEM だけが書き込める必要があり、他の利用者による祖先ディレクトリの置換も防ぐ必要があります。管理者にディレクトリポリシーの確認を依頼してください。opencodex 自身は ACL を変更せず、準備用のコマンドも提供しません。 -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -ランチャー自身はこのコマンドや ACL 変更を実行しません。準備後に `ocx codex` を再試行してください。既存のシステム設定ファイルは引き続き自動注入を阻止し、別途確認が必要です。他の保留理由も、未監査の引数/環境、ホーム/設定の所有権や検査、設定層/消費者/転送、サービス認証情報の欠落、管理認証情報との衝突を区別して stderr 一行で表示します。キー値、キーファイルの内容やパスは表示せず、明示した認証情報は保持します。 +代わりに利用者が自分でキーを指定できます。例えば `OPENCODEX_API_AUTH_TOKEN` を環境変数としてエクスポートすると、`ocx codex` はその値を変更しません。既存のシステム設定ファイルがあれば引き続き自動認証情報を保留し、別途確認が必要です。各保留理由はキー値、キーファイルの内容やパスを含まない短い具体的な stderr 診断一行で示されます。 単語一つのプロンプトの前に `--` を置き、未知の Codex サブコマンド選択を防いでください。 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 a6163151a46..d681dbd774f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -166,13 +166,9 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 키는 설정과 해당 경로가 다른 보안 주체의 변경으로부터 보호될 때만 전달합니다. 현재 사용자와 권한 있는 OS 주체는 신뢰합니다. 같은 사용자로 실행되는 프로세스는 신뢰 대상입니다. 사용자의 쓰기 권한을 가진 행위자는 사용자의 OpenCodex 키 파일도 이미 읽을 수 있습니다. 검증과 네이티브 시작 사이에 같은 사용자가 설정을 교체하는 시나리오는 이 위협 모델의 범위 밖이며, 런처는 인계 시 설정을 고정하지 않습니다. -일반적인 Windows 설치의 기본 ProgramData 권한은 다른 사용자에게 디렉터리 생성 권한을 줍니다. 따라서 `ProgramData\OpenAI\Codex`가 없어도 자동 자격 증명을 보류합니다. 검사를 완화하지 않고 아래 명령을 포함한 구체적인 stderr 진단 한 줄을 출력합니다. 관리자는 두 전용 디렉터리를 보호된 ACL(SYSTEM/Administrators 쓰기, Users 읽기/실행)로 준비할 수 있습니다. 기존 디렉터리 정책을 먼저 검토하고 **관리자 권한 Windows PowerShell 5.1**에서 실행하세요. 기존 디렉터리 ACL은 교체하며 reparse point는 거부합니다: +일반적인 Windows 설치의 기본 ProgramData 권한은 다른 사용자에게 디렉터리 생성 권한을 줍니다. 따라서 `ProgramData\OpenAI\Codex`가 없어도 자동 자격 증명을 보류합니다. ProgramData 아래의 시스템 Codex 설정 폴더는 존재해야 하고 Administrators/SYSTEM만 쓸 수 있어야 하며, 다른 사용자가 상위 디렉터리를 교체할 수 없어야 합니다. 관리자에게 디렉터리 정책 검토를 요청하세요. opencodex는 직접 ACL을 변경하지 않으며 프로비저닝 명령을 제공하지 않습니다. -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -런처가 이 명령을 실행하거나 ACL을 직접 변경하지는 않습니다. 준비 후 `ocx codex`를 다시 실행하세요. 기존 시스템 설정 파일은 여전히 자동 주입을 막으며 별도 검토가 필요합니다. 다른 모든 보류 사유도 짧고 구체적인 stderr 한 줄로 안내합니다. 검증되지 않은 인자·환경, 홈/설정 소유권·검사, 설정층·소비자·전송, 서비스 자격 증명 부재, 관리 자격 증명 충돌을 구분합니다. 키 값·키 파일 내용·키 파일 경로는 출력하지 않으며 명시적인 사용자 자격 증명은 유지합니다. +대신 사용자가 직접 키를 제공할 수 있습니다. 예를 들어 `OPENCODEX_API_AUTH_TOKEN` 환경 변수를 내보내면 `ocx codex`는 그 값을 그대로 유지합니다. 기존 시스템 설정 파일은 여전히 자동 자격 증명을 보류하며 별도 검토가 필요합니다. 각 보류 사유는 키 값, 키 파일 내용이나 경로 없이 짧고 구체적인 stderr 진단 한 줄로 표시됩니다. 한 단어 프롬프트 앞에는 `--`를 넣어 알 수 없는 Codex 하위 명령 선택을 막으세요. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 9174a574fb6..01a83045ceb 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -967,13 +967,9 @@ before choosing it. Rendered models are not proof of native application. The key is supplied only when the configuration and its namespaces are protected from other principals; the current user and privileged OS authorities are trusted. Same-user processes are trusted: an actor with your write access can already read your OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the launcher does not freeze configuration at handoff. -On a normal Windows installation, default ProgramData permissions allow other users to create directories. Automatic credentials are therefore withheld even when `ProgramData\OpenAI\Codex` is absent. The launcher keeps this check closed and prints one specific stderr diagnostic, including the command below. An administrator can provision both dedicated directories with protected ACLs (SYSTEM/Administrators write, Users read/execute). Review existing directory policy first, then run this in **elevated Windows PowerShell 5.1**; it replaces the ACL on existing directories and refuses reparse points: +On a normal Windows installation, default ProgramData permissions allow other users to create directories. Automatic credentials are therefore withheld even when `ProgramData\OpenAI\Codex` is absent. The system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, and its ancestors must prevent replacement by other users. Ask an administrator to review the directory policy. opencodex never changes ACLs itself and provides no provisioning command. -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -The launcher never runs this command or changes ACLs itself. Retry `ocx codex` after provisioning. Existing system configuration files still withhold automatic credentials and require separate review. Every other withholding reason has one short specific stderr line, including unaudited arguments/environment, home/config ownership or inspection, configuration layers/consumers/transport, an unavailable service credential, and a management-credential collision. Diagnostics include no key values, key-file contents or key-file paths. Explicit user credentials remain unchanged. +Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_AUTH_TOKEN`; `ocx codex` leaves it untouched. Existing system configuration files still withhold automatic credentials and require separate review. Each withholding reason produces one short, specific stderr diagnostic without key values, key-file contents or key-file paths. Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. 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 fa769337e03..d2d7e1ad783 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -155,13 +155,9 @@ ocx claude desktop import [--apply] Validate and import JSON Ключ передаётся только когда конфигурация и её пространства имён защищены от изменения другими субъектами; текущий пользователь и привилегированные субъекты ОС считаются доверенными. Процессы того же пользователя доверенные: субъект с вашими правами записи уже может прочитать ваш файл ключа OpenCodex. Замена конфигурации тем же пользователем между проверкой и нативным запуском находится вне этой модели угроз; конфигурация при передаче управления не фиксируется. -При обычной установке Windows права ProgramData по умолчанию позволяют другим пользователям создавать каталоги. Поэтому автоматические учётные данные не передаются даже при отсутствии `ProgramData\OpenAI\Codex`. Проверка остаётся закрытой, а stderr выводит одну конкретную строку с командой ниже. Администратор может защитить оба выделенных каталога ACL (запись SYSTEM/Administrators, чтение/выполнение Users). Сначала проверьте существующую политику, затем выполните в **Windows PowerShell 5.1 с повышенными правами**; команда заменяет ACL существующих каталогов и отклоняет точки повторной обработки: +При обычной установке Windows права ProgramData по умолчанию позволяют другим пользователям создавать каталоги. Поэтому автоматические учётные данные не передаются даже при отсутствии `ProgramData\OpenAI\Codex`. Папка системной конфигурации Codex в ProgramData должна существовать и быть доступна для записи только Administrators/SYSTEM; родительские каталоги должны быть защищены от замены другими пользователями. Попросите администратора проверить политику каталогов. opencodex никогда не меняет ACL самостоятельно и не предоставляет команду подготовки. -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -Средство запуска само не выполняет команду и не меняет ACL. После подготовки повторите `ocx codex`. Существующие системные файлы конфигурации по-прежнему блокируют автоматическую передачу и требуют отдельной проверки. Каждая другая причина даёт короткую конкретную строку stderr: непроверенные аргументы/окружение, владение или проверка домашнего каталога/конфигурации, слои/потребители/транспорт, отсутствие служебных учётных данных или совпадение с административными. Значения ключей, содержимое и пути файлов ключей не выводятся; явно экспортированные данные сохраняются. +Вместо этого можно передать ключ самостоятельно, например экспортировав `OPENCODEX_API_AUTH_TOKEN`; `ocx codex` оставляет его значение без изменений. Существующие системные файлы конфигурации по-прежнему блокируют автоматические учётные данные и требуют отдельной проверки. Для каждой причины отказа выводится одна короткая конкретная строка stderr без значений ключей, содержимого или путей файлов ключей. Перед подсказкой из одного слова укажите `--`, чтобы исключить выбор неизвестной подкоманды Codex. 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 636b1b11b70..c753c76b52d 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,13 +191,9 @@ eder. Claude Code ayarları için `ocx claude config ...` kullanın Anahtar yalnızca yapılandırma ve ad alanları diğer güvenlik sorumlularının değişikliklerine karşı korunduğunda sağlanır; geçerli kullanıcı ve ayrıcalıklı işletim sistemi yetkilileri güvenilirdir. Aynı kullanıcının süreçlerine güvenilir: yazma yetkinize sahip bir aktör OpenCodex anahtar dosyanızı zaten okuyabilir. Doğrulama ile yerel başlatma arasında aynı kullanıcının yapılandırmayı değiştirmesi bu tehdit modelinin kapsamı dışındadır; başlatıcı aktarım sırasında yapılandırmayı dondurmaz. -Normal Windows kurulumunda varsayılan ProgramData izinleri diğer kullanıcıların dizin oluşturmasına izin verir. Bu nedenle `ProgramData\OpenAI\Codex` yokken bile otomatik kimlik bilgileri verilmez. Denetim kapalı kalır ve aşağıdaki komutu içeren tek, belirli bir stderr tanısı gösterilir. Yönetici iki özel dizini korumalı ACL ile hazırlayabilir (SYSTEM/Administrators yazma, Users okuma/çalıştırma). Önce mevcut dizin politikasını inceleyin, sonra **yükseltilmiş Windows PowerShell 5.1** içinde çalıştırın; komut mevcut dizinlerin ACL’lerini değiştirir ve reparse point öğelerini reddeder: +Normal Windows kurulumunda varsayılan ProgramData izinleri diğer kullanıcıların dizin oluşturmasına izin verir. Bu nedenle `ProgramData\OpenAI\Codex` yokken bile otomatik kimlik bilgileri verilmez. ProgramData altındaki sistem Codex yapılandırma klasörü mevcut olmalı ve yalnızca Administrators/SYSTEM tarafından yazılabilir olmalıdır; üst dizinler de diğer kullanıcıların değiştirmesine karşı korunmalıdır. Bir yöneticiden dizin politikasını incelemesini isteyin. opencodex ACL’leri kendisi hiçbir zaman değiştirmez ve hazırlık komutu sağlamaz. -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -Başlatıcı bu komutu kendisi çalıştırmaz ve ACL değiştirmez. Hazırlıktan sonra `ocx codex` komutunu yeniden deneyin. Mevcut sistem yapılandırma dosyaları otomatik aktarımı hâlâ engeller ve ayrıca incelenmelidir. Diğer her engelleme nedeni de kısa ve belirli tek stderr satırı üretir: denetlenmemiş argüman/ortam, ana dizin/yapılandırma sahipliği veya incelemesi, katmanlar/tüketiciler/taşıma, kullanılamayan hizmet kimlik bilgisi veya yönetim kimlik bilgisi çakışması. Anahtar değerleri, anahtar dosyası içeriği ve yolları yazdırılmaz; açıkça dışa aktarılan kimlik bilgileri korunur. +Alternatif olarak anahtarı kendiniz sağlayabilirsiniz; örneğin `OPENCODEX_API_AUTH_TOKEN` ortam değişkenini dışa aktarabilirsiniz. `ocx codex` bu değeri değiştirmez. Mevcut sistem yapılandırma dosyaları otomatik kimlik bilgilerini engellemeye devam eder ve ayrıca incelenmelidir. Her engelleme nedeni, anahtar değeri, anahtar dosyası içeriği veya yolu içermeyen kısa ve belirli bir stderr satırı üretir. Bilinmeyen bir Codex alt komutu seçilmesini önlemek için tek sözcüklü istemden önce `--` kullanın. 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 2bf32a70395..6825b7a421b 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 @@ -138,13 +138,9 @@ ocx claude desktop import [--apply] Validate and import JSON 仅当配置及其命名空间受到保护、其他主体无法修改时才提供密钥;当前用户和特权 OS 主体被信任。同一用户的进程被信任:拥有该用户写入权限的主体已经能读取该用户的 OpenCodex 密钥文件。验证与原生启动之间由同一用户替换配置不在此威胁模型内,启动器不会在交接时冻结配置。 -正常 Windows 安装的默认 ProgramData 权限允许其他用户创建目录。因此即使 `ProgramData\OpenAI\Codex` 不存在,也会暂缓自动凭据。检查保持拒绝,并向 stderr 输出一条包含以下命令的具体诊断。管理员可为两个专用目录配置受保护 ACL(SYSTEM/Administrators 可写,Users 只读/执行)。请先审核现有目录策略,再在 **提升权限的 Windows PowerShell 5.1** 中运行;命令会替换现有目录的 ACL,并拒绝 reparse point: +正常 Windows 安装的默认 ProgramData 权限允许其他用户创建目录。因此即使 `ProgramData\OpenAI\Codex` 不存在,也会暂缓自动凭据。ProgramData 下的系统 Codex 配置文件夹必须存在,且仅 Administrators/SYSTEM 可写;其祖先目录也必须防止其他用户替换。请管理员审核目录策略。opencodex 从不自行修改 ACL,也不提供配置目录的命令。 -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -启动器不会自行运行命令或修改 ACL。配置后重试 `ocx codex`。已有系统配置文件仍会阻止自动注入,需另行审核。其他暂缓原因也各有一条简短明确的 stderr 提示,区分未审核的参数/环境、主目录/配置所有权或检查、配置层/消费者/传输、服务凭据缺失以及管理凭据冲突。不会打印密钥值、密钥文件内容或路径;明确导出的用户凭据保持不变。 +也可以自行提供密钥,例如导出 `OPENCODEX_API_AUTH_TOKEN` 环境变量;`ocx codex` 会保留其值不变。已有系统配置文件仍会阻止自动凭据,需另行审核。每种暂缓原因都会输出一条简短明确的 stderr 诊断,不包含密钥值、密钥文件内容或路径。 单词提示前请加 `--`,以免选择未知的 Codex 子命令。 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 674c4f62986..4b3bf964885 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 @@ -135,13 +135,9 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON 只有設定及其命名空間受到保護、其他主體無法修改時才提供金鑰;目前使用者和特權 OS 主體受到信任。同一使用者的程序受到信任:具有該使用者寫入權限的主體已能讀取該使用者的 OpenCodex 金鑰檔案。驗證與原生啟動之間由同一使用者替換設定不在此威脅模型內,啟動器不會在交接時凍結設定。 -一般 Windows 安裝的預設 ProgramData 權限允許其他使用者建立目錄。因此即使 `ProgramData\OpenAI\Codex` 不存在,也會暫緩自動認證資料。檢查維持拒絕,並向 stderr 輸出一行包含以下命令的具體診斷。管理員可為兩個專用目錄配置受保護 ACL(SYSTEM/Administrators 可寫入,Users 僅讀取/執行)。先審查現有目錄原則,再於 **提升權限的 Windows PowerShell 5.1** 執行;命令會取代現有目錄 ACL,並拒絕 reparse point: +一般 Windows 安裝的預設 ProgramData 權限允許其他使用者建立目錄。因此即使 `ProgramData\OpenAI\Codex` 不存在,也會暫緩自動憑證。ProgramData 下的系統 Codex 設定資料夾必須存在,且僅 Administrators/SYSTEM 可寫入;其祖先目錄也必須防止其他使用者替換。請管理員檢查目錄原則。opencodex 絕不自行修改 ACL,也不提供設定目錄的命令。 -```powershell -& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } } -``` - -啟動器不會自行執行命令或變更 ACL。完成後重試 `ocx codex`。既有系統設定檔仍會阻止自動注入,須另行審查。其他暫緩原因也各有一行簡短明確的 stderr 訊息,區分未審核的引數/環境、主目錄/設定擁有權或檢查、設定層/消費者/傳輸、服務認證資料缺失及管理認證資料衝突。不會列印金鑰值、金鑰檔案內容或路徑;明確匯出的使用者認證資料維持不變。 +您也可以自行提供金鑰,例如匯出 `OPENCODEX_API_AUTH_TOKEN` 環境變數;`ocx codex` 會保留其值不變。既有系統設定檔仍會阻止自動憑證,需另行檢查。每種暫緩原因都會輸出一行簡短明確的 stderr 診斷,不含金鑰值、金鑰檔案內容或路徑。 單字提示前請加 `--`,以免選取未知的 Codex 子命令。 diff --git a/src/cli/codex-launch-diagnostics.ts b/src/cli/codex-launch-diagnostics.ts index 65f36cf738b..f29d3d4d77b 100644 --- a/src/cli/codex-launch-diagnostics.ts +++ b/src/cli/codex-launch-diagnostics.ts @@ -29,14 +29,9 @@ export const CODEX_TOKEN_REFUSALS = { export type CodexTokenRefusal = keyof typeof CODEX_TOKEN_REFUSALS; export type CodexTokenRefusalReporter = (reason: CodexTokenRefusal) => void; -// Run only by an administrator in Windows PowerShell 5.1. Create missing -// directories with the protected DACL already installed; also protect existing -// dedicated directories. SYSTEM/Administrators write; Users only read/traverse. -export const CODEX_WINDOWS_NAMESPACE_COMMAND = String.raw`& { $ErrorActionPreference='Stop'; foreach ($rel in @('OpenAI','OpenAI\Codex')) { $p=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $rel; $acl=[System.Security.AccessControl.DirectorySecurity]::new(); $acl.SetSecurityDescriptorSddlForm('O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'); if ([System.IO.Directory]::Exists($p)) { if (([System.IO.File]::GetAttributes($p) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Refusing a reparse point' }; [System.IO.Directory]::SetAccessControl($p,$acl) } else { [System.IO.Directory]::CreateDirectory($p,$acl) | Out-Null } } }`; - export function codexTokenWithheldDiagnostic(reason: CodexTokenRefusal): string { const remedy = process.platform === "win32" && reason === "systemNamespaceWritable" - ? `; default ProgramData creation rights are insufficient even when OpenAI\\Codex is absent. Ask an administrator to review existing directory policy and run in elevated Windows PowerShell 5.1: ${CODEX_WINDOWS_NAMESPACE_COMMAND}` + ? ". Default ProgramData creation rights are insufficient. The system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with its ancestors protected against replacement by other users. Ask an administrator to review the directory policy. opencodex never changes ACLs itself. Alternatively, supply your own key by exporting OPENCODEX_API_AUTH_TOKEN, which ocx codex leaves untouched. Launching without a fresh token." : ". Launching without a fresh token."; return `OpenCodex: automatic service token withheld; ${CODEX_TOKEN_REFUSALS[reason]}${remedy}`; } diff --git a/structure/cli-management.md b/structure/cli-management.md index 15b2cca5c6a..3eafc41e74d 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -25,7 +25,7 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl `src/cli/codex-launch-ownership.ts` checks current-user ownership of the canonical home and config, rejects POSIX group/other writes and Windows DACL mutation grants outside current user/SYSTEM/Administrators, and rejects unsafe ancestor replacement rights. Windows system paths come from the CommonApplicationData known folder; ancestor ownership also permits TrustedInstaller. macOS checks forced CoreFoundation preferences. Read/inspection errors deny. The key is supplied only when configuration and its namespaces are protected from other principals; the current user, SYSTEM/Administrators and the documented OS authorities are trusted. Same-user processes are trusted: an actor with this user's write access can already read this user's OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the check does not establish immutable startup configuration or isolate privileged processes. Regression coverage is in `tests/codex-integration/codex-launcher-admission.test.ts`. -`src/cli/codex-launch-diagnostics.ts` maps each fresh-key withholding reason to one fixed stderr line without credential values, key-file paths, config contents or exception text. Windows namespace creation/mutation rights have a specific administrator remedy: default ProgramData rights remain unsafe even when OpenAI/Codex is absent. The printed elevated Windows PowerShell 5.1 command creates/protects both OpenAI and Codex with a fresh protected DACL per operation (SYSTEM/Administrators full control, Users read/execute), refuses existing reparse points, and changes no ACL automatically. Existing system configuration still refuses; provisioning only satisfies the namespace boundary. Tests in `tests/codex-integration/codex-launcher-diagnostics.test.ts` cover one-line non-disclosure and real missing/writable/protected Windows namespace controls. Public provisioning instructions live in the [CLI reference](../docs-site/src/content/docs/reference/cli/agents.md#ocx-codex-codex-args). +`src/cli/codex-launch-diagnostics.ts` maps each fresh-key withholding reason to one fixed stderr line without shell commands, credential values, key-file paths, config contents or exception text. Default Windows ProgramData rights remain unsafe even when OpenAI/Codex is absent. The diagnostic states that the system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with ancestors protected against replacement by other users. It requests administrator policy review and states that opencodex never changes ACLs itself; no provisioning command is supplied. Users can instead supply their own key by exporting OPENCODEX_API_AUTH_TOKEN, which the launcher leaves untouched. Existing system configuration still refuses automatic credentials. Tests in `tests/codex-integration/codex-launcher-diagnostics.test.ts` cover command-free, one-line non-disclosure, real missing/writable Windows namespace refusals and explicit credential preservation. Public requirements and the explicit-key alternative live in the [CLI reference](../docs-site/src/content/docs/reference/cli/agents.md#ocx-codex-codex-args). For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts index 9686c110eb3..a6e4de58f6b 100644 --- a/tests/codex-integration/codex-launcher-diagnostics.test.ts +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildCodexLaunchEnv, type CodexLaunchEnvDeps } from "../../src/cli/codex-launcher"; import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; -import { CODEX_WINDOWS_NAMESPACE_COMMAND } from "../../src/cli/codex-launch-diagnostics"; +import { CODEX_TOKEN_REFUSALS, codexTokenWithheldDiagnostic } from "../../src/cli/codex-launch-diagnostics"; import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; @@ -24,10 +24,17 @@ function diagnostic(notes: string[], reason: string) { expect(notes).toHaveLength(1); expect(notes[0]).toContain("automatic service token withheld; " + reason); expect(notes[0]).not.toMatch(/[\r\n]/); + expect(notes[0]).not.toMatch(/powershell|icacls|SetAccessControl|CreateDirectory|Set-Acl|New-Item|\$[a-z]|&\s*\{|[{}]/i); for (const forbidden of [secret, contents, "service-api-token", "admin-api-token"]) expect(notes[0]).not.toContain(forbidden); } describe("Codex credential withholding diagnostics", () => { + test("every refusal emits only a value-free explanation without a shell command", () => { + for (const reason of Object.keys(CODEX_TOKEN_REFUSALS) as (keyof typeof CODEX_TOKEN_REFUSALS)[]) { + diagnostic([codexTokenWithheldDiagnostic(reason)], CODEX_TOKEN_REFUSALS[reason]); + } + }); + for (const [base, deps, reason] of [ [{}, { args: ["--oss"] }, "arguments are outside the audited allowlist"], [{ HTTPS_PROXY: contents }, {}, "environment contains a proxy or unaudited credential control"], @@ -59,7 +66,7 @@ describe("Codex credential withholding diagnostics", () => { ]) expect(result.notes).toEqual([]); }); - test.skipIf(process.platform !== "win32")("missing or writable Windows namespace explains admin provisioning, which restores admission", () => { + test.skipIf(process.platform !== "win32")("missing or writable Windows namespace explains the requirement and explicit credential alternative", () => { const root = mkdtempSync(join(process.env.SystemRoot ?? "C:/Windows", "Temp", "ocx-namespace-diagnostic-")); makeCodexLaunchFixturePrivate(root); const home = join(root, "codex"), ocx = join(root, "ocx"), machine = join(root, "machine"); @@ -88,11 +95,6 @@ $acl.AddAccessRule($rule) }); try { ps(grantCreation); - // Exercise the documented command in scratch only. The fixture user stands - // in for Administrators so this test also runs without an elevated token. - const provision = CODEX_WINDOWS_NAMESPACE_COMMAND - .replace("[Environment]::GetFolderPath('CommonApplicationData')", "$env:OCX_TEST_MACHINE_ROOT") - .replace("if ([System.IO.Directory]::Exists", String.raw`$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $acl.SetOwner($user); $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)); if ([System.IO.Directory]::Exists`); for (const existing of [false, true]) { if (existing) ps(String.raw`[System.IO.Directory]::CreateDirectory($env:OCX_TEST_NAMESPACE) | Out-Null $p=$env:OCX_TEST_NAMESPACE @@ -101,17 +103,22 @@ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([Sy [System.IO.Directory]::SetAccessControl($p,$acl)`); const result = launch(); diagnostic(result.notes, "machine Codex namespace allows other principals to create or replace configuration"); - expect(result.notes[0]).toContain("default ProgramData creation rights"); - expect(result.notes[0]).toContain("elevated Windows PowerShell 5.1: " + CODEX_WINDOWS_NAMESPACE_COMMAND); + expect(result.notes[0]).toContain("Default ProgramData creation rights"); + expect(result.notes[0]).toContain("must exist"); + expect(result.notes[0]).toContain("writable only by Administrators/SYSTEM"); + expect(result.notes[0]).toContain("opencodex never changes ACLs itself"); + expect(result.notes[0]).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(result.notes[0]).toContain("leaves untouched"); expect(result.notes[0]).not.toContain(root); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); - expect(reads).toBe(existing ? 1 : 0); - ps(provision); - const protectedResult = launch(); - expect(protectedResult.notes).toEqual([]); - expect(protectedResult.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); + expect(reads).toBe(0); + const explicit = capture({ CODEX_HOME: home, OPENCODEX_API_AUTH_TOKEN: secret }, { + serviceToken: () => { throw new Error("must not read"); }, + }); + expect(explicit.notes).toEqual([]); + expect(explicit.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); } - expect(reads).toBe(2); + expect(reads).toBe(0); } finally { process.chdir(cwd); if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; From 464423075e1937b15bd870c81975ec461a1f7950 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:01:04 +0900 Subject: [PATCH 11/18] fix(cli): verify shim bypass after inspection errors and distinguish missing Codex --- src/cli/codex-launcher.ts | 16 +++++++++----- .../codex-launcher-process.test.ts | 14 ++++++++++++ .../codex-integration/codex-launcher.test.ts | 22 +++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 2d98e4d9507..98fc9f103ea 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -215,6 +215,7 @@ export function buildCodexLaunchEnv( } const CODEX_INSTALL_HINT = "❌ `codex` CLI not found. Install it first: npm install -g @openai/codex"; +class CodexNotFoundError extends Error {} /** * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd` @@ -250,13 +251,16 @@ export function resolveCodexLaunchCommand( ): CodexLaunchCommand { try { const selected = (deps.findOnPath ?? findExecutableOnPath)("codex"); - if (!selected) throw new Error(CODEX_UNVERIFIED_SHIM); - const shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); + if (!selected) throw new CodexNotFoundError(CODEX_INSTALL_HINT); + let shim: ReturnType; + try { shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); } + catch { shim = { status: "unknown", reason: "binding_unavailable" }; } if (shim.status === "matched" && shim.selectedRole === "wrapper") return { command: shim.backingPath, env }; if (shim.status !== "unknown") return { command: selected, env }; if (!(deps.verifyBypass ?? codexCommandCanBypass)(selected, env)) throw new Error(CODEX_UNVERIFIED_SHIM); return { command: selected, env: { ...env, OCX_SHIM_BYPASS: "1" } }; - } catch { + } catch (error) { + if (error instanceof CodexNotFoundError) throw error; throw new Error(CODEX_UNVERIFIED_SHIM); } } @@ -266,7 +270,7 @@ function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise(resolve => { let launch: CodexLaunchCommand; try { launch = resolveCodexLaunchCommand(env); } - catch { console.error(CODEX_UNVERIFIED_SHIM); resolve(1); return; } + catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : CODEX_UNVERIFIED_SHIM); resolve(1); return; } const inv = commandInvocation(launch.command, args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { @@ -297,8 +301,8 @@ export async function cmdCodex(args: string[]): Promise { if (!needsProxy) return spawnCodex(args, { ...process.env }); const live = await ensureProxyForCodex(); if (!live) { - // Never lock the user out of `codex`: mirror the generated shim's behavior of - // launching anyway with one stderr line. The already-injected config decides + // A failed ensure warns and continues, subject to executable verification. + // The already-injected config decides // what the session does next; `ocx doctor` names the autostart failure. console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); } diff --git a/tests/codex-integration/codex-launcher-process.test.ts b/tests/codex-integration/codex-launcher-process.test.ts index 917143dfd3e..87e1b7390bd 100644 --- a/tests/codex-integration/codex-launcher-process.test.ts +++ b/tests/codex-integration/codex-launcher-process.test.ts @@ -8,6 +8,20 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; describe("ocx codex real CLI handoff", () => { + for (const arg of ["--help", "--version"]) test(`missing Codex ${arg} reports the install hint`, () => { + const home = mkdtempSync(join(tmpdir(), "ocx-codex-missing-")); + try { + const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", arg], { + cwd: home, env: { ...process.env, PATH: "", OPENCODEX_HOME: home }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("npm install -g @openai/codex"); + expect(result.stderr).not.toContain("cannot verify Codex shim bypass"); + } finally { removeTreeWithRetry(home); } + }, SPAWN_BUDGET_MS); + for (const args of [["--help"], ["exec", "--help"], ["help"], ["--version"], ["resume", "two words"]]) { test(`forwards ${args.join(" ")} without loading a service token`, () => { const home = mkdtempSync(join(tmpdir(), "ocx-codex-process-")); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 4864a5cdafb..96f888fef4d 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -308,12 +308,30 @@ describe("codex launcher shim handoff", () => { expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); }); - test("a shim filesystem error fails closed without changing the input environment", () => { + test("a throwing shim inspector requires a verified bypass", () => { + let verified = false; + const launch = resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => { throw new Error("fixture inspection failure"); }, + verifyBypass: (selected, inherited) => { + expect(selected).toBe(onPath()); expect(inherited).toBe(env); + verified = true; return true; + }, + }); + expect(verified).toBe(true); + expect(launch).toEqual({ command: onPath(), env: { ...env, OCX_SHIM_BYPASS: "1" } }); + expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); + }); + + test("an unverifiable bypass after a shim filesystem error fails closed without changing the input environment", () => { const base = { ...env, OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_inherited_token", OCX_SHIM_BYPASS: "" }; + let verified = false; expect(() => resolveCodexLaunchCommand(base, { findOnPath: onPath, inspectShim: () => { throw Object.assign(new Error("shim state read failed"), { code: "EIO" }); }, + verifyBypass: () => { verified = true; return false; }, })).toThrow("cannot verify Codex shim bypass"); + expect(verified).toBe(true); expect(base.OCX_SHIM_BYPASS).toBe(""); }); @@ -324,6 +342,6 @@ describe("codex launcher shim handoff", () => { findOnPath: onPath, inspectShim: () => ({ status: "matched", selectedRole: "backing", backingPath: "/home/test/.local/bin/codex", backingKind: "real" }), })).toEqual({ command: onPath(), env }); - expect(() => resolveCodexLaunchCommand(env, { findOnPath: () => null })).toThrow("cannot verify Codex shim bypass"); + expect(() => resolveCodexLaunchCommand(env, { findOnPath: () => null })).toThrow("npm install -g @openai/codex"); }); }); From 8dfe0cf3c69bca1d6dfec3ba4b42833c7ce39706 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:01:30 +0900 Subject: [PATCH 12/18] fix(cli): compare ancestor Codex configs by canonical identity --- src/cli/codex-launch-auth.ts | 11 +++++--- .../codex-launcher-env.test.ts | 21 ++++++++++++++ .../codex-launcher-security.test.ts | 28 +++++++++++++++++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 980f3b40898..b6b814f5fec 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -68,10 +68,13 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac } for (let directory = process.cwd(); ; directory = dirname(directory)) { const projectConfig = join(directory, ".codex", "config.toml"); - if (projectConfig !== join(home, "config.toml")) { - try { lstatSync(projectConfig); return refuse("project"); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } - } + try { + lstatSync(projectConfig); + // A symlinked ~/.codex can name this very home config. Only proven file + // identity excludes an existing candidate from the project-layer check. + try { if (realpathSync(projectConfig) !== realpathSync(join(home, "config.toml"))) return refuse("project"); } + catch { return refuse("project"); } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } // A nested .git need not be Codex's root: custom project_root_markers can select // an outer workspace. Without proving that selection, inspect every ancestor. if (dirname(directory) === directory) break; diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 273a3edcbdd..182cd815310 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -48,7 +48,28 @@ function launch(base = { ...process.env }) { return { env, reads }; } +function canCreateDirectorySymlink(): boolean { + const root = mkdtempSync(join(tmpdir(), "ocx-symlink-probe-")); + try { symlinkSync(root, join(root, "link"), "dir"); return true; } + catch (error) { + if (["EPERM", "EACCES", "ENOSYS", "ENOTSUP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + throw error; + } finally { removeTreeWithRetry(root); } +} + describe("Codex launcher native home and proxy environment", () => { + test.skipIf(!canCreateDirectorySymlink())("a symlinked user .codex is the home config, not a project layer", () => fixture((root, home) => { + const user = join(root, "user"), cwd = join(user, "project"); + mkdirSync(cwd); + symlinkSync(home, join(user, ".codex"), "dir"); + process.chdir(cwd); + const base = { ...process.env }; delete base.CODEX_HOME; + const result = launch(base); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + })); + test("literal tilde home cannot borrow authority from the expanded user home", () => fixture((root) => { const expanded = join(root, "user", "trusted"), literal = join(root, "~", "trusted"); mkdirSync(expanded); mkdirSync(literal, { recursive: true }); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 0b851028191..f6c96ada2e8 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -4,6 +4,9 @@ import { tmpdir } from "node:os"; import { delimiter, join, parse } from "node:path"; import { spawnSync } from "node:child_process"; import { codexCommandCanBypass } from "../../src/cli/codex-launch-command"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { resolveCodexLaunchCommand } from "../../src/cli/codex-launcher"; +import { commandInvocation } from "../../src/lib/win-exec"; import { classifyCodexRouting } from "../../src/codex/inject/routing-classify"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; @@ -106,13 +109,17 @@ describe("codex launcher credential destination", () => { writeFileSync(join(workspace, ".workspace-root"), ""); writeFileSync(join(nested, ".git"), "gitdir: fixture\n"); writeFileSync(join(workspace, ".codex", "config.toml"), '[model_providers.opencodex]\nbase_url = "https://gateway.invalid/v1"\n'); - writeFileSync(join(codexHome, "config.toml"), 'project_root_markers = [".workspace-root"]\n' + local); + writeFileSync(join(codexHome, "config.toml"), local); const originalDirectory = process.cwd(); try { process.chdir(cwd); - let reads = 0; - const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + let reads = 0, reason: string | undefined; + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { + routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; }, + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + }); expect(reads).toBe(0); + expect(reason).toBe("project"); expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); } finally { process.chdir(originalDirectory); } })); @@ -143,6 +150,21 @@ describe.skipIf(process.platform !== "win32")("codex launcher installed Windows const wrapper = join(bin, "codex.cmd"); writeFileSync(wrapper, installed); const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, PATHEXT: ".EXE;.CMD" }; for (const key of ["OPENCODEX_API_AUTH_TOKEN", "OPENCODEX_ADMIN_AUTH_TOKEN", "OCX_API_TOKEN_FILE", "OCX_SHIM_BYPASS"]) delete env[key]; + const resolveWithThrowingInspector = () => resolveCodexLaunchCommand(env, { + findOnPath: () => wrapper, + inspectShim: () => { throw new Error("fixture inspection failure"); }, + }); + if (obsolete) expect(resolveWithThrowingInspector).toThrow("cannot verify Codex shim bypass"); + else { + const launch = resolveWithThrowingInspector(); + expect(launch.command).toBe(wrapper); + expect(launch.env.OCX_SHIM_BYPASS).toBe("1"); + const invocation = commandInvocation(launch.command, args); + const bypassed = spawnSync(invocation.file, invocation.args, { ...invocation.options, + env: launch.env as NodeJS.ProcessEnv, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true }); + expect(bypassed.status).toBe(23); + expect(JSON.parse(bypassed.stdout.trim())).toEqual({ token: null }); + } const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", ...args], { cwd: repoRoot(), env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, }); From e754de65b7e80778592aa9d34ebf37111a0eca5a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:01:40 +0900 Subject: [PATCH 13/18] fix(cli): accept nonmutating Darwin ACLs while inspecting every entry --- .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-launch-ownership.ts | 48 +++++++++++++--- .../codex-launcher-darwin-acl.test.ts | 56 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 tests/codex-integration/codex-launcher-darwin-acl.test.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 3710f43bbd4..850cab6c5ca 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,5 +1,6 @@ { "codex-launcher-diagnostics.test.ts": "codex-integration", + "codex-launcher-darwin-acl.test.ts": "codex-integration", "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts index 3657af7964f..2f5d97aeaa4 100644 --- a/src/cli/codex-launch-ownership.ts +++ b/src/cli/codex-launch-ownership.ts @@ -121,12 +121,25 @@ export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string, repo } catch { return refuse("systemNamespace"); } } -/** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ +/** Darwin's successful end-of-enumeration differs from POSIX. */ export function darwinAclInspectionIsEmpty(validResult: number, entryResult: number, errorNumber: number): boolean { // Darwin differs from POSIX: found entry => 0; a valid empty ACL => -1/EINVAL. return validResult === 0 && entryResult === -1 && errorNumber === 22; } +// Tags, permission bits and enumeration constants: Apple's sys/acl.h +// https://github.com/apple-oss-distributions/Libc/blob/main/include/sys/acl.h +// WRITE_DATA/APPEND_DATA are also ADD_FILE/ADD_SUBDIRECTORY, respectively. +const DARWIN_ACL_MUTATION_PERMISSIONS = [2, 4, 5, 6, 8, 10, 12, 13].map(bit => 1 << bit); +const DARWIN_ACL_MUTATION_MASK = DARWIN_ACL_MUTATION_PERMISSIONS.reduce((mask, permission) => mask | permission, 0); + +/** null means incomplete/unreadable enumeration; DENYs never conceal later ALLOWs. */ +export function darwinAclEntriesAreSafe(entries: readonly { tag: number; perms: number }[] | null): boolean { + return entries !== null && entries.every(({ tag, perms }) => + tag === 2 || (tag === 1 && (perms & DARWIN_ACL_MUTATION_MASK) === 0)); +} + +/** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ function macHasNoManagedCodexPreferences(paths: readonly string[]): boolean { let library: { close(): void } | undefined; let aclLibrary: { close(): void } | undefined; @@ -135,23 +148,44 @@ function macHasNoManagedCodexPreferences(paths: readonly string[]): boolean { const acl = dlopen("/usr/lib/libSystem.B.dylib", { acl_get_file: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr }, acl_get_entry: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr], returns: FFIType.i32 }, + acl_get_tag_type: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + acl_get_permset: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + acl_get_perm_np: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 }, acl_valid: { args: [FFIType.ptr], returns: FFIType.i32 }, acl_free: { args: [FFIType.ptr], returns: FFIType.i32 }, __error: { args: [], returns: FFIType.ptr }, }); aclLibrary = acl; for (const path of paths) { - const bytes = Buffer.from(path + "\0"), entry = Buffer.alloc(8); + const bytes = Buffer.from(path + "\0"), entry = Buffer.alloc(8), tag = Buffer.alloc(4), permset = Buffer.alloc(8); const descriptor = acl.symbols.acl_get_file(ptr(bytes), 0x100); // ACL_TYPE_EXTENDED if (!descriptor) return false; try { - // Deny all extended ACLs, including unknown ACL semantics; mode bits alone are insufficient. const valid = acl.symbols.acl_valid(descriptor); if (valid !== 0) return false; - const found = acl.symbols.acl_get_entry(descriptor, 0, ptr(entry)); // ACL_FIRST_ENTRY - const errno = acl.symbols.__error(); - if (!errno || !darwinAclInspectionIsEmpty(valid, found, read.i32(errno))) return false; - } finally { acl.symbols.acl_free(descriptor); } + const entries: { tag: number; perms: number }[] = []; + for (let id = 0; ; id = -1) { // ACL_FIRST_ENTRY=0, ACL_NEXT_ENTRY=-1 + const found = acl.symbols.acl_get_entry(descriptor, id, ptr(entry)); + if (found !== 0) { + const errno = acl.symbols.__error(); + if (!errno || !darwinAclInspectionIsEmpty(valid, found, read.i32(errno))) return false; + break; + } + const entryPointer = read.ptr(ptr(entry)) as import("bun:ffi").Pointer; + if (!entryPointer || acl.symbols.acl_get_tag_type(entryPointer, ptr(tag)) !== 0 + || acl.symbols.acl_get_permset(entryPointer, ptr(permset)) !== 0) return false; + const permissions = read.ptr(ptr(permset)) as import("bun:ffi").Pointer; + if (!permissions) return false; + let perms = 0; + for (const permission of DARWIN_ACL_MUTATION_PERMISSIONS) { + const granted = acl.symbols.acl_get_perm_np(permissions, permission); + if (granted !== 0 && granted !== 1) return false; + if (granted === 1) perms |= permission; + } + entries.push({ tag: tag.readInt32LE(), perms }); + } + if (!darwinAclEntriesAreSafe(entries)) return false; + } finally { if (acl.symbols.acl_free(descriptor) !== 0) return false; } } const core = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", { CFStringCreateWithCString: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.ptr }, diff --git a/tests/codex-integration/codex-launcher-darwin-acl.test.ts b/tests/codex-integration/codex-launcher-darwin-acl.test.ts new file mode 100644 index 00000000000..f428753c2f4 --- /dev/null +++ b/tests/codex-integration/codex-launcher-darwin-acl.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as ownership from "../../src/cli/codex-launch-ownership"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("Codex launcher Darwin ACL mutation admission", () => { + test("empty ACLs are safe", () => { + expect(ownership.darwinAclEntriesAreSafe([])).toBe(true); + }); + test("deny-only ACLs are safe", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 2, perms: 0xffffffff }])).toBe(true); + }); + test("read-only allow ACLs are safe", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 1, perms: (1 << 1) | (1 << 3) | (1 << 7) | (1 << 9) | (1 << 11) | (1 << 20) }])).toBe(true); + }); + + // Apple sys/acl.h: aliases ADD_FILE/ADD_SUBDIRECTORY share WRITE_DATA/APPEND_DATA bits. + for (const [name, bit] of [ + ["WRITE_DATA / ADD_FILE", 2], ["DELETE", 4], ["APPEND_DATA / ADD_SUBDIRECTORY", 5], + ["DELETE_CHILD", 6], ["WRITE_ATTRIBUTES", 8], ["WRITE_EXTATTRIBUTES", 10], + ["WRITE_SECURITY", 12], ["CHANGE_OWNER", 13], + ] as const) { + test(`allow ${name} refuses`, () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 1, perms: 1 << bit }])).toBe(false); + }); + } + + test("a deny entry cannot hide a subsequent mutating allow", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 2, perms: 1 << 2 }, { tag: 1, perms: 1 << 2 }])).toBe(false); + }); + + test("unknown tags and incomplete enumeration refuse", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 0, perms: 0 }])).toBe(false); + expect(ownership.darwinAclEntriesAreSafe([{ tag: 3, perms: 0 }])).toBe(false); + expect(ownership.darwinAclEntriesAreSafe(null)).toBe(false); + }); + + test.skipIf(process.platform !== "darwin")("native deny-delete ACL accepts; a later allow-write ACL refuses", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-darwin-acl-"))); + const home = join(root, "codex"); + try { + mkdirSync(home); chmodSync(root, 0o700); chmodSync(home, 0o700); + writeFileSync(join(home, "config.toml"), 'model = "fixture"\n', { mode: 0o600 }); + const chmodAcl = (entry: string) => { + const result = Bun.spawnSync(["/bin/chmod", "+a", entry, root], { stdout: "pipe", stderr: "pipe" }); + expect(result.success).toBe(true); + }; + chmodAcl("everyone deny delete"); + expect(ownership.codexLaunchHomeIsOwned(home)).toBe(true); + chmodAcl("everyone allow write"); + expect(ownership.codexLaunchHomeIsOwned(home)).toBe(false); + } finally { removeTreeWithRetry(root); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 21e9dc75ed0..432c6f05cae 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -591,6 +591,7 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", "codex-launcher-diagnostics.test.ts": "codex-integration", + "codex-launcher-darwin-acl.test.ts": "codex-integration", "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", From 81e3c9b13fbec4139f468797a6b84006b0c86250 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:04:09 +0900 Subject: [PATCH 14/18] docs(cli): describe Codex token admission and fail-closed shim launch --- docs-site/src/content/docs/fr/reference/cli/agents.md | 6 +++--- docs-site/src/content/docs/ja/reference/cli/agents.md | 6 +++--- docs-site/src/content/docs/ko/reference/cli/agents.md | 6 +++--- docs-site/src/content/docs/reference/cli/agents.md | 6 +++--- docs-site/src/content/docs/ru/reference/cli/agents.md | 6 +++--- docs-site/src/content/docs/tr/reference/cli/agents.md | 6 +++--- .../src/content/docs/zh-cn/reference/cli/agents.md | 6 +++--- .../src/content/docs/zh-tw/reference/cli/agents.md | 6 +++--- src/cli/registry.ts | 10 ++++++---- structure/cli-management.md | 4 ++-- structure/codex-home.md | 2 +- 11 files changed, 33 insertions(+), 31 deletions(-) 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 140b8a85028..d7a613a731f 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -179,9 +179,9 @@ Vous pouvez aussi fournir la clé vous-même, par exemple en exportant `OPENCODE Placez `--` avant une invite d’un seul mot pour éviter la sélection d’une sous-commande Codex inconnue. -L’espace de configuration machine doit empêcher un autre utilisateur de créer les fichiers système absents. Un espace Windows ProgramData/OpenAI/Codex non protégé bloque l’injection sans créer de répertoire. macOS refuse les ACL étendues, même avec des bits de mode sûrs. +L’espace de configuration machine doit empêcher un autre utilisateur de créer les fichiers système absents. Un espace Windows ProgramData/OpenAI/Codex non protégé bloque l’injection sans créer de répertoire. macOS accepte les ACL de refus seules et les autorisations en lecture seule. Toute autorisation d’écriture, ajout, suppression, modification d’attributs, de sécurité ou de propriétaire est refusée, même après un refus. Les tags inconnus et erreurs d’inspection entraînent un refus. -L’injection d’un nouveau jeton de service est refusée par défaut. Elle exige le fournisseur loopback géré exact, uniquement les clés de configuration auditées et un lancement interactif ou `exec`. Les options ou clés inconnues, `--oss`, `--local-provider`, toute surcharge `-c`/`--config` ou de profil, les sessions restaurées (`resume`/`fork`), les fournisseurs supplémentaires, serveurs MCP, hooks, commandes de notification, configurations de projet et couches supplémentaires system/managed/requirements/authentification empêchent l’injection. Les variables non vides `CODEX_*` (sauf `CODEX_HOME` canonique), `OPENAI_*` et `AZURE_OPENAI_*` l’empêchent aussi. Les arguments et identifiants explicitement exportés sont conservés ; stderr explique le refus. +Un nouveau jeton de service est chargé uniquement si argv respecte la liste ci-dessous, si aucune variable non vide de proxy HTTP(S)/ALL ou `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` ne surcharge l’environnement (`CODEX_HOME` canonique est permis), et si les espaces home/config/système passent les contrôles de propriété. La configuration doit contenir uniquement les clés auditées et le fournisseur loopback géré exact, sans autre consommateur de clé ni couche de configuration. Tout argument hors liste, dont `-c`/`--config`, `-p`/`--profile`, les options inconnues et `resume`/`fork`, retient le jeton avec un diagnostic sans valeurs ; les identifiants explicites et les arguments sont préservés. Le répertoire canonique et `config.toml` doivent appartenir à l’utilisateur courant. POSIX refuse les écritures group/other ; Windows vérifie le SID propriétaire et refuse les droits d’écriture DACL hors utilisateur courant, SYSTEM et Administrators. Les droits de remplacement des ancêtres sont vérifiés. macOS exige aussi l’absence de préférences Codex forcées. Ces contrôles n’isolent pas un processus malveillant du même utilisateur ou d’un administrateur. @@ -198,7 +198,7 @@ Le lanceur tente de démarrer le proxy puis transmet les arguments d’origine Un `OPENCODEX_API_AUTH_TOKEN` absent est lu dans le `service-api-token` de cette installation uniquement si toute la configuration de transport du provider sélectionné correspond au bloc géré pour son écouteur loopback ou sa connexion client locale enregistrée. Le nom et les commentaires ne suffisent pas. Destinations inconnues ou distantes, profile par défaut, configuration de projet, `OPENAI_BASE_URL` et options transmises `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` désactivent cette lecture. Les identifiants exportés et les arguments sont conservés ; les remplacements du fichier et les collisions avec les jetons d’administration sont refusés. -Un shim dont la provenance est vérifiée lance directement son Codex sous-jacent. Sinon, les wrappers CMD Windows doivent correspondre intégralement au modèle généré actuel avant l’exécution du chemin absolu sélectionné avec `OCX_SHIM_BYPASS=1`. Les lanceurs npm ordinaires restent pris en charge. Les wrappers gérés obsolètes, illisibles ou ambigus sont refusés avec un diagnostic : actualisez-les avec `ocx codex-shim install` ou choisissez l’exécutable natif. Ce lancement ne répare pas les shims. Le contrôle du contenu ne garantit ni la provenance du fichier sous-jacent Windows ni l’absence de remplacement concurrent du chemin. +Seul un wrapper autostart correspondant (`matched`) lance directement son Codex sous-jacent. Un état inconnu, y compris une exception d’inspection, exige un contournement vérifié ; les wrappers CMD Windows gérés doivent correspondre au modèle généré complet avant leur exécution avec `OCX_SHIM_BYPASS=1`. Les lanceurs npm ordinaires restent pris en charge. Un wrapper invérifiable ou obsolète entraîne le code de sortie 1 sans démarrer Codex ; actualisez-le avec `ocx codex-shim install`. Le lancement ne répare pas les shims ; le contrôle de contenu ne garantit ni la provenance du fichier Windows sous-jacent ni l’absence de remplacement concurrent. Aide du lanceur : `ocx help codex` ; aide de Codex : `ocx codex --help`. Le comportement ultérieur de `codex` seul est conservé. 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 b449d550656..5bd50f5acce 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -140,9 +140,9 @@ ocx claude desktop import [--apply] Validate and import JSON 単語一つのプロンプトの前に `--` を置き、未知の Codex サブコマンド選択を防いでください。 -設定ファイルがなくても、machine 設定パスに他の利用者が作成できない必要があります。Windows ProgramData/OpenAI/Codex の保護を確認できなければ注入を保留し、ディレクトリを作成しません。macOS は mode が安全でも拡張 ACL があれば拒否します。 +設定ファイルがなくても、machine 設定パスに他の利用者が作成できない必要があります。Windows ProgramData/OpenAI/Codex の保護を確認できなければ注入を保留し、ディレクトリを作成しません。macOS は拒否のみの ACL と読み取り専用の許可を受け入れます。書き込み・追記・削除・属性・セキュリティ・所有者の変更を許可するエントリは、先行する拒否があっても拒否します。不明なタグや検査エラーも拒否します。 -新しいサービストークンは既定で注入しません。検証済みの管理 loopback provider、監査済みキーのみの設定、対話実行または `exec` が必要です。未知のフラグ・設定キー、`--oss`、`--local-provider`、すべての `-c`/`--config` と profile 上書き、復元セッション(`resume`/`fork`)、追加 provider、MCP サーバー、hook、通知コマンド、プロジェクト設定、追加の system/managed/requirements/認証設定層があると注入を保留します。空でない `CODEX_*`(正規の `CODEX_HOME` を除く)、`OPENAI_*`、`AZURE_OPENAI_*` も同様です。元の引数と利用者が明示した資格情報は維持し、stderr で通知します。 +新しいサービストークンを読み込むのは、argv が下記の許可リスト内で、空でない HTTP(S)/ALL proxy や `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 環境変数の上書きがなく(正規の `CODEX_HOME` は許可)、ホーム・設定・システムの名前空間が所有権検査を通過した場合だけです。設定は監査済みキーと正確な管理 loopback provider のみで、追加の資格情報利用先や設定層がない必要があります。`-c`/`--config`、`-p`/`--profile`、未知のフラグ、`resume`/`fork` など許可外の引数ではトークンを保留し、値を含まない診断を出します。明示した資格情報と元の引数は維持します。 正規ホームと `config.toml` は現在の利用者所有である必要があります。POSIX は group/other の書き込みを、Windows は現在の利用者・SYSTEM・Administrators 以外への DACL 書き込み許可を拒否し、所有者 SID を確認します。祖先パスの置換権限も確認し、macOS は強制 Codex 設定 preference がないことも要求します。同じ利用者や管理者の悪意あるプロセスを隔離するものではありません。 @@ -159,7 +159,7 @@ ocx claude desktop import [--apply] Validate and import JSON 選択した provider の全転送設定が、このインストールのループバックリスナーまたは保存されたローカルクライアント接続向けの管理ブロックと一致する場合だけ、不足する `OPENCODEX_API_AUTH_TOKEN` をこのインストールの `service-api-token` から読みます。名前や注入コメントだけでは許可しません。不明・リモートの接続先、既定 profile、プロジェクト設定、`OPENAI_BASE_URL`、転送した `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` は自動読み込みを省略します。ユーザーがエクスポートした認証情報と引数を保持し、トークンファイルの上書きと管理トークンの衝突を拒否します。 -出所を検証した shim は背後の Codex を直接実行します。それ以外の Windows CMD wrapper は現在の生成テンプレート全体との一致が必要で、選択した絶対パスを `OCX_SHIM_BYPASS=1` で実行します。通常の npm ランチャーも使えます。古い、読めない、曖昧な管理 wrapper は診断を出して拒否します。`ocx codex-shim install` で更新するかネイティブ実行ファイルを選んでください。この起動で shim は修復しません。内容検査は Windows backing ファイルの出所や同時のパス置換を保証しません。 +一致を確認した(`matched`)autostart wrapper だけが背後の Codex を直接実行します。検査例外を含む不明な状態では検証済みのバイパスが必要で、管理対象の Windows CMD wrapper は生成テンプレート全体と一致した場合だけ `OCX_SHIM_BYPASS=1` で実行します。通常の npm ランチャーは利用できます。検証不能または古い wrapper は終了コード 1 で拒否し、Codex は開始しません。`ocx codex-shim install` で更新してください。この起動は shim を修復せず、内容検査は Windows backing ファイルの出所や並行したパス置換を保証しません。 ランチャーのヘルプは `ocx help codex`、Codex のヘルプは `ocx codex --help` です。後で通常の `codex` を起動する動作は変わりません。 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 d681dbd774f..8c6ae99e5a9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -172,9 +172,9 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 한 단어 프롬프트 앞에는 `--`를 넣어 알 수 없는 Codex 하위 명령 선택을 막으세요. -설정 파일이 없어도 machine 설정 경로에 다른 사용자가 파일을 만들 수 없어야 합니다. Windows ProgramData/OpenAI/Codex 경로의 보호를 증명할 수 없으면 자동 토큰을 보류하며 디렉터리를 만들지 않습니다. macOS에서는 mode 비트가 안전해도 확장 ACL이 있으면 거부합니다. +설정 파일이 없어도 machine 설정 경로에 다른 사용자가 파일을 만들 수 없어야 합니다. Windows ProgramData/OpenAI/Codex 경로의 보호를 증명할 수 없으면 자동 토큰을 보류하며 디렉터리를 만들지 않습니다. macOS는 거부 전용 ACL과 읽기 전용 허용 항목을 수락합니다. 쓰기·추가·삭제·속성·보안·소유자 변경을 허용하는 항목은 앞에 거부 항목이 있어도 거부합니다. 알 수 없는 태그나 검사 오류도 거부합니다. -새 서비스 토큰은 기본적으로 주입하지 않습니다. 정확한 관리 loopback provider, 검증된 키만 포함하는 설정, 대화형 실행 또는 `exec`가 모두 확인되어야 합니다. 알 수 없는 플래그·설정 키, `--oss`, `--local-provider`, 모든 `-c`/`--config` 및 profile 재정의, 세션 복원(`resume`/`fork`), 추가 provider, MCP 서버, hook, 알림 명령, 프로젝트 설정, 추가 system/managed/requirements/인증 설정층이 있으면 토큰을 보류합니다. 비어 있지 않은 `CODEX_*`(정규 `CODEX_HOME` 제외), `OPENAI_*`, `AZURE_OPENAI_*` 환경 변수도 주입을 막습니다. 원래 인자와 명시적으로 내보낸 사용자 토큰은 유지하며 stderr에 이유를 안내합니다. +새 서비스 토큰은 argv가 아래 허용 목록 안에 있고, 비어 있지 않은 HTTP(S)/ALL proxy 및 `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 환경 재정의가 없으며(정규 `CODEX_HOME`은 허용), 홈·설정·시스템 경로가 소유권 검사를 통과할 때만 읽습니다. 설정에는 검증된 키와 정확한 관리 loopback provider만 있어야 하며 추가 자격 증명 소비자나 설정층이 없어야 합니다. `-c`/`--config`, `-p`/`--profile`, 알 수 없는 플래그, `resume`/`fork` 등 허용 목록 밖의 인자는 토큰을 보류하고 값 없는 진단을 출력합니다. 명시적인 사용자 자격 증명과 원래 인자는 유지합니다. 정규 홈 디렉터리와 `config.toml`은 현재 사용자 소유여야 합니다. POSIX에서는 group/other 쓰기를, Windows에서는 현재 사용자·SYSTEM·Administrators 이외의 DACL 쓰기 권한을 거부하고 소유자 SID를 확인합니다. 상위 경로 교체 권한도 확인합니다. macOS에서는 강제 Codex 설정 preference가 없어야 합니다. 같은 사용자나 관리자의 악성 프로세스로부터 격리하는 기능은 아닙니다. @@ -191,7 +191,7 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 선택한 provider의 전체 전송 설정이 이 설치의 로컬 리스너 또는 저장된 로컬 클라이언트 연결에 대해 생성한 관리 블록과 일치할 때만, 누락된 `OPENCODEX_API_AUTH_TOKEN`을 이 설치의 `service-api-token`에서 읽습니다. 이름이나 주입 주석만으로는 허용하지 않습니다. 알 수 없거나 원격인 목적지, 기본 profile, 프로젝트 설정, `OPENAI_BASE_URL`, 전달된 `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote`는 자동 읽기를 생략합니다. 사용자가 export한 자격 증명과 인자는 보존하며, 토큰 파일 경로 재정의와 관리 토큰 충돌은 거부합니다. -출처가 검증된 shim은 뒤의 Codex를 직접 실행합니다. 그 외 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 선택된 절대 경로를 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처도 지원합니다. 오래되었거나 읽을 수 없거나 모호한 관리 wrapper는 진단 후 실행을 거부합니다. `ocx codex-shim install`로 갱신하거나 네이티브 실행 파일을 선택하세요. 이 실행은 shim을 자동 복구하지 않습니다. 내용 검사는 Windows backing 파일의 출처나 동시 경로 교체를 보장하지 않습니다. +일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다. 런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 01a83045ceb..7f0be73abe8 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -973,9 +973,9 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. -The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS refuses extended filesystem ACLs even with safe mode bits. +The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. -Fresh service-token injection is denied by default. It requires the exact managed loopback provider, a configuration containing only the audited keys, and a launch restricted to interactive mode or `exec`. Unknown flags and configuration keys, `--oss`, `--local-provider`, every `-c`/`--config` or profile override, restored sessions (`resume`/`fork`), additional providers, MCP servers, hooks, notification commands, project configuration, and additional system/managed/requirements/authentication layers withhold the token. Nonempty `CODEX_*` (except canonical `CODEX_HOME`), `OPENAI_*`, and `AZURE_OPENAI_*` environment controls also withhold it. Codex still receives the original arguments and a clear stderr note; explicit user credentials are preserved. +A fresh service token is loaded only when argv is within the allowlist below, the environment has no nonempty HTTP(S)/ALL proxy or `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` overrides (canonical `CODEX_HOME` is allowed), and the home/config/system namespaces pass ownership checks. The configuration must contain only audited keys and the exact managed loopback provider, with no additional credential consumers or configuration layers. Every unsupported argument, including `-c`/`--config`, `-p`/`--profile`, unknown flags and `resume`/`fork`, withholds the token with a value-free diagnostic; explicit user credentials and original arguments are preserved. The canonical home directory and `config.toml` must belong to the current user. POSIX denies group/other writes; Windows checks owner SID and rejects DACL write grants outside the current user, SYSTEM and Administrators. Ancestor replacement permissions are checked too. macOS additionally requires no forced Codex configuration preferences. These checks exclude other-user writers; they do not isolate a malicious process running as the same user or an administrator. @@ -992,7 +992,7 @@ Ensure the proxy is running, then launch Codex with the original arguments. Rout A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-api-token` only when the selected provider's complete transport configuration matches the managed block for the configured loopback listener or persisted local client connection. Names and injection comments alone grant no credential access. Unknown or remote destinations, default profiles, project configuration, `OPENAI_BASE_URL`, and forwarded `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, or `--remote` options skip automatic loading. User-exported credentials and arguments are preserved; token-file overrides cannot select a different credential, and management-token collisions are refused. -A provenance-verified shim uses its backing Codex directly. Otherwise Windows CMD wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Obsolete, unreadable, or ambiguous managed wrappers fail closed with a diagnostic; refresh them with `ocx codex-shim install` or select the native executable. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. +Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. 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 d2d7e1ad783..2c0953c8650 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -161,9 +161,9 @@ ocx claude desktop import [--apply] Validate and import JSON Перед подсказкой из одного слова укажите `--`, чтобы исключить выбор неизвестной подкоманды Codex. -Пространство машинной конфигурации должно запрещать другим пользователям создание отсутствующих системных файлов. Непроверенный Windows ProgramData/OpenAI/Codex запрещает передачу токена; каталог не создаётся. macOS запрещает расширенные ACL даже при безопасных битах режима. +Пространство машинной конфигурации должно запрещать другим пользователям создание отсутствующих системных файлов. Непроверенный Windows ProgramData/OpenAI/Codex запрещает передачу токена; каталог не создаётся. macOS допускает ACL только с запретами и разрешения только на чтение. Любое разрешение записи, добавления, удаления, изменения атрибутов, безопасности или владельца блокирует передачу даже после запрещающей записи. Неизвестные теги и ошибки проверки также блокируют её. -Новый служебный токен по умолчанию не передаётся. Нужны точный управляемый loopback-провайдер, только проверенные ключи конфигурации и интерактивный запуск либо `exec`. Неизвестные флаги и ключи, `--oss`, `--local-provider`, любые переопределения `-c`/`--config` и профиля, восстановление сессий (`resume`/`fork`), дополнительные провайдеры, MCP-серверы, hooks, команды уведомлений, конфигурация проекта и дополнительные слои system/managed/requirements/аутентификации запрещают передачу. Непустые `CODEX_*` (кроме канонического `CODEX_HOME`), `OPENAI_*` и `AZURE_OPENAI_*` также запрещают её. Исходные аргументы и явно экспортированные пользователем токены сохраняются; причина выводится в stderr. +Новый служебный токен читается только если argv входит в список ниже, нет непустых переменных прокси HTTP(S)/ALL или переопределений `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` (канонический `CODEX_HOME` разрешён), а пространства home/config/system проходят проверку владельца. Нужны только проверенные ключи и точный управляемый loopback-провайдер, без дополнительных потребителей учётных данных и слоёв конфигурации. Любой аргумент вне списка, включая `-c`/`--config`, `-p`/`--profile`, неизвестные флаги и `resume`/`fork`, блокирует токен с диагностикой без значений; явные учётные данные и исходные аргументы сохраняются. Канонический каталог и `config.toml` должны принадлежать текущему пользователю. POSIX запрещает запись group/other; Windows проверяет SID владельца и запрещает запись DACL всем, кроме текущего пользователя, SYSTEM и Administrators. Проверяются права замены родительских путей. На macOS также не должно быть принудительных настроек Codex. Эти проверки не изолируют вредоносный процесс того же пользователя или администратора. @@ -180,7 +180,7 @@ ocx claude desktop import [--apply] Validate and import JSON Отсутствующий `OPENCODEX_API_AUTH_TOKEN` читается из `service-api-token` этой установки только при полном совпадении транспортной конфигурации выбранного provider с управляемым блоком для настроенного loopback-слушателя или сохранённого локального клиентского подключения. Имя и комментарии не дают разрешения. Неизвестные или удалённые адреса, profile по умолчанию, конфигурация проекта, `OPENAI_BASE_URL` и переданные `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` отключают автоматическое чтение. Экспортированные пользователем учётные данные и аргументы сохраняются; подмена файла токена и совпадения с административными токенами отклоняются. -Shim с проверенным происхождением запускает исходный Codex напрямую. Иначе Windows CMD wrapper должен полностью совпадать с текущим генерируемым шаблоном; выбранный абсолютный путь запускается с `OCX_SHIM_BYPASS=1`. Обычные npm-лаунчеры поддерживаются. Устаревший, нечитаемый или неоднозначный управляемый wrapper отклоняется с диагностикой: обновите его через `ocx codex-shim install` или выберите нативный исполняемый файл. Этот запуск не ремонтирует shim. Проверка содержимого не гарантирует происхождение backing-файла Windows и не предотвращает одновременную замену пути. +Только совпавшая (`matched`) обёртка autostart запускает свой базовый Codex напрямую. Неизвестное состояние, включая исключение проверки, требует проверенного обхода; управляемая CMD-обёртка Windows должна полностью совпадать с текущим шаблоном для запуска с `OCX_SHIM_BYPASS=1`. Обычные npm-лаунчеры поддерживаются. Непроверяемая или устаревшая обёртка отклоняется с кодом 1 без запуска Codex; обновите её через `ocx codex-shim install`. Запуск не исправляет shim; проверка содержимого не доказывает происхождение базового файла Windows и не предотвращает одновременную замену пути. Справка лаунчера: `ocx help codex`; справка Codex: `ocx codex --help`. Последующий обычный запуск `codex` сохраняет прежнее поведение. 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 c753c76b52d..924b9c6a1ce 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -197,9 +197,9 @@ Alternatif olarak anahtarı kendiniz sağlayabilirsiniz; örneğin `OPENCODEX_AP Bilinmeyen bir Codex alt komutu seçilmesini önlemek için tek sözcüklü istemden önce `--` kullanın. -Makine yapılandırma alanı, diğer kullanıcıların eksik sistem dosyalarını oluşturmasını da engellemelidir. Korunmayan Windows ProgramData/OpenAI/Codex alanı otomatik belirteci engeller; dizin oluşturulmaz. macOS güvenli mod bitlerinde bile genişletilmiş ACL’leri reddeder. +Makine yapılandırma alanı, diğer kullanıcıların eksik sistem dosyalarını oluşturmasını da engellemelidir. Korunmayan Windows ProgramData/OpenAI/Codex alanı otomatik belirteci engeller; dizin oluşturulmaz. macOS yalnızca ret içeren ACL’leri ve salt okunur izinleri kabul eder. Yazma, ekleme, silme, öznitelik, güvenlik veya sahip değişikliği veren her izin, önce bir ret olsa da reddedilir. Bilinmeyen etiketler ve inceleme hataları da reddedilir. -Yeni hizmet belirteci varsayılan olarak eklenmez. Tam doğrulanmış yönetilen loopback sağlayıcısı, yalnızca denetlenmiş yapılandırma anahtarları ve etkileşimli başlatma veya `exec` gerekir. Bilinmeyen bayraklar ve anahtarlar, `--oss`, `--local-provider`, tüm `-c`/`--config` ve profil geçersiz kılmaları, geri yüklenen oturumlar (`resume`/`fork`), ek sağlayıcılar, MCP sunucuları, hook ve bildirim komutları, proje yapılandırması ve ek system/managed/requirements/kimlik doğrulama katmanları belirteci engeller. Boş olmayan `CODEX_*` (kanonik `CODEX_HOME` hariç), `OPENAI_*` ve `AZURE_OPENAI_*` da engeller. Özgün argümanlar ve kullanıcının açıkça dışa aktardığı kimlik bilgileri korunur; stderr açıklama verir. +Yeni hizmet belirteci yalnızca argv aşağıdaki izin listesinde olduğunda, boş olmayan HTTP(S)/ALL proxy veya `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` ortam geçersiz kılmaları bulunmadığında (kanonik `CODEX_HOME` izinlidir) ve home/config/system ad alanları sahiplik denetimlerini geçtiğinde okunur. Yapılandırma yalnızca denetlenmiş anahtarları ve tam yönetilen loopback sağlayıcısını içermeli; ek kimlik bilgisi tüketicisi veya yapılandırma katmanı bulunmamalıdır. `-c`/`--config`, `-p`/`--profile`, bilinmeyen bayraklar ve `resume`/`fork` dahil liste dışındaki her argüman belirteci engeller ve değer içermeyen tanı üretir; açık kimlik bilgileri ve özgün argümanlar korunur. Kanonik dizin ve `config.toml` geçerli kullanıcıya ait olmalıdır. POSIX group/other yazmasını reddeder; Windows sahip SID’sini doğrular ve geçerli kullanıcı, SYSTEM ve Administrators dışındaki DACL yazma izinlerini reddeder. Üst yol değiştirme izinleri de denetlenir. macOS ayrıca zorunlu Codex tercihlerinin bulunmamasını gerektirir. Bu kontroller aynı kullanıcı veya yönetici olarak çalışan kötü amaçlı işlemleri yalıtmaz. @@ -216,7 +216,7 @@ Başlatıcı proxy'yi başlatmayı dener ve özgün argümanları Codex'e aktar Eksik `OPENCODEX_API_AUTH_TOKEN`, yalnızca seçili provider'ın tüm taşıma yapılandırması bu kurulumun yapılandırılmış loopback dinleyicisi veya kayıtlı yerel istemci bağlantısı için yönetilen blokla eşleştiğinde bu kurulumun `service-api-token` dosyasından okunur. Adlar ve açıklamalar yetki vermez. Bilinmeyen veya uzak hedefler, varsayılan profile, proje yapılandırması, `OPENAI_BASE_URL` ve iletilen `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` otomatik okumayı atlar. Kullanıcının dışa aktardığı kimlik bilgileri ve argümanlar korunur; belirteç dosyası geçersiz kılmaları ve yönetim belirteci çakışmaları reddedilir. -Kaynağı doğrulanmış shim arkasındaki Codex'i doğrudan çalıştırır. Diğer Windows CMD wrapper'ları, seçilen mutlak yol `OCX_SHIM_BYPASS=1` ile çalıştırılmadan önce güncel üretilen şablonla tamamen eşleşmelidir. Normal npm başlatıcıları desteklenir. Eski, okunamayan veya belirsiz yönetilen wrapper tanı mesajıyla reddedilir; `ocx codex-shim install` ile yenileyin veya yerel yürütülebilir dosyayı seçin. Bu başlatma shim'i onarmaz. İçerik denetimi Windows backing dosyasının kaynağını doğrulamaz ve eşzamanlı yol değişimini engellemez. +Yalnızca eşleşen (`matched`) autostart wrapper arkasındaki Codex’i doğrudan başlatır. İnceleme istisnası dahil bilinmeyen durum doğrulanmış bypass gerektirir; yönetilen Windows CMD wrapper’ları `OCX_SHIM_BYPASS=1` ile çalışmadan önce güncel şablonun tamamıyla eşleşmelidir. Normal npm başlatıcıları desteklenir. Doğrulanamayan veya eski wrapper, Codex başlamadan çıkış kodu 1 ile reddedilir; `ocx codex-shim install` ile yenileyin. Bu başlatma shim’i onarmaz; içerik denetimi Windows backing dosyasının kaynağını veya eşzamanlı yol değişimini güvenceye almaz. Başlatıcı yardımı: `ocx help codex`; Codex yardımı: `ocx codex --help`. Sonraki düz `codex` çalıştırmasının davranışı korunur. 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 6825b7a421b..405529dc3e3 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 @@ -144,9 +144,9 @@ ocx claude desktop import [--apply] Validate and import JSON 单词提示前请加 `--`,以免选择未知的 Codex 子命令。 -即使系统配置文件不存在,也必须阻止其他用户创建这些文件。无法验证 Windows ProgramData/OpenAI/Codex 路径保护时不自动注入令牌,也不创建目录。macOS 即使 mode 位安全,也会拒绝扩展 ACL。 +即使系统配置文件不存在,也必须阻止其他用户创建这些文件。无法验证 Windows ProgramData/OpenAI/Codex 路径保护时不自动注入令牌,也不创建目录。macOS 接受仅拒绝的 ACL 和只读允许项。任何允许写入、追加、删除、修改属性、安全或所有者的项都会被拒绝,即使前面已有拒绝项。未知标签和检查错误也会被拒绝。 -新服务令牌默认不注入。必须确认使用准确的托管 loopback provider、仅含已审核键的配置,以及交互启动或 `exec`。未知标志或配置键、`--oss`、`--local-provider`、任何 `-c`/`--config` 或 profile 覆盖、恢复会话(`resume`/`fork`)、额外 provider、MCP 服务器、hook、通知命令、项目配置,以及额外 system/managed/requirements/认证配置层都会阻止注入。非空 `CODEX_*`(规范 `CODEX_HOME` 除外)、`OPENAI_*`、`AZURE_OPENAI_*` 环境变量也会阻止注入。原始参数和用户明确导出的凭据保持不变,stderr 会说明原因。 +只有 argv 位于下方允许列表内、环境中没有非空 HTTP(S)/ALL proxy 或 `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 覆盖(允许规范的 `CODEX_HOME`),且主目录、配置和系统命名空间通过所有权检查时,才读取新服务令牌。配置必须只包含已审核键和准确的托管 loopback provider,没有额外凭据消费者或配置层。`-c`/`--config`、`-p`/`--profile`、未知标志和 `resume`/`fork` 等列表外参数都会暂缓令牌,并输出不含值的诊断;明确提供的用户凭据和原始参数保持不变。 规范主目录和 `config.toml` 必须属于当前用户。POSIX 拒绝 group/other 写权限;Windows 验证所有者 SID,并拒绝当前用户、SYSTEM、Administrators 之外的 DACL 写入授权。还会检查祖先路径替换权限。macOS 还要求不存在强制 Codex 配置 preference。这些检查不能隔离同一用户或管理员运行的恶意进程。 @@ -163,7 +163,7 @@ ocx claude desktop import [--apply] Validate and import JSON 只有所选 provider 的完整传输配置与本次安装的回环监听器或已保存的本地客户端连接对应的管理块一致时,才从本次安装的 `service-api-token` 读取缺失的 `OPENCODEX_API_AUTH_TOKEN`。名称或注入注释本身不构成授权。未知或远程目标、默认 profile、项目配置、`OPENAI_BASE_URL` 以及传入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都会跳过自动读取。保留用户导出的凭据和参数,拒绝令牌文件覆盖和管理令牌冲突。 -来源已验证的 shim 直接运行其背后的 Codex。其他 Windows CMD wrapper 必须与当前生成模板完整一致,才以 `OCX_SHIM_BYPASS=1` 执行选定的绝对路径。普通 npm 启动器仍受支持。过时、无法读取或含糊的管理 wrapper 会输出诊断并拒绝执行;请用 `ocx codex-shim install` 更新或选择原生可执行文件。本次启动不修复 shim。内容检查不保证 Windows backing 文件来源,也不防止并发路径替换。 +只有匹配成功(`matched`)的 autostart wrapper 才直接启动背后的 Codex。未知状态(包括检查异常)必须通过 bypass 验证;托管 Windows CMD wrapper 必须与当前生成模板完整一致,才以 `OCX_SHIM_BYPASS=1` 执行。普通 npm 启动器仍受支持。无法验证或过时的 wrapper 以退出码 1 拒绝,且不启动 Codex;请用 `ocx codex-shim install` 更新。本次启动不修复 shim;内容检查不证明 Windows backing 文件来源,也不防止并发路径替换。 启动器帮助为 `ocx help codex`,Codex 帮助为 `ocx codex --help`。以后直接运行普通 `codex` 的行为保持不变。 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 4b3bf964885..ab30eedb539 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 @@ -141,9 +141,9 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON 單字提示前請加 `--`,以免選取未知的 Codex 子命令。 -即使系統設定檔不存在,也必須阻止其他使用者建立這些檔案。無法驗證 Windows ProgramData/OpenAI/Codex 路徑保護時不自動注入權杖,也不建立目錄。macOS 即使 mode 位元安全,也會拒絕擴充 ACL。 +即使系統設定檔不存在,也必須阻止其他使用者建立這些檔案。無法驗證 Windows ProgramData/OpenAI/Codex 路徑保護時不自動注入權杖,也不建立目錄。macOS 接受僅拒絕的 ACL 與唯讀允許項。任何允許寫入、附加、刪除、修改屬性、安全或擁有者的項目都會被拒絕,即使前面已有拒絕項。未知標籤與檢查錯誤也會被拒絕。 -新服務權杖預設不注入。必須確認使用準確的受管 loopback provider、僅含已稽核鍵的設定,以及互動啟動或 `exec`。未知旗標或設定鍵、`--oss`、`--local-provider`、任何 `-c`/`--config` 或 profile 覆寫、還原工作階段(`resume`/`fork`)、額外 provider、MCP 伺服器、hook、通知命令、專案設定,以及額外 system/managed/requirements/驗證設定層都會阻止注入。非空 `CODEX_*`(標準 `CODEX_HOME` 除外)、`OPENAI_*`、`AZURE_OPENAI_*` 環境變數也會阻止注入。原始引數與使用者明確匯出的憑證維持不變,stderr 會說明原因。 +只有 argv 位於下方允許清單內、環境中沒有非空 HTTP(S)/ALL proxy 或 `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 覆寫(允許標準的 `CODEX_HOME`),且主目錄、設定與系統命名空間通過擁有權檢查時,才讀取新服務權杖。設定必須只包含已稽核鍵與準確的受管 loopback provider,沒有額外憑證消費者或設定層。`-c`/`--config`、`-p`/`--profile`、未知旗標和 `resume`/`fork` 等清單外引數都會暫緩權杖,並輸出不含值的診斷;明確提供的使用者憑證與原始引數保持不變。 標準主目錄與 `config.toml` 必須屬於目前使用者。POSIX 拒絕 group/other 寫入權限;Windows 驗證擁有者 SID,並拒絕目前使用者、SYSTEM、Administrators 以外的 DACL 寫入授權。也會檢查祖先路徑替換權限。macOS 還要求不存在強制 Codex 設定 preference。這些檢查無法隔離相同使用者或管理員執行的惡意程序。 @@ -160,7 +160,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON 只有所選 provider 的完整傳輸設定與本次安裝的回環監聽器或已儲存的本機用戶端連線對應的管理區塊一致時,才從本次安裝的 `service-api-token` 讀取缺少的 `OPENCODEX_API_AUTH_TOKEN`。名稱或注入註解本身不構成授權。未知或遠端目的地、預設 profile、專案設定、`OPENAI_BASE_URL` 及傳入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都會略過自動讀取。保留使用者匯出的憑證與引數,拒絕權杖檔案覆寫和管理權杖衝突。 -來源已驗證的 shim 直接執行背後的 Codex。其他 Windows CMD wrapper 必須與目前產生的範本完整一致,才以 `OCX_SHIM_BYPASS=1` 執行選定的絕對路徑。一般 npm 啟動器仍受支援。過時、無法讀取或模糊的管理 wrapper 會輸出診斷並拒絕執行;請用 `ocx codex-shim install` 更新或選擇原生執行檔。本次啟動不修復 shim。內容檢查不保證 Windows backing 檔案來源,也不防止並行路徑替換。 +只有比對成功(`matched`)的 autostart wrapper 才直接啟動背後的 Codex。未知狀態(包括檢查例外)必須通過 bypass 驗證;受管 Windows CMD wrapper 必須與目前產生的範本完整一致,才以 `OCX_SHIM_BYPASS=1` 執行。一般 npm 啟動器仍受支援。無法驗證或過時的 wrapper 以結束代碼 1 拒絕,且不啟動 Codex;請用 `ocx codex-shim install` 更新。本次啟動不修復 shim;內容檢查不證明 Windows backing 檔案來源,也不防止並行路徑替換。 啟動器說明為 `ocx help codex`,Codex 說明為 `ocx codex --help`。之後直接執行一般 `codex` 的行為保持不變。 diff --git a/src/cli/registry.ts b/src/cli/registry.ts index e374c472b87..f4077a0905d 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -619,10 +619,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Foreign gateway routing (custom-local/custom-remote): warning, native exec, that route untouched.", "Unverifiable routing: warning, and the proxy ensure is still attempted.", "Unknown routing never adds a service token; token-file overrides and management credentials are not used for client injection.", - "Forwarded -c/--config or -p/--profile overrides also skip automatic service-token injection.", - "If the ensure fails, one stderr line is printed and `codex` launches anyway.", - "When `codex` is the autostart shim, its backing Codex is launched directly so the shim", - "never repeats the ensure or overrides the routing decision.", + "A fresh token is loaded only for allowlisted argv, no HTTP(S)/ALL proxy or CODEX_*/OPENAI_*/AZURE_OPENAI_* env overrides", + "(canonical CODEX_HOME is allowed), and verified home/config/system ownership and managed transport.", + "Other inputs (including -c/--config, -p/--profile and resume/fork) withhold it with a value-free diagnostic.", + "If the ensure fails, one stderr line is printed and launch continues subject to executable verification.", + "Only a matched autostart wrapper launches its backing Codex directly; unknown state requires a verified bypass.", + "Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install`.", "Stop using `ocx codex` and plain `codex` behaves exactly as before.", ], }, diff --git a/structure/cli-management.md b/structure/cli-management.md index 3eafc41e74d..c37e0dcfc07 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -30,10 +30,10 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A recorded wrapper uses its backing directly; unknown Windows CMD wrappers require complete generated-template equality via `src/cli/codex-launch-command.ts`, then execute the selected absolute path with bypass. Obsolete, unreadable or ambiguous wrappers and lookup/inspection errors refuse launch without printing exception contents. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. -Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS rejects every extended filesystem ACL, even when ordinary owner/mode bits look safe. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Native checks use production defaults. +Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS validates the ACL and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. The pure policy and Darwin-gated native ACL regression live in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/codex-home.md b/structure/codex-home.md index 18062bcf23d..3d424ad3124 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -57,7 +57,7 @@ $CODEX_HOME/.opencodex-native-main-profiles/ ``` Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on -the resolved `CODEX_HOME`. +the resolved `CODEX_HOME`. For credential delivery, `src/cli/codex-launch-auth.ts` excludes an ancestor `.codex/config.toml` only when its realpath matches the home config, including a symlinked user `.codex`; distinct files or failed identity resolution withhold the token. Namespace ownership follows the [launcher contract](cli-management.md#head-and-help-navigation). Log Guard reclaim in `src/codex/log-guard/maintenance.ts` refuses a non-regular or redirected database path. It compares canonical path and full-width filesystem device and file identity From 0fec56a89f491a8da9d17b486cab78cc5c07fe95 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:04:14 +0900 Subject: [PATCH 15/18] fix(cli): protect same-home project aliases before token admission Require ownership and replacement resistance for each lexical project alias, its ancestors, and every intermediate symlink target before the same-file exemption can authorize reading the local proxy service token. Inspection failures retain the project refusal. Reuse the Windows ACL subprocess with an arbitrary path-chain mode and inspect Darwin link ACLs without following the final link. Preserve link target dot segments during the lexical walk. The canonical-home-only alternative cannot preserve the protected ~/.codex symlink case: resolveCodexLaunchHome canonicalizes the supplied path before codexLaunchHomeIsOwned sees it, so there is no already-validated lexical alias chain to reuse. Validate that chain explicitly instead of assuming its safety. Add writable alias parent, ancestor, redirect and Windows link-entry regressions, plus failed-inspection coverage and a relative home-link control. Against the original 81e3c9b13 auth/ownership modules, the six-case subset has 1 pass and 5 fail; the five negatives each read the token once. Final focused validation has 205 pass, 4 macOS-only skips, 0 fail (1171 expectations). Typecheck, structure:check, privacy:scan and the file-size ratchet pass. Full suite, test:changed, builds, native POSIX/macOS execution and hosted CI were not run under this implementation lane's host limits. --- src/cli/codex-launch-auth.ts | 9 +-- src/cli/codex-launch-ownership.ts | 59 +++++++++++++--- structure/cli-management.md | 2 + structure/codex-home.md | 2 +- .../codex-launcher-env.test.ts | 67 +++++++++++++++++-- 5 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index b6b814f5fec..9d825b81454 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -8,7 +8,7 @@ import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; import { readClientConnectionState } from "../client/state"; import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; -import { codexLaunchHomeIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; +import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; /** Resolve once; the credential verifier and child must use this exact directory. */ @@ -70,9 +70,10 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac const projectConfig = join(directory, ".codex", "config.toml"); try { lstatSync(projectConfig); - // A symlinked ~/.codex can name this very home config. Only proven file - // identity excludes an existing candidate from the project-layer check. - try { if (realpathSync(projectConfig) !== realpathSync(join(home, "config.toml"))) return refuse("project"); } + // Same-file aliases qualify only when their lexical entries and redirect + // targets are protected against replacement by another principal. + try { if (realpathSync(projectConfig) !== realpathSync(join(home, "config.toml")) + || !codexLaunchProjectAliasIsOwned(projectConfig)) return refuse("project"); } catch { return refuse("project"); } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } // A nested .git need not be Codex's root: custom project_root_markers can select diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts index 2f5d97aeaa4..e705e0b20fa 100644 --- a/src/cli/codex-launch-ownership.ts +++ b/src/cli/codex-launch-ownership.ts @@ -1,5 +1,5 @@ -import { lstatSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { lstatSync, readlinkSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; @@ -14,6 +14,12 @@ try { $sections=[System.Security.AccessControl.AccessControlSections]'Owner,Access' $paths=@($env:OCX_CODEX_OWNED_HOME,[System.IO.Path]::Combine($env:OCX_CODEX_OWNED_HOME,'config.toml')) $exactOwners=2 + $chain=$env:OCX_CODEX_PATH_CHAIN -eq '1' + if ($chain) { + $paths=ConvertFrom-Json -InputObject $env:OCX_CODEX_OWNED_PATHS + if ($paths.Count -eq 0) { exit 1 } + $exactOwners=0 + } if ($env:OCX_CODEX_NAMESPACE_CHECK -eq '1') { $systemDir=$env:OCX_CODEX_NAMESPACE_FIXTURE if ([string]::IsNullOrEmpty($systemDir)) { @@ -42,7 +48,8 @@ try { while ($null -ne $parent) { $paths+= $parent.FullName; $parent=$parent.Parent } for ($i=0;$i -lt $paths.Count;$i++) { $path=$paths[$i] - if (([System.IO.File]::GetAttributes($path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { exit 1 } + $reparse=([System.IO.File]::GetAttributes($path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + if (!$chain -and $reparse) { exit 1 } if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path,$sections) } else { $acl=[System.Security.AccessControl.FileSecurity]::new($path,$sections) } $raw=[System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(),0) @@ -61,7 +68,8 @@ try { # Include generic writes/all, DELETE, WRITE_DAC, WRITE_OWNER, and directory DELETE_CHILD. $mask=[int64]$ace.AccessMask -band 4294967295 $mutation=1343029590 - if ($i -ge 2 -or ($exactOwners -eq 0 -and $i -ge 1)) { $mutation=1343029312 } + if (($chain -and $i -gt 0 -and !$reparse) -or + (!$chain -and ($i -ge 2 -or ($exactOwners -eq 0 -and $i -ge 1)))) { $mutation=1343029312 } if (($mask -band $mutation) -ne 0) { if ($exactOwners -eq 0) { exit 2 } exit 1 @@ -73,20 +81,51 @@ try { `; const ENCODED_SCRIPT = Buffer.from(SCRIPT, "utf16le").toString("base64"); -function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirectory?: string, report?: CodexTokenRefusalReporter): boolean { +function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirectory?: string, report?: CodexTokenRefusalReporter, paths?: readonly string[]): boolean { // Remove case aliases before constructing a Windows environment block. These // private protocol inputs must come from this call, never the caller's shell. const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => - !/^(?:OCX_CODEX_OWNED_HOME|OCX_CODEX_NAMESPACE_CHECK|OCX_CODEX_NAMESPACE_FIXTURE)$/i.test(key))); + !/^(?:OCX_CODEX_OWNED_HOME|OCX_CODEX_NAMESPACE_CHECK|OCX_CODEX_NAMESPACE_FIXTURE|OCX_CODEX_PATH_CHAIN|OCX_CODEX_OWNED_PATHS)$/i.test(key))); const result = Bun.spawnSync([ resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT, ], { env: { ...inherited, OCX_CODEX_OWNED_HOME: home, OCX_CODEX_NAMESPACE_CHECK: namespace ? "1" : "0", - OCX_CODEX_NAMESPACE_FIXTURE: fixtureDirectory }, stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000, windowsHide: true }); + OCX_CODEX_NAMESPACE_FIXTURE: fixtureDirectory, OCX_CODEX_PATH_CHAIN: paths ? "1" : "0", + OCX_CODEX_OWNED_PATHS: JSON.stringify(paths) }, stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000, windowsHide: true }); const safe = result.success && !result.exitedDueToTimeout; if (!safe && namespace) report?.(result.exitCode === 2 ? "systemNamespaceWritable" : result.exitCode === 3 ? "systemConfig" : "systemNamespace"); return safe; } +/** Inspect lexical entries as well as every link target; realpath alone loses replacement authority. */ +export function codexLaunchProjectAliasIsOwned(path: string): boolean { + try { + const paths = new Set(), uid = process.getuid?.(); + const inspect = (entry: string): boolean => { + if (paths.has(entry)) return true; + paths.add(entry); + const stat = lstatSync(entry), parent = dirname(entry); + if (!stat.isSymbolicLink() && !stat.isDirectory() && !stat.isFile()) return false; + if (process.platform !== "win32") { + if (uid === undefined || (stat.uid !== uid && stat.uid !== 0)) return false; + // Symlink modes are not access controls; their owner and parent protect the entry. + if (!stat.isSymbolicLink() && (stat.mode & 0o022) !== 0 + && (!stat.isDirectory() || (stat.mode & 0o1000) === 0)) return false; + const container = lstatSync(parent); + if ((container.mode & 0o022) !== 0 && (container.mode & 0o1000) !== 0 && stat.uid !== uid) return false; + } + if (stat.isSymbolicLink()) { + const target = readlinkSync(entry); + // Keep dot segments: collapsing link/../target could hide the link that redirects it. + if (!inspect(isAbsolute(target) ? target : parent + sep + target)) return false; + } + return parent === entry || inspect(parent); + }; + if (!inspect(resolve(path))) return false; + if (process.platform === "win32") return windowsOwnershipCheck("", false, undefined, undefined, [...paths]); + return process.platform !== "darwin" || macHasNoManagedCodexPreferences([...paths], true); + } catch { return false; } +} + /** A missing system layer is safe only if another user cannot create it later. */ export function codexPosixSystemNamespace(platform: NodeJS.Platform = process.platform): string { // Darwin's /etc is a standard root-owned symlink; inspect its physical namespace. @@ -140,13 +179,14 @@ export function darwinAclEntriesAreSafe(entries: readonly { tag: number; perms: } /** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ -function macHasNoManagedCodexPreferences(paths: readonly string[]): boolean { +function macHasNoManagedCodexPreferences(paths: readonly string[], inspectLinks = false): boolean { let library: { close(): void } | undefined; let aclLibrary: { close(): void } | undefined; try { const { dlopen, FFIType, ptr, read } = require("bun:ffi") as typeof import("bun:ffi"); const acl = dlopen("/usr/lib/libSystem.B.dylib", { acl_get_file: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr }, + acl_get_link_np: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr }, acl_get_entry: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr], returns: FFIType.i32 }, acl_get_tag_type: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, acl_get_permset: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, @@ -158,7 +198,8 @@ function macHasNoManagedCodexPreferences(paths: readonly string[]): boolean { aclLibrary = acl; for (const path of paths) { const bytes = Buffer.from(path + "\0"), entry = Buffer.alloc(8), tag = Buffer.alloc(4), permset = Buffer.alloc(8); - const descriptor = acl.symbols.acl_get_file(ptr(bytes), 0x100); // ACL_TYPE_EXTENDED + // Alias admission must inspect the link's ACL, too; acl_get_file follows it. + const descriptor = (inspectLinks ? acl.symbols.acl_get_link_np : acl.symbols.acl_get_file)(ptr(bytes), 0x100); // ACL_TYPE_EXTENDED if (!descriptor) return false; try { const valid = acl.symbols.acl_valid(descriptor); diff --git a/structure/cli-management.md b/structure/cli-management.md index c37e0dcfc07..1d059dff416 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -25,6 +25,8 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl `src/cli/codex-launch-ownership.ts` checks current-user ownership of the canonical home and config, rejects POSIX group/other writes and Windows DACL mutation grants outside current user/SYSTEM/Administrators, and rejects unsafe ancestor replacement rights. Windows system paths come from the CommonApplicationData known folder; ancestor ownership also permits TrustedInstaller. macOS checks forced CoreFoundation preferences. Read/inspection errors deny. The key is supplied only when configuration and its namespaces are protected from other principals; the current user, SYSTEM/Administrators and the documented OS authorities are trusted. Same-user processes are trusted: an actor with this user's write access can already read this user's OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the check does not establish immutable startup configuration or isolate privileged processes. Regression coverage is in `tests/codex-integration/codex-launcher-admission.test.ts`. +Same-file project aliases additionally require ownership/ACL inspection of the lexical entry, every ancestor and every intermediate symlink target. The Windows checker reuses its ACL policy in a path-chain mode, including the reparse entry itself; POSIX permits only current-user/root ownership and no group/other writes, with sticky directories protecting current-user-owned entries. macOS applies the same ACL policy with no-follow inspection of symlink entries. Unknown or failed inspection withholds the token with reason `project` before reading it. `tests/codex-integration/codex-launcher-env.test.ts` covers protected home aliases, writable alias entries/parents/ancestors/redirects and inspection failure. + `src/cli/codex-launch-diagnostics.ts` maps each fresh-key withholding reason to one fixed stderr line without shell commands, credential values, key-file paths, config contents or exception text. Default Windows ProgramData rights remain unsafe even when OpenAI/Codex is absent. The diagnostic states that the system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with ancestors protected against replacement by other users. It requests administrator policy review and states that opencodex never changes ACLs itself; no provisioning command is supplied. Users can instead supply their own key by exporting OPENCODEX_API_AUTH_TOKEN, which the launcher leaves untouched. Existing system configuration still refuses automatic credentials. Tests in `tests/codex-integration/codex-launcher-diagnostics.test.ts` cover command-free, one-line non-disclosure, real missing/writable Windows namespace refusals and explicit credential preservation. Public requirements and the explicit-key alternative live in the [CLI reference](../docs-site/src/content/docs/reference/cli/agents.md#ocx-codex-codex-args). diff --git a/structure/codex-home.md b/structure/codex-home.md index 3d424ad3124..30459ff663e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -57,7 +57,7 @@ $CODEX_HOME/.opencodex-native-main-profiles/ ``` Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on -the resolved `CODEX_HOME`. For credential delivery, `src/cli/codex-launch-auth.ts` excludes an ancestor `.codex/config.toml` only when its realpath matches the home config, including a symlinked user `.codex`; distinct files or failed identity resolution withhold the token. Namespace ownership follows the [launcher contract](cli-management.md#head-and-help-navigation). +the resolved `CODEX_HOME`. For credential delivery, `src/cli/codex-launch-auth.ts` excludes an ancestor `.codex/config.toml` only when its realpath matches the home config and its lexical alias, ancestors and intermediate link targets pass ownership/ACL inspection. A protected symlinked user `.codex` qualifies; distinct files, replaceable aliases and failed inspections withhold the token as a project layer. Namespace ownership follows the [launcher contract](cli-management.md#head-and-help-navigation). Log Guard reclaim in `src/codex/log-guard/maintenance.ts` refuses a non-regular or redirected database path. It compares canonical path and full-width filesystem device and file identity diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 182cd815310..1364ef914c3 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { codexLaunchHomeIsOwned } from "../../src/cli/codex-launch-ownership"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { fixturePath } from "../helpers/repo-root"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -43,9 +46,12 @@ async function fixture(run: (root: string, home: string) => void | Promise } function launch(base = { ...process.env }) { - let reads = 0; - const env = buildCodexLaunchEnv(base, { routingKind: "opencodex-local", serviceToken: () => { reads++; return token; } }); - return { env, reads }; + let reads = 0, reason: string | undefined; + const env = buildCodexLaunchEnv(base, { + routingKind: "opencodex-local", serviceToken: () => { reads++; return token; }, + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + }); + return { env, reads, reason }; } function canCreateDirectorySymlink(): boolean { @@ -61,7 +67,7 @@ describe("Codex launcher native home and proxy environment", () => { test.skipIf(!canCreateDirectorySymlink())("a symlinked user .codex is the home config, not a project layer", () => fixture((root, home) => { const user = join(root, "user"), cwd = join(user, "project"); mkdirSync(cwd); - symlinkSync(home, join(user, ".codex"), "dir"); + symlinkSync(join("..", "codex-home"), join(user, ".codex"), "dir"); process.chdir(cwd); const base = { ...process.env }; delete base.CODEX_HOME; const result = launch(base); @@ -70,6 +76,55 @@ describe("Codex launcher native home and proxy environment", () => { expect(result.env.CODEX_HOME).toBe(realpathSync(home)); })); + for (const entry of ["parent", "ancestor", "redirect"] as const) { + test.skipIf(!canCreateDirectorySymlink())(`a writable project alias ${entry} refuses before token read`, () => fixture((root, home) => { + const shared = join(root, "shared"), project = join(shared, "project"); + mkdirSync(project, { recursive: true }); + if (entry === "redirect") { + const bridge = join(project, "bridge"); + symlinkSync(home, bridge, "dir"); + symlinkSync(bridge, join(root, ".codex"), "dir"); + } else { + symlinkSync(home, join(project, ".codex"), "dir"); + process.chdir(project); + } + const writable = entry === "ancestor" ? shared : project; + if (process.platform === "win32") { + const result = spawnSync("icacls.exe", [writable, "/grant", "*S-1-1-0:(F)"], { encoding: "utf8", windowsHide: true }); + expect(result.status).toBe(0); + } else chmodSync(writable, 0o707); + try { + const result = launch(); + expect(result.reads).toBe(0); + expect(result.reason).toBe("project"); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } finally { if (process.platform !== "win32") chmodSync(writable, 0o700); } + })); + } + + test.skipIf(!canCreateDirectorySymlink())("project alias inspection failure refuses before token read", () => fixture((root, home) => { + symlinkSync(home, join(root, ".codex"), "dir"); + const inspect = spyOn(fs, "readlinkSync").mockImplementation(() => { throw new Error("fixture inspection unavailable"); }); + try { + const result = launch(); + expect(result.reads).toBe(0); + expect(result.reason).toBe("project"); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } finally { inspect.mockRestore(); } + })); + + test.skipIf(process.platform !== "win32" || !canCreateDirectorySymlink())("a writable Windows alias entry refuses while its target stays protected", () => fixture((root, home) => { + const link = join(root, ".codex"); + symlinkSync(home, link, "dir"); + const grant = spawnSync("icacls.exe", [link, "/L", "/grant", "*S-1-1-0:(F)"], { encoding: "utf8", windowsHide: true }); + expect(grant.status).toBe(0); + expect(codexLaunchHomeIsOwned(home)).toBe(true); + const result = launch(); + expect(result.reads).toBe(0); + expect(result.reason).toBe("project"); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + })); + test("literal tilde home cannot borrow authority from the expanded user home", () => fixture((root) => { const expanded = join(root, "user", "trusted"), literal = join(root, "~", "trusted"); mkdirSync(expanded); mkdirSync(literal, { recursive: true }); From 7c848f1625a5e68d86fca9da5a6233b420dc8bf2 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:53:35 +0900 Subject: [PATCH 16/18] fix(codex): admit absent Darwin ACLs and protect launcher fixtures --- src/cli/codex-launch-ownership.ts | 13 ++- structure/cli-management.md | 2 +- .../codex-launcher-admission.test.ts | 13 ++- .../codex-launcher-darwin-acl.test.ts | 18 +++- .../codex-launcher-diagnostics.test.ts | 15 ++-- .../codex-launcher-env.test.ts | 32 ++++--- .../codex-launcher-security.test.ts | 38 ++++---- tests/helpers/codex-launch-private-fixture.ts | 89 ++++++++++++++++++- 8 files changed, 160 insertions(+), 60 deletions(-) diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts index e705e0b20fa..87f4adfd961 100644 --- a/src/cli/codex-launch-ownership.ts +++ b/src/cli/codex-launch-ownership.ts @@ -160,6 +160,11 @@ export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string, repo } catch { return refuse("systemNamespace"); } } +/** Libc's filesec_get_property reports an absent FILESEC_ACL as ENOENT. */ +export function darwinAclDescriptorIsAbsent(descriptor: number | bigint | null, errorNumber: number): boolean { + return !descriptor && errorNumber === 2; +} + /** Darwin's successful end-of-enumeration differs from POSIX. */ export function darwinAclInspectionIsEmpty(validResult: number, entryResult: number, errorNumber: number): boolean { // Darwin differs from POSIX: found entry => 0; a valid empty ACL => -1/EINVAL. @@ -200,7 +205,13 @@ function macHasNoManagedCodexPreferences(paths: readonly string[], inspectLinks const bytes = Buffer.from(path + "\0"), entry = Buffer.alloc(8), tag = Buffer.alloc(4), permset = Buffer.alloc(8); // Alias admission must inspect the link's ACL, too; acl_get_file follows it. const descriptor = (inspectLinks ? acl.symbols.acl_get_link_np : acl.symbols.acl_get_file)(ptr(bytes), 0x100); // ACL_TYPE_EXTENDED - if (!descriptor) return false; + if (!descriptor) { + // Read errno immediately: both get_file and get_link_np use acl_get_file1. + // https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_file.c + const errno = acl.symbols.__error(); + if (!errno || !darwinAclDescriptorIsAbsent(descriptor, read.i32(errno))) return false; + continue; + } try { const valid = acl.symbols.acl_valid(descriptor); if (valid !== 0) return false; diff --git a/structure/cli-management.md b/structure/cli-management.md index 69670e3e2f7..fd77c5908b8 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -35,7 +35,7 @@ For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. -Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS validates the ACL and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. The pure policy and Darwin-gated native ACL regression live in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Native checks use production defaults. +Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS accepts an absent extended ACL only when the descriptor is NULL and immediately captured errno is ENOENT, for both file and no-follow link inspection; every other NULL/error refuses. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. The pure policy and Darwin-gated native ACL regression live in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/tests/codex-integration/codex-launcher-admission.test.ts b/tests/codex-integration/codex-launcher-admission.test.ts index 0f3a0df38a9..364ad3ef864 100644 --- a/tests/codex-integration/codex-launcher-admission.test.ts +++ b/tests/codex-integration/codex-launcher-admission.test.ts @@ -1,12 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { makeCodexLaunchFixturePrivate, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; import { codexLaunchSystemNamespaceIsSafe, codexPosixSystemNamespace, darwinAclInspectionIsEmpty } from "../../src/cli/codex-launch-ownership"; const token = "ocx_fixture_admission"; @@ -14,9 +13,7 @@ const cfg = { port: 12199, hostname: "0.0.0.0" }; const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); function fixture(run: (root: string, home: string) => void): void { - const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); - const root = mkdtempSync(join(temp, "ocx-admission-")), home = join(root, "codex"), ocx = join(root, "ocx"); - makeCodexLaunchFixturePrivate(root); + const root = createCodexLaunchFixtureRoot("ocx-admission-"), home = join(root, "codex"), ocx = join(root, "ocx"); mkdirSync(home); mkdirSync(ocx); const prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); process.env.OPENCODEX_HOME = ocx; @@ -122,7 +119,7 @@ else { [System.IO.File]::SetAccessControl($path,$acl) }`; if (entry === "directory") expect(codexLaunchSystemNamespaceIsSafe(join(home, "missing-system"))).toBe(false); })); - test("a protected empty system namespace may authorize missing files", () => fixture((_root, home) => { + codexLaunchOwnershipTest("a protected empty system namespace may authorize missing files", () => fixture((_root, home) => { expect(codexLaunchSystemNamespaceIsSafe(join(home, "missing", "Codex"))).toBe(true); })); test.skipIf(process.platform !== "win32")("shell casing aliases cannot replace the system namespace protocol input", () => fixture((_root, home) => { @@ -158,7 +155,7 @@ else { [System.IO.File]::SetAccessControl($path,$acl) }`; })); for (const args of [[], ["-m", "fixture", "--", "hello"], ["--model=fixture", "--", "hello"], ["--no-daemon", "exec", "--skip-git-repo-check", "--ephemeral", "--json", "fixture prompt"]]) { - test(`managed positive ${args.join(" ")}`, () => fixture((_root, home) => { + codexLaunchOwnershipTest(`managed positive ${args.join(" ")}`, () => fixture((_root, home) => { const result = launch(home, args); expect(result.reads).toBe(1); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); diff --git a/tests/codex-integration/codex-launcher-darwin-acl.test.ts b/tests/codex-integration/codex-launcher-darwin-acl.test.ts index f428753c2f4..28014cdba7f 100644 --- a/tests/codex-integration/codex-launcher-darwin-acl.test.ts +++ b/tests/codex-integration/codex-launcher-darwin-acl.test.ts @@ -6,6 +6,15 @@ import * as ownership from "../../src/cli/codex-launch-ownership"; import { removeTreeWithRetry } from "../helpers/remove-tree"; describe("Codex launcher Darwin ACL mutation admission", () => { + test("only a null descriptor with ENOENT means no extended ACL", () => { + expect(ownership.darwinAclDescriptorIsAbsent(null, 2)).toBe(true); + expect(ownership.darwinAclDescriptorIsAbsent(0, 2)).toBe(true); + expect(ownership.darwinAclDescriptorIsAbsent(0n, 2)).toBe(true); + for (const errno of [0, 5, 12, 13, 22, 45]) { + expect(ownership.darwinAclDescriptorIsAbsent(null, errno)).toBe(false); + } + expect(ownership.darwinAclDescriptorIsAbsent(123, 2)).toBe(false); + }); test("empty ACLs are safe", () => { expect(ownership.darwinAclEntriesAreSafe([])).toBe(true); }); @@ -43,6 +52,9 @@ describe("Codex launcher Darwin ACL mutation admission", () => { try { mkdirSync(home); chmodSync(root, 0o700); chmodSync(home, 0o700); writeFileSync(join(home, "config.toml"), 'model = "fixture"\n', { mode: 0o600 }); + expect(ownership.codexLaunchHomeIsOwned(home)).toBe(true); + expect(ownership.codexLaunchSystemNamespaceIsSafe(join(home, "missing-system"))).toBe(true); + expect(ownership.codexLaunchProjectAliasIsOwned(join(home, "config.toml"))).toBe(true); const chmodAcl = (entry: string) => { const result = Bun.spawnSync(["/bin/chmod", "+a", entry, root], { stdout: "pipe", stderr: "pipe" }); expect(result.success).toBe(true); @@ -51,6 +63,10 @@ describe("Codex launcher Darwin ACL mutation admission", () => { expect(ownership.codexLaunchHomeIsOwned(home)).toBe(true); chmodAcl("everyone allow write"); expect(ownership.codexLaunchHomeIsOwned(home)).toBe(false); - } finally { removeTreeWithRetry(root); } + } finally { + const cleared = Bun.spawnSync(["/bin/chmod", "-N", root], { stdout: "pipe", stderr: "pipe" }); + expect(cleared.success).toBe(true); + removeTreeWithRetry(root); + } }); }); diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts index a6e4de58f6b..2def06a9900 100644 --- a/tests/codex-integration/codex-launcher-diagnostics.test.ts +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { buildCodexLaunchEnv, type CodexLaunchEnvDeps } from "../../src/cli/codex-launcher"; import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; @@ -8,7 +7,7 @@ import { CODEX_TOKEN_REFUSALS, codexTokenWithheldDiagnostic } from "../../src/cl import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; -import { makeCodexLaunchFixturePrivate } from "../helpers/codex-launch-private-fixture"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest } from "../helpers/codex-launch-private-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const secret = "ocx_fixture_diagnostic_secret", contents = "fixture-key-file-content-never-log"; @@ -66,9 +65,8 @@ describe("Codex credential withholding diagnostics", () => { ]) expect(result.notes).toEqual([]); }); - test.skipIf(process.platform !== "win32")("missing or writable Windows namespace explains the requirement and explicit credential alternative", () => { - const root = mkdtempSync(join(process.env.SystemRoot ?? "C:/Windows", "Temp", "ocx-namespace-diagnostic-")); - makeCodexLaunchFixturePrivate(root); + (process.platform === "win32" ? codexLaunchOwnershipTest : test.skip)("missing or writable Windows namespace explains the requirement and explicit credential alternative", () => { + const root = createCodexLaunchFixtureRoot("ocx-namespace-diagnostic-"); const home = join(root, "codex"), ocx = join(root, "ocx"), machine = join(root, "machine"); for (const path of [home, ocx, machine]) mkdirSync(path); const system = join(machine, "OpenAI", "Codex"), prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); @@ -126,9 +124,8 @@ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([Sy } }); - test("configuration reasons reach stderr without file contents or credential paths", () => { - const root = mkdtempSync(join(process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(), "ocx-diagnostic-")); - makeCodexLaunchFixturePrivate(root); + codexLaunchOwnershipTest("configuration reasons reach stderr without file contents or credential paths", () => { + const root = createCodexLaunchFixtureRoot("ocx-diagnostic-"); const home = join(root, "codex"), ocx = join(root, "ocx"), system = join(root, "system"); for (const path of [home, ocx, system]) mkdirSync(path); const cfg = { port: 12199, hostname: "0.0.0.0" }, prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 1364ef914c3..334fa676067 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -11,7 +11,7 @@ import { codexLaunchHomeIsOwned } from "../../src/cli/codex-launch-ownership"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { fixturePath } from "../helpers/repo-root"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; -import { makeCodexLaunchFixturePrivate, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, codexLaunchProjectRefusalTest, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; const token = "ocx_fixture_launch_env"; const installation = { port: 12199, hostname: "0.0.0.0" }; @@ -21,9 +21,7 @@ const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "http async function fixture(run: (root: string, home: string) => void | Promise): Promise { // Avoid the real user's ancestor .codex layer, which deliberately denies injection. - const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); - const root = mkdtempSync(join(temp, "ocx-launch-env-")); - makeCodexLaunchFixturePrivate(root); + const root = createCodexLaunchFixtureRoot("ocx-launch-env-"); const saved = { ...process.env }, cwd = process.cwd(); const user = join(root, "user"), home = join(root, "codex-home"), ocx = join(root, "ocx"); for (const path of [user, home, ocx]) mkdirSync(path); @@ -64,7 +62,7 @@ function canCreateDirectorySymlink(): boolean { } describe("Codex launcher native home and proxy environment", () => { - test.skipIf(!canCreateDirectorySymlink())("a symlinked user .codex is the home config, not a project layer", () => fixture((root, home) => { + (canCreateDirectorySymlink() ? codexLaunchOwnershipTest : test.skip)("a symlinked user .codex is the home config, not a project layer", () => fixture((root, home) => { const user = join(root, "user"), cwd = join(user, "project"); mkdirSync(cwd); symlinkSync(join("..", "codex-home"), join(user, ".codex"), "dir"); @@ -77,7 +75,7 @@ describe("Codex launcher native home and proxy environment", () => { })); for (const entry of ["parent", "ancestor", "redirect"] as const) { - test.skipIf(!canCreateDirectorySymlink())(`a writable project alias ${entry} refuses before token read`, () => fixture((root, home) => { + (canCreateDirectorySymlink() ? codexLaunchProjectRefusalTest : test.skip)(`a writable project alias ${entry} refuses before token read`, (expectedReason) => fixture((root, home) => { const shared = join(root, "shared"), project = join(shared, "project"); mkdirSync(project, { recursive: true }); if (entry === "redirect") { @@ -96,32 +94,32 @@ describe("Codex launcher native home and proxy environment", () => { try { const result = launch(); expect(result.reads).toBe(0); - expect(result.reason).toBe("project"); + expect(result.reason).toBe(expectedReason); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); } finally { if (process.platform !== "win32") chmodSync(writable, 0o700); } })); } - test.skipIf(!canCreateDirectorySymlink())("project alias inspection failure refuses before token read", () => fixture((root, home) => { + (canCreateDirectorySymlink() ? codexLaunchProjectRefusalTest : test.skip)("project alias inspection failure refuses before token read", (expectedReason) => fixture((root, home) => { symlinkSync(home, join(root, ".codex"), "dir"); const inspect = spyOn(fs, "readlinkSync").mockImplementation(() => { throw new Error("fixture inspection unavailable"); }); try { const result = launch(); expect(result.reads).toBe(0); - expect(result.reason).toBe("project"); + expect(result.reason).toBe(expectedReason); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); } finally { inspect.mockRestore(); } })); - test.skipIf(process.platform !== "win32" || !canCreateDirectorySymlink())("a writable Windows alias entry refuses while its target stays protected", () => fixture((root, home) => { + (process.platform === "win32" && canCreateDirectorySymlink() ? codexLaunchProjectRefusalTest : test.skip)("a writable Windows alias entry refuses while its target stays protected", (expectedReason) => fixture((root, home) => { const link = join(root, ".codex"); symlinkSync(home, link, "dir"); const grant = spawnSync("icacls.exe", [link, "/L", "/grant", "*S-1-1-0:(F)"], { encoding: "utf8", windowsHide: true }); expect(grant.status).toBe(0); - expect(codexLaunchHomeIsOwned(home)).toBe(true); + expect(codexLaunchHomeIsOwned(home)).toBe(expectedReason === "project"); const result = launch(); expect(result.reads).toBe(0); - expect(result.reason).toBe("project"); + expect(result.reason).toBe(expectedReason); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); })); @@ -157,7 +155,7 @@ describe("Codex launcher native home and proxy environment", () => { expect(result.env).toEqual(base); })); - test("relative and literal tilde local homes are bound to the verified absolute directory", () => fixture((root) => { + codexLaunchOwnershipTest("relative and literal tilde local homes are bound to the verified absolute directory", () => fixture((root) => { const literal = join(root, "~", "local"); mkdirSync(literal, { recursive: true }); writeFileSync(join(literal, "config.toml"), local); for (const raw of ["codex-home", join("~", "local")]) { @@ -176,7 +174,7 @@ describe("Codex launcher native home and proxy environment", () => { } })); - test("an implicit home is resolved from the child environment and bound explicitly", () => fixture((root) => { + codexLaunchOwnershipTest("an implicit home is resolved from the child environment and bound explicitly", () => fixture((root) => { const implicit = join(root, "user", ".codex"); mkdirSync(implicit); writeFileSync(join(implicit, "config.toml"), local); const base = { ...process.env }; delete base.CODEX_HOME; @@ -193,7 +191,7 @@ describe("Codex launcher native home and proxy environment", () => { } })); - test("junction or symlink homes bind to the physical verified directory", () => fixture((root, home) => { + codexLaunchOwnershipTest("junction or symlink homes bind to the physical verified directory", () => fixture((root, home) => { const link = join(root, "linked"); symlinkSync(home, link, process.platform === "win32" ? "junction" : "dir"); process.env.CODEX_HOME = link; const result = launch(); @@ -212,7 +210,7 @@ describe("Codex launcher native home and proxy environment", () => { })); } - test("empty proxy variables preserve normal local credential delivery", () => fixture((_root, home) => { + codexLaunchOwnershipTest("empty proxy variables preserve normal local credential delivery", () => fixture((_root, home) => { const result = launch({ ...process.env, HTTP_PROXY: "", HTTPS_PROXY: "", ALL_PROXY: "" }); expect(result.reads).toBe(1); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); @@ -234,7 +232,7 @@ describe("Codex launcher native home and proxy environment", () => { } })); - test("default transport gets identical loopback bypasses and preserves existing entries", () => fixture((_root, home) => { + codexLaunchOwnershipTest("default transport gets identical loopback bypasses and preserves existing entries", () => fixture((_root, home) => { writeFileSync(join(home, "config.toml"), local + "\n[features]\nrespect_system_proxy = false\nsystem_proxy_fallback = true\n"); const result = launch({ ...process.env, NO_PROXY: "example.invalid", no_proxy: "other.invalid", No_Proxy: "third.invalid" }); expect(result.reads).toBe(1); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index f6c96ada2e8..0837aad73dc 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { delimiter, join, parse } from "node:path"; import { spawnSync } from "node:child_process"; import { codexCommandCanBypass } from "../../src/cli/codex-launch-command"; @@ -14,16 +13,14 @@ import { buildWindowsCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/c import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; -import { makeCodexLaunchFixturePrivate, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, codexLaunchProjectRefusalTest, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; const fixtureToken = "ocx_fixture_launcher_security"; const installation = { port: 12199, hostname: "0.0.0.0" }; const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(12199, installation)); function fixture(run: (home: string, codexHome: string, ocxHome: string) => void): void { - const temp = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "Temp") : tmpdir(); - const home = mkdtempSync(join(temp, "ocx-launch-security-")); - makeCodexLaunchFixturePrivate(home); + const home = createCodexLaunchFixtureRoot("ocx-launch-security-"); const cwd = process.cwd(); const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; const codexHome = join(home, "codex-home"), ocxHome = join(home, "ocx-home"); @@ -42,15 +39,15 @@ function fixture(run: (home: string, codexHome: string, ocxHome: string) => void } } -function expectManagedHome(codexHome: string): void { - writeFileSync(join(codexHome, "config.toml"), local); - let reads = 0; - const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); - expect(reads).toBe(1); - expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); -} - describe("codex launcher credential destination", () => { + // Keep the shared positive control separate so unsafe hosts still execute all negatives. + codexLaunchOwnershipTest("the managed starting configuration reads the service token exactly once", () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + let reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + expect(reads).toBe(1); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + })); for (const [name, content] of [ ["remote reserved provider", local.replace("http://127.0.0.1:12199/v1", "https://gateway.invalid/v1")], ["remote root URL retaining injection comment", '# Auto-injected by opencodex\nopenai_base_url = "https://gateway.invalid/v1"\n'], @@ -60,7 +57,6 @@ describe("codex launcher credential destination", () => { ["malformed TOML", local + '\nmodel_provider = [\n'], ]) { test(`does not load a token for ${name}`, () => fixture((_home, codexHome) => { - expectManagedHome(codexHome); writeFileSync(join(codexHome, "config.toml"), content!); let reads = 0; const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: classifyCodexRouting(content!), serviceToken: () => { reads++; return fixtureToken; } }); @@ -68,7 +64,7 @@ describe("codex launcher credential destination", () => { expect(reads).toBe(0); })); } - test("loads the service token for the managed provider on this installation's local listener", () => fixture((home, codexHome) => { + codexLaunchOwnershipTest("loads the service token for the managed provider on this installation's local listener", () => fixture((home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); // This isolated Codex home must not inherit project configs beside the real user's // home/worktree. The filesystem root has no caller ancestor configuration layers. @@ -79,7 +75,7 @@ describe("codex launcher credential destination", () => { } finally { process.chdir(originalDirectory); } })); test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { - expectManagedHome(codexHome); + writeFileSync(join(codexHome, "config.toml"), local); for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { let reads = 0; expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); @@ -87,7 +83,7 @@ describe("codex launcher credential destination", () => { } })); test("project configuration and environment destination overrides suppress loading", () => fixture((home, codexHome) => { - expectManagedHome(codexHome); + writeFileSync(join(codexHome, "config.toml"), local); expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome, OPENAI_BASE_URL: "https://gateway.invalid/v1" }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); const project = join(home, "project"); mkdirSync(project); mkdirSync(join(project, ".codex")); @@ -101,8 +97,8 @@ describe("codex launcher credential destination", () => { expect(reads).toBe(0); } finally { process.chdir(originalDirectory); } })); - test("custom project roots cannot hide an outer override behind a nested .git", () => fixture((home, codexHome) => { - expectManagedHome(codexHome); + codexLaunchProjectRefusalTest("custom project roots cannot hide an outer override behind a nested .git", (expectedReason) => fixture((home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); const workspace = join(home, "workspace"), nested = join(workspace, "nested"), cwd = join(nested, "src"); mkdirSync(cwd, { recursive: true }); mkdirSync(join(workspace, ".codex")); @@ -119,7 +115,7 @@ describe("codex launcher credential destination", () => { credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), }); expect(reads).toBe(0); - expect(reason).toBe("project"); + expect(reason).toBe(expectedReason); expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); } finally { process.chdir(originalDirectory); } })); diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts index 419e6895551..789c0b72509 100644 --- a/tests/helpers/codex-launch-private-fixture.ts +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -1,8 +1,93 @@ -import { chmodSync } from "node:fs"; +import { expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { codexLaunchHomeIsOwned } from "../../src/cli/codex-launch-ownership"; +import { removeTreeWithRetry } from "./remove-tree"; + +let location: { base: string; owned: boolean; refusals: string[] } | undefined; + +function fixtureLocation() { + if (location) return location; + if (process.platform !== "win32") return location = { base: tmpdir(), owned: true, refusals: [] }; + // The suite rewrites USERPROFILE/TEMP beneath its sandbox, which may live on + // a writable CI work drive. Try real profile Temp without touching client data. + // The explicit override is for reproducing that unsafe ancestor environment. + const candidates = process.env.OCX_CODEX_TEST_FIXTURE_ROOT ? [process.env.OCX_CODEX_TEST_FIXTURE_ROOT] : [ + join(process.env.SystemRoot ?? "C:/Windows", "Temp"), + ...(process.env.OCX_REAL_HOME ? [join(process.env.OCX_REAL_HOME, "AppData", "Local", "Temp")] : []), + tmpdir(), + ]; + const refusals: string[] = []; + let fallback: string | undefined; + for (const base of new Set(candidates)) { + if (!existsSync(base)) continue; + let root: string; + try { root = realpathSync(mkdtempSync(join(base, "ocx-ownership-probe-"))); } + catch (error) { + if (["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; + throw error; + } + try { + makeCodexLaunchFixturePrivate(root); + writeFileSync(join(root, "config.toml"), 'model="fixture"\n'); + if (!codexLaunchHomeIsOwned(root)) { + let reason: string | undefined; + expect(codexCredentialDestinationIsTrusted(root, join(root, "system"), value => { reason = value; })).toBe(false); + expect(reason).toBe("ownership"); + refusals.push(base); fallback ??= base; + continue; + } + // A real profile can contain a distinct project config; never borrow it. + let clean = true; + for (let parent = base; ; parent = dirname(parent)) { + if (existsSync(join(parent, ".codex", "config.toml"))) clean = false; + if (dirname(parent) === parent) break; + } + if (clean) return location = { base, owned: true, refusals }; + } finally { removeTreeWithRetry(root); } + } + if (!fallback) throw new Error("No isolated Codex fixture location; no ownership refusal proved"); + return location = { base: fallback, owned: false, refusals }; +} + +export function createCodexLaunchFixtureRoot(prefix: string): string { + const root = realpathSync(mkdtempSync(join(fixtureLocation().base, prefix))); + makeCodexLaunchFixturePrivate(root); + return root; +} + +/** Gate only assertions that need a protected starting point, with an executed refusal proof. */ +export function codexLaunchOwnershipTest(name: string, body: () => void | Promise, timeout?: number): void { + const support = fixtureLocation(); + if (support.owned) { test(name, body, timeout); return; } + test(`${name}: unsafe Windows fixture refuses before token read`, () => { + const root = createCodexLaunchFixtureRoot("ocx-refused-fixture-"); + try { + writeFileSync(join(root, "config.toml"), 'model="fixture"\n'); + expect(codexLaunchHomeIsOwned(root)).toBe(false); + let reason: string | undefined, reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: root }, { + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system"), value => { reason = value; report?.(value); }), + serviceToken: () => { reads++; return "ocx_fixture_must_not_read"; }, + }); + expect(reason).toBe("ownership"); + expect(reads).toBe(0); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(support.refusals.length).toBeGreaterThan(0); + } finally { removeTreeWithRetry(root); } + }); + test.skip(`${name} (no protected Windows fixture ancestor chain; ownership refusal proved)`, body); +} + +/** Still exercise the mutating negative scenario when its later diagnostic is unreachable. */ +export function codexLaunchProjectRefusalTest(name: string, body: (reason: "project" | "ownership") => void | Promise): void { + codexLaunchOwnershipTest(name, () => body("project")); + if (!fixtureLocation().owned) test(`${name}: unsafe ancestors still withhold credentials`, () => body("ownership")); +} // Model a protected, empty machine configuration namespace inside this fixture. // Every other authority check uses real config/ownership IO. Native probes use production defaults. From 33ce710d105d264eae095bdfb4d9ede91b3f73a0 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:26:43 +0900 Subject: [PATCH 17/18] fix(codex): bind Darwin ACL reads and preserve negative coverage --- .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-launch-ownership.ts | 131 +++++++++++------- structure/cli-management.md | 2 +- .../codex-launcher-darwin-identity.test.ts | 36 +++++ .../codex-launcher-diagnostics.test.ts | 86 +++++++----- .../codex-launcher-env.test.ts | 9 +- tests/fixtures/test-layout-expected.json | 1 + tests/helpers/codex-launch-darwin-model.ts | 71 ++++++++++ tests/helpers/codex-launch-private-fixture.ts | 8 +- 9 files changed, 255 insertions(+), 90 deletions(-) create mode 100644 tests/codex-integration/codex-launcher-darwin-identity.test.ts create mode 100644 tests/helpers/codex-launch-darwin-model.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 1815dabf720..126ae7dfbcb 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,6 +1,7 @@ { "codex-launcher-diagnostics.test.ts": "codex-integration", "codex-launcher-darwin-acl.test.ts": "codex-integration", + "codex-launcher-darwin-identity.test.ts": "codex-integration", "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts index 87f4adfd961..3bab44e83fa 100644 --- a/src/cli/codex-launch-ownership.ts +++ b/src/cli/codex-launch-ownership.ts @@ -1,8 +1,20 @@ -import { lstatSync, readlinkSync } from "node:fs"; +import { closeSync, fstatSync, lstatSync, openSync, readlinkSync, type Stats } from "node:fs"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; +type OwnershipStat = Pick & { + dev: number | bigint; ino: number | bigint; +}; + +function ownershipStat(path: string): OwnershipStat { + if (process.platform !== "darwin") return lstatSync(path); + // Keep 64-bit object identities exact; ownership and permission fields fit u32. + const stat = lstatSync(path, { bigint: true }); + return { dev: stat.dev, ino: stat.ino, uid: Number(stat.uid), mode: Number(stat.mode), + isFile: () => stat.isFile(), isDirectory: () => stat.isDirectory(), isSymbolicLink: () => stat.isSymbolicLink() }; +} + // Paths are environment data, never interpolated PowerShell source. The token is // not read until this read-only subprocess and the config checks have succeeded. const SCRIPT = String.raw` @@ -99,11 +111,11 @@ function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirector /** Inspect lexical entries as well as every link target; realpath alone loses replacement authority. */ export function codexLaunchProjectAliasIsOwned(path: string): boolean { try { - const paths = new Set(), uid = process.getuid?.(); + const paths = new Map(), uid = process.getuid?.(); const inspect = (entry: string): boolean => { if (paths.has(entry)) return true; - paths.add(entry); - const stat = lstatSync(entry), parent = dirname(entry); + const stat = ownershipStat(entry), parent = dirname(entry); + paths.set(entry, stat); if (!stat.isSymbolicLink() && !stat.isDirectory() && !stat.isFile()) return false; if (process.platform !== "win32") { if (uid === undefined || (stat.uid !== uid && stat.uid !== 0)) return false; @@ -121,8 +133,8 @@ export function codexLaunchProjectAliasIsOwned(path: string): boolean { return parent === entry || inspect(parent); }; if (!inspect(resolve(path))) return false; - if (process.platform === "win32") return windowsOwnershipCheck("", false, undefined, undefined, [...paths]); - return process.platform !== "darwin" || macHasNoManagedCodexPreferences([...paths], true); + if (process.platform === "win32") return windowsOwnershipCheck("", false, undefined, undefined, [...paths.keys()]); + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); } catch { return false; } } @@ -147,12 +159,12 @@ export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string, repo if (dirname(path) === path) return refuse("systemNamespace"); path = dirname(path); } - const paths: string[] = []; + const paths = new Map(); for (;;) { - const stat = lstatSync(path); + const stat = ownershipStat(path); if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.uid !== process.getuid?.() && stat.uid !== 0) - || ((stat.mode & 0o022) !== 0 && (paths.length === 0 || (stat.mode & 0o1000) === 0))) return refuse("systemNamespace"); - paths.push(path); + || ((stat.mode & 0o022) !== 0 && (paths.size === 0 || (stat.mode & 0o1000) === 0))) return refuse("systemNamespace"); + paths.set(path, stat); if (dirname(path) === path) break; path = dirname(path); } @@ -160,7 +172,7 @@ export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string, repo } catch { return refuse("systemNamespace"); } } -/** Libc's filesec_get_property reports an absent FILESEC_ACL as ENOENT. */ +/** Only for a descriptor-bound ACL query: absent FILESEC_ACL reports ENOENT. */ export function darwinAclDescriptorIsAbsent(descriptor: number | bigint | null, errorNumber: number): boolean { return !descriptor && errorNumber === 2; } @@ -184,14 +196,13 @@ export function darwinAclEntriesAreSafe(entries: readonly { tag: number; perms: } /** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ -function macHasNoManagedCodexPreferences(paths: readonly string[], inspectLinks = false): boolean { +function macHasNoManagedCodexPreferences(paths: ReadonlyMap): boolean { let library: { close(): void } | undefined; let aclLibrary: { close(): void } | undefined; try { const { dlopen, FFIType, ptr, read } = require("bun:ffi") as typeof import("bun:ffi"); const acl = dlopen("/usr/lib/libSystem.B.dylib", { - acl_get_file: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr }, - acl_get_link_np: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr }, + acl_get_fd_np: { args: [FFIType.i32, FFIType.i32], returns: FFIType.ptr }, acl_get_entry: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr], returns: FFIType.i32 }, acl_get_tag_type: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, acl_get_permset: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, @@ -201,43 +212,57 @@ function macHasNoManagedCodexPreferences(paths: readonly string[], inspectLinks __error: { args: [], returns: FFIType.ptr }, }); aclLibrary = acl; - for (const path of paths) { - const bytes = Buffer.from(path + "\0"), entry = Buffer.alloc(8), tag = Buffer.alloc(4), permset = Buffer.alloc(8); - // Alias admission must inspect the link's ACL, too; acl_get_file follows it. - const descriptor = (inspectLinks ? acl.symbols.acl_get_link_np : acl.symbols.acl_get_file)(ptr(bytes), 0x100); // ACL_TYPE_EXTENDED - if (!descriptor) { - // Read errno immediately: both get_file and get_link_np use acl_get_file1. - // https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_file.c - const errno = acl.symbols.__error(); - if (!errno || !darwinAclDescriptorIsAbsent(descriptor, read.i32(errno))) return false; - continue; - } + for (const [path, expected] of paths) { + // Apple bsd/sys/fcntl.h: O_RDONLY=0, O_NONBLOCK=4, O_NOFOLLOW=0x100, + // O_DIRECTORY=0x100000, O_SYMLINK=0x200000, O_CLOEXEC=0x1000000. + // https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/fcntl.h + // O_SYMLINK opens the lexical link itself, never its target. Nonblocking + // open also prevents a replaced regular file/FIFO from stalling admission. + const fd = openSync(path, 0x1000104 | (expected.isSymbolicLink() ? 0x200000 : expected.isDirectory() ? 0x100000 : 0)); try { - const valid = acl.symbols.acl_valid(descriptor); - if (valid !== 0) return false; - const entries: { tag: number; perms: number }[] = []; - for (let id = 0; ; id = -1) { // ACL_FIRST_ENTRY=0, ACL_NEXT_ENTRY=-1 - const found = acl.symbols.acl_get_entry(descriptor, id, ptr(entry)); - if (found !== 0) { - const errno = acl.symbols.__error(); - if (!errno || !darwinAclInspectionIsEmpty(valid, found, read.i32(errno))) return false; - break; - } - const entryPointer = read.ptr(ptr(entry)) as import("bun:ffi").Pointer; - if (!entryPointer || acl.symbols.acl_get_tag_type(entryPointer, ptr(tag)) !== 0 - || acl.symbols.acl_get_permset(entryPointer, ptr(permset)) !== 0) return false; - const permissions = read.ptr(ptr(permset)) as import("bun:ffi").Pointer; - if (!permissions) return false; - let perms = 0; - for (const permission of DARWIN_ACL_MUTATION_PERMISSIONS) { - const granted = acl.symbols.acl_get_perm_np(permissions, permission); - if (granted !== 0 && granted !== 1) return false; - if (granted === 1) perms |= permission; - } - entries.push({ tag: tag.readInt32LE(), perms }); + const actual = fstatSync(fd, { bigint: true }); + if (actual.dev !== expected.dev || actual.ino !== expected.ino + || Number(actual.mode & 0o170000n) !== (expected.mode & 0o170000)) return false; + const entry = Buffer.alloc(8), tag = Buffer.alloc(4), permset = Buffer.alloc(8); + const descriptor = acl.symbols.acl_get_fd_np(fd, 0x100); // ACL_TYPE_EXTENDED + if (!descriptor) { + // Read errno immediately. acl_get_fd_np uses fstatx_np on this open fd, + // with no pathname lookup that could fail ENOENT after a rename/unlink. + // Only filesec_get_property(FILESEC_ACL) can report absence as ENOENT. + // https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_file.c + // https://github.com/apple-oss-distributions/Libc/blob/main/sys/statx_np.c + // https://github.com/apple-oss-distributions/Libc/blob/main/gen/filesec.c + const errno = acl.symbols.__error(); + if (!errno || !darwinAclDescriptorIsAbsent(descriptor, read.i32(errno))) return false; + continue; } - if (!darwinAclEntriesAreSafe(entries)) return false; - } finally { if (acl.symbols.acl_free(descriptor) !== 0) return false; } + try { + const valid = acl.symbols.acl_valid(descriptor); + if (valid !== 0) return false; + const entries: { tag: number; perms: number }[] = []; + for (let id = 0; ; id = -1) { // ACL_FIRST_ENTRY=0, ACL_NEXT_ENTRY=-1 + const found = acl.symbols.acl_get_entry(descriptor, id, ptr(entry)); + if (found !== 0) { + const errno = acl.symbols.__error(); + if (!errno || !darwinAclInspectionIsEmpty(valid, found, read.i32(errno))) return false; + break; + } + const entryPointer = read.ptr(ptr(entry)) as import("bun:ffi").Pointer; + if (!entryPointer || acl.symbols.acl_get_tag_type(entryPointer, ptr(tag)) !== 0 + || acl.symbols.acl_get_permset(entryPointer, ptr(permset)) !== 0) return false; + const permissions = read.ptr(ptr(permset)) as import("bun:ffi").Pointer; + if (!permissions) return false; + let perms = 0; + for (const permission of DARWIN_ACL_MUTATION_PERMISSIONS) { + const granted = acl.symbols.acl_get_perm_np(permissions, permission); + if (granted !== 0 && granted !== 1) return false; + if (granted === 1) perms |= permission; + } + entries.push({ tag: tag.readInt32LE(), perms }); + } + if (!darwinAclEntriesAreSafe(entries)) return false; + } finally { if (acl.symbols.acl_free(descriptor) !== 0) return false; } + } finally { closeSync(fd); } } const core = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", { CFStringCreateWithCString: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.ptr }, @@ -259,21 +284,21 @@ function macHasNoManagedCodexPreferences(paths: readonly string[], inspectLinks /** Excludes other-user writers; it cannot isolate processes with the same user identity. */ export function codexLaunchHomeIsOwned(home: string): boolean { try { - const directory = lstatSync(home), config = lstatSync(join(home, "config.toml")); + const directory = ownershipStat(home), config = ownershipStat(join(home, "config.toml")); if (!directory.isDirectory() || directory.isSymbolicLink() || !config.isFile() || config.isSymbolicLink()) return false; if (process.platform === "win32") { return windowsOwnershipCheck(home, false); } const uid = process.getuid?.(); if (uid === undefined || [directory, config].some(stat => stat.uid !== uid || (stat.mode & 0o022) !== 0)) return false; - const paths = [home, join(home, "config.toml")]; + const paths = new Map([[home, directory], [join(home, "config.toml"), config]]); for (let parent = dirname(home); ; parent = dirname(parent)) { - const stat = lstatSync(parent); + const stat = ownershipStat(parent); if (!stat.isDirectory() || stat.isSymbolicLink()) return false; if (stat.uid !== uid && stat.uid !== 0) return false; // Sticky directories (e.g. /tmp) protect entries owned by this user. if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) return false; - paths.push(parent); + paths.set(parent, stat); if (dirname(parent) === parent) break; } return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); diff --git a/structure/cli-management.md b/structure/cli-management.md index fd77c5908b8..e3809c53fb6 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -35,7 +35,7 @@ For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. -Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS accepts an absent extended ACL only when the descriptor is NULL and immediately captured errno is ENOENT, for both file and no-follow link inspection; every other NULL/error refuses. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. The pure policy and Darwin-gated native ACL regression live in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Native checks use production defaults. +Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/tests/codex-integration/codex-launcher-darwin-identity.test.ts b/tests/codex-integration/codex-launcher-darwin-identity.test.ts new file mode 100644 index 00000000000..4b367977a68 --- /dev/null +++ b/tests/codex-integration/codex-launcher-darwin-identity.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { darwinOwnershipModel, type DarwinWalk, type DarwinFault } from "../helpers/codex-launch-darwin-model"; + +for (const walk of ["home", "alias", "namespace"] as DarwinWalk[]) { + for (const fault of ["vanished", "inode", "wide-inode", "device", "type", "stat", "close"] as DarwinFault[]) { + test(`Darwin ${walk}: ${fault} entry refuses and closes every opened descriptor`, () => { + const result = darwinOwnershipModel(walk, fault); + expect(result.reached).toBe(true); + expect(result.admitted).toBe(false); + expect(result.closed.sort()).toEqual(result.opened.sort()); + }); + } + test(`Darwin ${walk}: the same existing object without an ACL admits`, () => { + const result = darwinOwnershipModel(walk, "none"); + expect(result.reached).toBe(true); + expect(result.admitted).toBe(true); + expect(result.aclReads).toBeGreaterThan(0); + expect(result.forcedReads).toBe(2); + expect(result.closed.sort()).toEqual(result.opened.sort()); + // Apple fcntl.h: O_NOFOLLOW, O_SYMLINK for links; O_DIRECTORY for directories. + expect(result.flags[0]! & 0x100).toBe(0x100); + if (walk === "alias") expect(result.flags[0]! & 0x200000).toBe(0x200000); + if (walk === "namespace") expect(result.flags[0]! & 0x100000).toBe(0x100000); + }); + for (const errno of [0, 5, 12, 13, 22, 45]) test(`Darwin ${walk}: NULL ACL errno ${errno} refuses`, () => { + const result = darwinOwnershipModel(walk, "errno", errno); + expect(result.admitted).toBe(false); + expect(result.aclReads).toBe(1); + expect(result.closed.sort()).toEqual(result.opened.sort()); + }); + test(`Darwin ${walk}: absent ACL does not bypass forced preferences`, () => { + const result = darwinOwnershipModel(walk, "none", 2, true); + expect(result.admitted).toBe(false); + expect(result.forcedReads).toBe(1); + }); +} diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts index 2def06a9900..d0532e055ba 100644 --- a/tests/codex-integration/codex-launcher-diagnostics.test.ts +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -4,10 +4,11 @@ import { join } from "node:path"; import { buildCodexLaunchEnv, type CodexLaunchEnvDeps } from "../../src/cli/codex-launcher"; import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; import { CODEX_TOKEN_REFUSALS, codexTokenWithheldDiagnostic } from "../../src/cli/codex-launch-diagnostics"; +import { codexLaunchSystemNamespaceIsSafe } from "../../src/cli/codex-launch-ownership"; import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; -import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest } from "../helpers/codex-launch-private-fixture"; +import { createCodexLaunchFixtureRoot, codexLaunchFixtureHasProtectedAncestors } from "../helpers/codex-launch-private-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const secret = "ocx_fixture_diagnostic_secret", contents = "fixture-key-file-content-never-log"; @@ -65,7 +66,7 @@ describe("Codex credential withholding diagnostics", () => { ]) expect(result.notes).toEqual([]); }); - (process.platform === "win32" ? codexLaunchOwnershipTest : test.skip)("missing or writable Windows namespace explains the requirement and explicit credential alternative", () => { + test.skipIf(process.platform !== "win32")("missing or writable Windows namespace always refuses without reading credentials", () => { const root = createCodexLaunchFixtureRoot("ocx-namespace-diagnostic-"); const home = join(root, "codex"), ocx = join(root, "ocx"), machine = join(root, "machine"); for (const path of [home, ocx, machine]) mkdirSync(path); @@ -99,14 +100,17 @@ $p=$env:OCX_TEST_NAMESPACE $acl=[System.IO.Directory]::GetAccessControl($p) $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[System.Security.AccessControl.FileSystemRights]::CreateFiles,[System.Security.AccessControl.AccessControlType]::Allow)) [System.IO.Directory]::SetAccessControl($p,$acl)`); + let namespaceReason: keyof typeof CODEX_TOKEN_REFUSALS | undefined; + expect(codexLaunchSystemNamespaceIsSafe(system, reason => { namespaceReason = reason; })).toBe(false); + expect(namespaceReason).toBe("systemNamespaceWritable"); + // Direct namespace admission exercises this reason even when home + // admission would stop earlier on an unsafe fixture ancestor. + const note = codexTokenWithheldDiagnostic(namespaceReason!); + diagnostic([note], CODEX_TOKEN_REFUSALS.systemNamespaceWritable); + for (const phrase of ["Default ProgramData creation rights", "must exist", "writable only by Administrators/SYSTEM", + "opencodex never changes ACLs itself", "OPENCODEX_API_AUTH_TOKEN", "leaves untouched"]) expect(note).toContain(phrase); const result = launch(); - diagnostic(result.notes, "machine Codex namespace allows other principals to create or replace configuration"); - expect(result.notes[0]).toContain("Default ProgramData creation rights"); - expect(result.notes[0]).toContain("must exist"); - expect(result.notes[0]).toContain("writable only by Administrators/SYSTEM"); - expect(result.notes[0]).toContain("opencodex never changes ACLs itself"); - expect(result.notes[0]).toContain("OPENCODEX_API_AUTH_TOKEN"); - expect(result.notes[0]).toContain("leaves untouched"); + diagnostic(result.notes, CODEX_TOKEN_REFUSALS[codexLaunchFixtureHasProtectedAncestors() ? "systemNamespaceWritable" : "ownership"]); expect(result.notes[0]).not.toContain(root); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); expect(reads).toBe(0); @@ -124,19 +128,20 @@ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([Sy } }); - codexLaunchOwnershipTest("configuration reasons reach stderr without file contents or credential paths", () => { + test("credential-file missing, malformed and colliding negatives bypass destination ownership", () => { const root = createCodexLaunchFixtureRoot("ocx-diagnostic-"); const home = join(root, "codex"), ocx = join(root, "ocx"), system = join(root, "system"); for (const path of [home, ocx, system]) mkdirSync(path); const cfg = { port: 12199, hostname: "0.0.0.0" }, prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); process.env.OPENCODEX_HOME = ocx; process.chdir(root); - const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); try { const keyFile = join(ocx, "service-api-token"); const keyRead = (extra = {}) => capture({ CODEX_HOME: home, ...extra }, { credentialDestination: () => true }); - diagnostic(keyRead().notes, "installation service credential is unavailable or invalid"); + const missing = keyRead(); + diagnostic(missing.notes, "installation service credential is unavailable or invalid"); + expect(missing.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); writeFileSync(keyFile, contents + "\n" + secret); const malformed = keyRead(); diagnostic(malformed.notes, "installation service credential is unavailable or invalid"); @@ -149,31 +154,46 @@ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([Sy const loaded = keyRead(); expect(loaded.notes).toEqual([]); expect(loaded.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); - for (const [config, reason] of [ - [local + '\n[mcp_servers.fixture]\ncommand="' + contents + '"\n', "configuration has additional credential consumers"], - ['unknown="' + contents + '"\n' + local, "configuration contains unaudited keys or values"], - [local.replace("127.0.0.1", "capture.invalid"), "provider transport does not match the managed loopback provider"], - ['broken = "' + contents, "credential destination inspection failed"], - ]) { - writeFileSync(join(home, "config.toml"), config!); - let reads = 0; - const result = capture({ CODEX_HOME: home }, { - credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), - serviceToken: () => { reads++; return secret; }, - }); - diagnostic(result.notes, reason!); - expect(reads).toBe(0); - expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); - expect(result.notes[0]).not.toContain(root); + } finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } + }); + + for (const [name, addition, reason] of [ + ["MCP consumer", (local: string) => local + '\n[mcp_servers.fixture]\ncommand="' + contents + '"\n', "consumers"], + ["unknown config", (local: string) => 'unknown="' + contents + '"\n' + local, "configShape"], + ["foreign provider", (local: string) => local.replace("127.0.0.1", "capture.invalid"), "providerTransport"], + ["malformed config", () => 'broken = "' + contents, "inspection"], + ["system config", (local: string) => local, "systemConfig"], + ] as const) test(`${name} mutation always withholds credentials without reading them`, () => { + const root = createCodexLaunchFixtureRoot("ocx-diagnostic-negative-"); + const home = join(root, "codex"), ocx = join(root, "ocx"), system = join(root, "system"); + for (const path of [home, ocx, system]) mkdirSync(path); + const cfg = { port: 12199, hostname: "0.0.0.0" }, prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; process.chdir(root); + const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + try { + writeFileSync(join(home, "config.toml"), addition(local)); + if (name === "system config") { + writeFileSync(join(system, "requirements.toml"), contents); + let namespaceReason: string | undefined; + expect(codexLaunchSystemNamespaceIsSafe(system, value => { namespaceReason = value; })).toBe(false); + expect(namespaceReason).toBe("systemConfig"); } - writeFileSync(join(home, "config.toml"), local); - // The production namespace is deliberately not modified by this test. - writeFileSync(join(system, "requirements.toml"), contents); + let reads = 0; const result = capture({ CODEX_HOME: home }, { credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), - serviceToken: () => { throw new Error("must not read"); }, + serviceToken: () => { reads++; return secret; }, }); - diagnostic(result.notes, "machine Codex configuration adds an unaudited system layer"); + // Diagnostic reachability needs protected ancestors; the mutation and + // no-token/zero-read assertions above and below run on every host. + diagnostic(result.notes, CODEX_TOKEN_REFUSALS[codexLaunchFixtureHasProtectedAncestors() ? reason : "ownership"]); + expect(reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(result.notes[0]).not.toContain(root); } finally { process.chdir(cwd); if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 334fa676067..505ce6ffc3e 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -197,8 +197,15 @@ describe("Codex launcher native home and proxy environment", () => { const result = launch(); expect(result.reads).toBe(1); expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + })); + + test("junction or symlink homes with a foreign destination withhold credentials", () => fixture((root, home) => { + const link = join(root, "linked"); symlinkSync(home, link, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEX_HOME = link; writeFileSync(join(home, "config.toml"), foreign); - expect(launch().reads).toBe(0); + const result = launch(); + expect(result.reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); })); for (const key of proxyKeys) for (const bypass of [undefined, "", "localhost", "*"]) { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dd846106934..45f542a4d68 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -610,6 +610,7 @@ "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", "codex-launcher-diagnostics.test.ts": "codex-integration", "codex-launcher-darwin-acl.test.ts": "codex-integration", + "codex-launcher-darwin-identity.test.ts": "codex-integration", "codex-launcher-admission.test.ts": "codex-integration", "codex-launcher.test.ts": "codex-integration", "codex-launcher-process.test.ts": "codex-integration", diff --git a/tests/helpers/codex-launch-darwin-model.ts b/tests/helpers/codex-launch-darwin-model.ts new file mode 100644 index 00000000000..26580bc81c9 --- /dev/null +++ b/tests/helpers/codex-launch-darwin-model.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import * as path from "node:path/posix"; +import { repoPath } from "./repo-root"; + +// Run the production walks and FFI adapter on every host. Only imported IO, +// platform identity and the FFI implementation are modeled; no policy is copied. +const source = readFileSync(repoPath("src/cli/codex-launch-ownership.ts"), "utf8") + .replace(/^import .*;\r?\n/gm, "") + .replace('require("bun:ffi")', "ffi") + .replace(/export function /g, "function "); +const js = new Bun.Transpiler({ loader: "ts", target: "bun" }).transformSync(source); + +export type DarwinWalk = "home" | "alias" | "namespace"; +export type DarwinFault = "none" | "vanished" | "inode" | "device" | "type" | "stat" | "close" | "errno" | "wide-inode"; + +export function darwinOwnershipModel(walk: DarwinWalk, fault: DarwinFault, errno = 13, preferencesForced = false) { + const home = "/owned/home", config = home + "/config.toml", link = "/owned/alias", system = "/owned/system"; + const target = walk === "home" ? config : walk === "alias" ? link : system; + const opened = new Map(), closed: number[] = [], flags: number[] = []; + let reached = false, nextFd = 10, aclReads = 0, forcedReads = 0; + const missing = () => Object.assign(new Error("modeled missing entry"), { code: "ENOENT" }); + function stat(entry: string, options?: { bigint?: boolean }) { + if (/\/(?:config|requirements|managed_config)\.toml$/.test(entry) && entry !== config) throw missing(); + const kind = entry === link ? 0o120000 : entry === config ? 0o100000 : 0o040000; + const ino = fault === "wide-inode" && entry === target ? 9007199254740992n : BigInt(entry.length); + return { dev: options?.bigint ? 1n : 1, ino: options?.bigint ? ino : Number(ino), + mode: options?.bigint ? BigInt(kind | 0o700) : kind | 0o700, uid: options?.bigint ? 101n : 101, + isFile: () => kind === 0o100000, isDirectory: () => kind === 0o040000, isSymbolicLink: () => kind === 0o120000 }; + } + const io = { + lstatSync: stat, + readlinkSync: () => config, + openSync(entry: string, value: number) { + if (entry === target) { reached = true; flags.push(value); if (fault === "vanished") throw missing(); } + const fd = nextFd++; opened.set(fd, entry); return fd; + }, + fstatSync(fd: number, options?: { bigint?: boolean }) { + const entry = opened.get(fd)!, value = stat(entry, options); + if (entry !== target) return value; + if (fault === "stat") throw new Error("modeled fstat failure"); + const ino = BigInt(value.ino) + (fault === "inode" || fault === "wide-inode" ? 1n : 0n); + return { ...value, ino: options?.bigint ? ino : Number(ino), + dev: fault === "device" ? (options?.bigint ? 2n : 2) : value.dev, + mode: fault === "type" ? (options?.bigint ? BigInt(Number(value.mode) ^ 0o140000) : Number(value.mode) ^ 0o140000) : value.mode }; + }, + closeSync(fd: number) { closed.push(fd); if (fault === "close" && opened.get(fd) === target) throw new Error("modeled close failure"); }, + }; + const symbols = { + // These pathname APIs intentionally model the old ambiguous NULL/ENOENT. + acl_get_file: pathnameAcl, acl_get_link_np: pathnameAcl, + acl_get_fd_np(fd: number) { aclReads++; return null; }, + __error: () => 1, + CFStringCreateWithCString: () => 1, + CFPreferencesAppValueIsForced: () => { forcedReads++; return preferencesForced; }, + CFRelease() {}, + }; + function pathnameAcl(value: Buffer) { + const entry = value.toString().replace(/\0$/, ""); + if (entry === target) reached = true; + aclReads++; return null; + } + const ffi = { FFIType: { ptr: 1, i32: 2, u32: 3, bool: 4, void: 5 }, ptr: (value: Buffer) => value, + read: { i32: () => fault === "errno" ? errno : 2 }, dlopen: () => ({ symbols, close() {} }) }; + const { dirname, isAbsolute, join, resolve, sep } = path; + const inputs = { ...io, dirname, isAbsolute, join, resolve, sep, process: { platform: "darwin", getuid: () => 101 }, ffi }; + const api = new Function(...Object.keys(inputs), js + + "\nreturn { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe };")(...Object.values(inputs)); + const admitted = walk === "home" ? api.codexLaunchHomeIsOwned(home) + : walk === "alias" ? api.codexLaunchProjectAliasIsOwned(link) : api.codexLaunchSystemNamespaceIsSafe(system); + return { admitted, reached, flags, opened: [...opened.keys()], closed, aclReads, forcedReads }; +} diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts index 789c0b72509..ad959f83e2d 100644 --- a/tests/helpers/codex-launch-private-fixture.ts +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -60,6 +60,10 @@ export function createCodexLaunchFixtureRoot(prefix: string): string { return root; } +export function codexLaunchFixtureHasProtectedAncestors(): boolean { + return fixtureLocation().owned; +} + /** Gate only assertions that need a protected starting point, with an executed refusal proof. */ export function codexLaunchOwnershipTest(name: string, body: () => void | Promise, timeout?: number): void { const support = fixtureLocation(); @@ -85,8 +89,8 @@ export function codexLaunchOwnershipTest(name: string, body: () => void | Promis /** Still exercise the mutating negative scenario when its later diagnostic is unreachable. */ export function codexLaunchProjectRefusalTest(name: string, body: (reason: "project" | "ownership") => void | Promise): void { - codexLaunchOwnershipTest(name, () => body("project")); - if (!fixtureLocation().owned) test(`${name}: unsafe ancestors still withhold credentials`, () => body("ownership")); + // This is a negative scenario, so its body must never be replaced or skipped. + test(name, () => body(fixtureLocation().owned ? "project" : "ownership")); } // Model a protected, empty machine configuration namespace inside this fixture. From f1ef79444bdaf5158a9d709f916707efeb8b6606 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:50:03 +0900 Subject: [PATCH 18/18] fix(cli): admit protected Darwin Codex symlink aliases --- src/cli/codex-launch-ownership.ts | 7 ++++--- structure/cli-management.md | 2 +- .../codex-launcher-darwin-acl.test.ts | 15 ++++++++++++- .../codex-launcher-darwin-identity.test.ts | 20 ++++++++++++++---- tests/helpers/codex-launch-darwin-model.ts | 21 ++++++++++++++----- 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts index 3bab44e83fa..cf78476fe38 100644 --- a/src/cli/codex-launch-ownership.ts +++ b/src/cli/codex-launch-ownership.ts @@ -216,9 +216,10 @@ function macHasNoManagedCodexPreferences(paths: ReadonlyMap { expect(ownership.darwinAclEntriesAreSafe(null)).toBe(false); }); + test.skipIf(process.platform !== "darwin")("native protected symlinked user .codex admits", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-darwin-link-"))); + const home = join(root, "codex-home"), user = join(root, "user"), alias = join(user, ".codex"); + try { + for (const directory of [root, home, user]) { mkdirSync(directory, { recursive: true }); chmodSync(directory, 0o700); } + writeFileSync(join(home, "config.toml"), 'model = "fixture"\n', { mode: 0o600 }); + symlinkSync(home, alias, "dir"); + expect(ownership.codexLaunchHomeIsOwned(realpathSync(alias))).toBe(true); + expect(ownership.codexLaunchProjectAliasIsOwned(alias)).toBe(true); + expect(ownership.codexLaunchProjectAliasIsOwned(join(alias, "config.toml"))).toBe(true); + } finally { removeTreeWithRetry(root); } + }); + test.skipIf(process.platform !== "darwin")("native deny-delete ACL accepts; a later allow-write ACL refuses", () => { const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-darwin-acl-"))); const home = join(root, "codex"); diff --git a/tests/codex-integration/codex-launcher-darwin-identity.test.ts b/tests/codex-integration/codex-launcher-darwin-identity.test.ts index 4b367977a68..4d15f30d900 100644 --- a/tests/codex-integration/codex-launcher-darwin-identity.test.ts +++ b/tests/codex-integration/codex-launcher-darwin-identity.test.ts @@ -10,16 +10,16 @@ for (const walk of ["home", "alias", "namespace"] as DarwinWalk[]) { expect(result.closed.sort()).toEqual(result.opened.sort()); }); } - test(`Darwin ${walk}: the same existing object without an ACL admits`, () => { + test(`Darwin ${walk}: ${walk === "alias" ? "protected symlink alias" : "the same existing object"} without an ACL admits`, () => { const result = darwinOwnershipModel(walk, "none"); expect(result.reached).toBe(true); expect(result.admitted).toBe(true); expect(result.aclReads).toBeGreaterThan(0); expect(result.forcedReads).toBe(2); expect(result.closed.sort()).toEqual(result.opened.sort()); - // Apple fcntl.h: O_NOFOLLOW, O_SYMLINK for links; O_DIRECTORY for directories. - expect(result.flags[0]! & 0x100).toBe(0x100); - if (walk === "alias") expect(result.flags[0]! & 0x200000).toBe(0x200000); + // XNU rejects final symlinks with O_NOFOLLOW even alongside O_SYMLINK. + expect(result.flags[0]! & 0x200100).toBe(walk === "alias" ? 0x200000 : 0x100); + expect(result.flags[0]! & 0x1000004).toBe(0x1000004); // NONBLOCK | CLOEXEC if (walk === "namespace") expect(result.flags[0]! & 0x100000).toBe(0x100000); }); for (const errno of [0, 5, 12, 13, 22, 45]) test(`Darwin ${walk}: NULL ACL errno ${errno} refuses`, () => { @@ -34,3 +34,15 @@ for (const walk of ["home", "alias", "namespace"] as DarwinWalk[]) { expect(result.forcedReads).toBe(1); }); } + +for (const fault of ["link-file", "link-directory"] as const) { + test(`Darwin alias: ${fault} replacement between lstat and open refuses before ACL read`, () => { + const result = darwinOwnershipModel("alias", fault); + expect(result.reached).toBe(true); + expect(result.flags[0]! & 0x200100).toBe(0x200000); + expect(result.admitted).toBe(false); + expect(result.aclReads).toBe(0); + expect(result.opened).toHaveLength(1); + expect(result.closed).toEqual(result.opened); + }); +} diff --git a/tests/helpers/codex-launch-darwin-model.ts b/tests/helpers/codex-launch-darwin-model.ts index 26580bc81c9..253c8863193 100644 --- a/tests/helpers/codex-launch-darwin-model.ts +++ b/tests/helpers/codex-launch-darwin-model.ts @@ -11,17 +11,17 @@ const source = readFileSync(repoPath("src/cli/codex-launch-ownership.ts"), "utf8 const js = new Bun.Transpiler({ loader: "ts", target: "bun" }).transformSync(source); export type DarwinWalk = "home" | "alias" | "namespace"; -export type DarwinFault = "none" | "vanished" | "inode" | "device" | "type" | "stat" | "close" | "errno" | "wide-inode"; +export type DarwinFault = "none" | "vanished" | "inode" | "device" | "type" | "stat" | "close" | "errno" | "wide-inode" | "link-file" | "link-directory"; export function darwinOwnershipModel(walk: DarwinWalk, fault: DarwinFault, errno = 13, preferencesForced = false) { const home = "/owned/home", config = home + "/config.toml", link = "/owned/alias", system = "/owned/system"; const target = walk === "home" ? config : walk === "alias" ? link : system; const opened = new Map(), closed: number[] = [], flags: number[] = []; + const descriptors = new Map>(); let reached = false, nextFd = 10, aclReads = 0, forcedReads = 0; const missing = () => Object.assign(new Error("modeled missing entry"), { code: "ENOENT" }); - function stat(entry: string, options?: { bigint?: boolean }) { + function stat(entry: string, options?: { bigint?: boolean }, kind = entry === link ? 0o120000 : entry === config ? 0o100000 : 0o040000) { if (/\/(?:config|requirements|managed_config)\.toml$/.test(entry) && entry !== config) throw missing(); - const kind = entry === link ? 0o120000 : entry === config ? 0o100000 : 0o040000; const ino = fault === "wide-inode" && entry === target ? 9007199254740992n : BigInt(entry.length); return { dev: options?.bigint ? 1n : 1, ino: options?.bigint ? ino : Number(ino), mode: options?.bigint ? BigInt(kind | 0o700) : kind | 0o700, uid: options?.bigint ? 101n : 101, @@ -32,10 +32,21 @@ export function darwinOwnershipModel(walk: DarwinWalk, fault: DarwinFault, errno readlinkSync: () => config, openSync(entry: string, value: number) { if (entry === target) { reached = true; flags.push(value); if (fault === "vanished") throw missing(); } - const fd = nextFd++; opened.set(fd, entry); return fd; + // XNU vn_authorize_open_existing: VLNK + O_NOFOLLOW is ELOOP, + // including when O_SYMLINK is also set (bsd/vfs/vfs_subr.c). + const kind = entry === target && fault === "link-file" ? 0o100000 + : entry === target && fault === "link-directory" ? 0o040000 : undefined; + let actual = stat(entry, { bigint: true }, kind); + if (actual.isSymbolicLink()) { + if (value & 0x100) throw Object.assign(new Error("modeled final symlink"), { code: "ELOOP" }); + if (!(value & 0x200000)) actual = stat(config, { bigint: true }); + } + // O_SYMLINK also opens non-links. Retain that object for fstat even + // when a replacement has the old dev/ino; its type must still differ. + const fd = nextFd++; opened.set(fd, entry); descriptors.set(fd, actual); return fd; }, fstatSync(fd: number, options?: { bigint?: boolean }) { - const entry = opened.get(fd)!, value = stat(entry, options); + const entry = opened.get(fd)!, value = descriptors.get(fd)!; if (entry !== target) return value; if (fault === "stat") throw new Error("modeled fstat failure"); const ino = BigInt(value.ino) + (fault === "inode" || fault === "wide-inode" ? 1n : 0n);