diff --git a/docs-site/src/content/docs/fr/guides/codex-prompt.md b/docs-site/src/content/docs/fr/guides/codex-prompt.md index 33793afbcc2..2641bb0c050 100644 --- a/docs-site/src/content/docs/fr/guides/codex-prompt.md +++ b/docs-site/src/content/docs/fr/guides/codex-prompt.md @@ -36,6 +36,12 @@ temporairement indisponible, ce qui n’est pas le cas. Cliquez sur le nom d’une couche pour afficher le texte qu’elle envoie. La boîte de dialogue le lit depuis `codex debug prompt-input` : il s’agit donc du contenu réel, et non d’une description. +Chaque appel de lecture dispose d’un délai fixe de 15 secondes, qui inclut la lecture des +métadonnées et l’attente d’une sonde partagée. L’expiration est vérifiée après la fin de la +lecture synchrone des métadonnées ; si le délai est épuisé, aucune nouvelle sonde ne démarre. +À l’expiration, la boîte de dialogue indique que le prompt n’a pas pu être lu, mais les +instructions de base lues localement sont tout de même renvoyées. + Il arrive qu’il n’y ait rien à afficher. La boîte de dialogue précise alors la raison : - **Le fichier existe, mais il est vide.** Votre fichier `~/.codex/AGENTS.md` ne contient aucun diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 102bab1aa8a..4eb3e800e34 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -427,6 +427,14 @@ $CODEX_HOME/models_cache.json On macOS, opencodex checks the Codex app’s bundled CLI in `/Applications/ChatGPT.app` and then `~/Applications/ChatGPT.app` after explicit selections, shims, PATH, established installs, and the runnable bare `codex` fallback, requires the bundle’s XML `Info.plist` identity to be `com.openai.codex`, and never automatically replaces a runnable persisted selection with an app candidate. +Execution checks also recognize `Codex.app` and `ChatGPT.app` at any location, including relocated +bundles, explicit `CODEX_CLI_PATH` or configured runtime commands, and symlink aliases. A recognized +name triggers verification; it does not grant trust. Before each execution, the canonical bundle +and exact binary must pass strict signature verification with OpenAI's Team ID and the ownership, +permission and ancestor-directory policy. The binary must resolve inside the bundle's `Contents/` +(including `Codex.app/Contents/Resources/codex-aarch64-apple-darwin`); unresolved bundles or binaries +are refused. Arbitrary non-app CLIs and third-party app bundles keep their existing execution policy. + On WSL, if `CODEX_HOME` is unset and the Linux `~/.codex` directory is absent or holds no Codex state (`config.toml`, `auth.json`, `sessions`, `history.jsonl`), opencodex also checks for a single Windows Codex Desktop home at `/mnt/c/Users/*/.codex/config.toml`. When exactly diff --git a/docs-site/src/content/docs/guides/codex-prompt.md b/docs-site/src/content/docs/guides/codex-prompt.md index 75610d3a24b..5ecd7f46cd4 100644 --- a/docs-site/src/content/docs/guides/codex-prompt.md +++ b/docs-site/src/content/docs/guides/codex-prompt.md @@ -36,6 +36,12 @@ unavailable, which is not the case. Click a layer name to see the text it sends. The dialog reads it from `codex debug prompt-input`, so it is the real thing rather than a description. +The read has one fixed 15-second caller budget, including time spent +reading metadata and waiting on a shared probe. Synchronous metadata reads finish +before expiry can be checked; an exhausted budget then prevents a new probe. +If it expires, the dialog reports that the prompt +could not be read; locally available base instructions are still returned. + Sometimes there is nothing to show, and the dialog says which reason applies: - **The file exists but is empty.** Your `~/.codex/AGENTS.md` is zero bytes, so diff --git a/docs-site/src/content/docs/ja/guides/codex-prompt.md b/docs-site/src/content/docs/ja/guides/codex-prompt.md index fa1639d47bf..30fa39a9188 100644 --- a/docs-site/src/content/docs/ja/guides/codex-prompt.md +++ b/docs-site/src/content/docs/ja/guides/codex-prompt.md @@ -36,6 +36,11 @@ Codex が実際には使用しない順序を示すことになります。 レイヤー名をクリックすると、送信されるテキストを確認できます。ダイアログは `codex debug prompt-input` から内容を読み取るため、説明ではなく実際の送信内容です。 +読み取りには、メタデータの読み取りと共有プローブの待機時間を含む、呼び出しごとに固定の +15 秒の制限があります。同期的なメタデータの読み取りが終わってから時間切れを確認し、 +制限に達していれば新しいプローブを開始しません。時間切れになると、ダイアログはプロンプトを +読み取れなかったと表示しますが、ローカルで読み取れた基本指示は引き続き返されます。 + 表示する内容がない場合もあり、ダイアログに該当する理由が表示されます。 - **ファイルは存在しますが空です。** `~/.codex/AGENTS.md` が 0 バイトのため、 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index ad7c94bb7d5..717a24f1da0 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -213,6 +213,14 @@ $CODEX_HOME/models_cache.json macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다. +실행 시에는 위치와 관계없이 `Codex.app`과 `ChatGPT.app`을 인식하며, 이동한 번들, 명시적 +`CODEX_CLI_PATH`·설정 런타임 명령, 심볼릭 링크 별칭에도 같은 검증을 적용합니다. 앱 이름은 검증 +대상만 결정하고 신뢰를 허용하지 않습니다. 매 실행 전에 정규화한 번들과 정확한 실행 파일의 +엄격한 서명 검증, OpenAI Team ID, 소유권·권한·상위 디렉터리 정책을 확인합니다. 실행 파일은 +번들의 `Contents/` 안에 있어야 하며(Codex.app의 `Contents/Resources/codex-aarch64-apple-darwin` +포함), 번들이나 실행 파일을 정규화할 수 없으면 실행을 거부합니다. 일반 CLI와 다른 회사 앱의 +번들은 기존 실행 정책을 유지합니다. + WSL에서는 `CODEX_HOME`이 비어 있고 Linux `~/.codex` 디렉터리가 없거나 Codex 상태(`config.toml`, `auth.json`, `sessions`, `history.jsonl`)가 전혀 없을 때 `/mnt/c/Users/*/.codex/config.toml` 아래의 단일 Windows Codex Desktop home도 확인합니다. 후보가 정확히 하나면 그 디렉터리를 사용하므로 WSL app-server mode와 Windows Codex Desktop이 같은 config와 auth 파일을 공유합니다. 이 탐지를 덮으려면 `CODEX_HOME`을 명시하세요. Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 런타임 home으로 설정할 수 있지만, ChatGPT/Codex app은 여전히 `%USERPROFILE%\\.codex`를 읽습니다. `ocx status`와 `ocx doctor`는 이 정확한 불일치를 경고하고, 경로는 가린 채 대상 home을 출력합니다. 해당 Orca shell에서 background service를 설치했다면 먼저 원래 shell에서 uninstall하고, `CODEX_HOME`을 app home으로 설정한 뒤 `ORCA_CODEX_HOME`을 해제하고, sync/restore를 다시 실행한 다음 service를 다시 설치하세요. diff --git a/docs-site/src/content/docs/ko/guides/codex-prompt.md b/docs-site/src/content/docs/ko/guides/codex-prompt.md index 318468b05d8..d01f82b5df1 100644 --- a/docs-site/src/content/docs/ko/guides/codex-prompt.md +++ b/docs-site/src/content/docs/ko/guides/codex-prompt.md @@ -35,6 +35,11 @@ Codex가 실제로 쓰지 않는 순서를 보여 주게 됩니다. 레이어 이름을 클릭하면 전송되는 텍스트를 볼 수 있습니다. 대화 상자는 `codex debug prompt-input`에서 내용을 읽으므로 설명문이 아닌 실제 전송 내용입니다. +읽기에는 호출마다 고정된 15초 제한이 있으며, 메타데이터를 읽는 시간과 공유 프로브를 +기다리는 시간도 포함됩니다. 동기식 메타데이터 읽기가 끝난 뒤에 시간 초과를 확인하므로, +그때 제한에 도달했다면 새 프로브를 시작하지 않습니다. 시간이 초과되면 대화 상자는 +프롬프트를 읽지 못했다고 표시하지만, 로컬에서 읽은 기본 지침은 계속 반환됩니다. + 표시할 내용이 없을 때도 있으며, 대화 상자에 해당하는 이유가 나옵니다. - **파일은 있지만 비어 있습니다.** `~/.codex/AGENTS.md`가 0바이트라서 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 53fda43408b..afd0665bc8b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -253,7 +253,7 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | Method and path | Purpose | Notable errors | | --- | --- | --- | | `GET /api/codex-prompt` | Read the prompt-layer snapshot: layers, base variants, selection, and drift state | — | -| `GET /api/codex-prompt/text` | Probe the model-visible prompt text via `codex debug prompt-input` | Fail-soft: an unavailable probe degrades to a status in the body, not an HTTP error | +| `GET /api/codex-prompt/text` | Probe the model-visible prompt text via `codex debug prompt-input`; one caller budget includes metadata, shared wait and execution; synchronous metadata is checked for expiry after it returns | Fail-soft: an unavailable probe degrades to a status in the body, not an HTTP error; timeout preserves locally read base instructions; earlier external cancellation stays cancelled | | `PUT /api/codex-prompt/toggle` | Enable or disable one toggleable layer | 400 invalid body or unknown layer; 409 `stale_revision`, `layer_not_toggleable` | | `PUT /api/codex-prompt/custom` | Replace the custom-layer set | 400 invalid body, `invalid_characters`, `body_too_large` when a normalized UTF-8 layer exceeds 65,536 bytes, `composed_too_large` over 131,072 bytes; 409 `stale_revision` | | `PUT /api/codex-prompt/base/select` | Select the default base prompt or one stored variant | 400 invalid body, `unknown_layer` for an id matching no stored variant; 409 `stale_revision`, `developer_instructions_not_owned` when the current base is external | diff --git a/docs-site/src/content/docs/ru/guides/codex-prompt.md b/docs-site/src/content/docs/ru/guides/codex-prompt.md index 528a20c663c..125a7328613 100644 --- a/docs-site/src/content/docs/ru/guides/codex-prompt.md +++ b/docs-site/src/content/docs/ru/guides/codex-prompt.md @@ -36,6 +36,11 @@ Codex собирает свой промпт из слоёв: собственн Нажмите на название слоя, чтобы увидеть отправляемый им текст. Диалоговое окно получает его из `codex debug prompt-input`, поэтому показывает реальное содержимое, а не описание. +Для каждого вызова установлен фиксированный срок чтения — 15 секунд, включая чтение метаданных и ожидание +общей проверки. Истечение срока проверяется после завершения синхронного чтения метаданных; +если время уже исчерпано, новая проверка не запускается. При истечении срока диалог сообщает, +что промпт не удалось прочитать, но базовые инструкции, прочитанные локально, всё равно возвращаются. + Иногда показывать нечего, и диалоговое окно указывает причину: - **Файл существует, но пуст.** Ваш файл `~/.codex/AGENTS.md` имеет нулевой размер, поэтому diff --git a/docs-site/src/content/docs/tr/guides/codex-prompt.md b/docs-site/src/content/docs/tr/guides/codex-prompt.md index 856368e1000..ce859996b23 100644 --- a/docs-site/src/content/docs/tr/guides/codex-prompt.md +++ b/docs-site/src/content/docs/tr/guides/codex-prompt.md @@ -38,6 +38,12 @@ Gönderdiği metni görmek için katman adına tıklayın. İletişim kutusu bun `codex debug prompt-input` üzerinden okur; dolayısıyla bir açıklama değil, gerçekte gönderilen metindir. +Her okuma çağrısının, meta verileri okuma ve ortak incelemeyi bekleme süresini de içeren, +sabit 15 saniyelik bir süresi vardır. Sürenin dolup dolmadığı eşzamanlı meta veri okuması +tamamlandıktan sonra denetlenir; süre tükenmişse yeni bir inceleme başlatılmaz. +Süre dolarsa iletişim kutusu istemin okunamadığını bildirir, ancak yerel olarak okunan +temel talimatlar yine de döndürülür. + Bazen gösterilecek bir şey olmaz ve iletişim kutusu hangi nedenin geçerli olduğunu belirtir: diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 0ade0e19282..af2df25fe35 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -144,6 +144,13 @@ $CODEX_HOME/models_cache.json 在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。 +执行检查还会识别任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移动后的应用包、显式 +`CODEX_CLI_PATH` 或配置的运行时命令,以及符号链接别名。名称只触发验证,不代表可信。 +每次执行前,规范化的应用包和确切二进制都必须通过严格签名验证、OpenAI Team ID 及所有权、 +权限和祖先目录策略。二进制必须位于包的 `Contents/` 内(包括 Codex.app 的 +`Contents/Resources/codex-aarch64-apple-darwin`);无法规范化应用包或二进制时拒绝执行。 +普通非应用 CLI 和第三方应用包保留原有执行策略。 + 在 WSL 中,如果未设置 `CODEX_HOME`,且 Linux 侧的 `~/.codex` 目录不存在或不含任何 Codex 状态(`config.toml`, `auth.json`, `sessions`, `history.jsonl`),opencodex 还会检查 `/mnt/c/Users/*/.codex/config.toml` 下是否存在单一的 Windows Codex Desktop home。只要候选项恰好只有一个, 它就会使用那个目录,让 WSL app-server mode 和 Windows Codex Desktop 共享同一份 config 与 auth 文件。 diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-prompt.md b/docs-site/src/content/docs/zh-cn/guides/codex-prompt.md index f98d7901ac5..6ae13c88b47 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-prompt.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-prompt.md @@ -34,6 +34,10 @@ Codex 会分层组装提示词:它自己的基础指令、你的项目文档 点击层名称即可查看它发送的文本。对话框会从 `codex debug prompt-input` 读取内容, 所以显示的是真实内容,而不是描述。 +每次读取都有固定的 15 秒时限,包括读取元数据和等待共享探测所花的时间。 +同步元数据读取结束后才能检查是否超时;如果时限已用尽,就不会启动新的探测。 +超时后,对话框会提示无法读取提示词,但仍会返回已从本地读取的基础指令。 + 有时没有内容可显示,对话框会说明具体原因: - **文件存在但为空。** 你的 `~/.codex/AGENTS.md` 是零字节,因此该层没有内容可发送。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 04ba8bd8922..d3c3a0c0741 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -141,6 +141,13 @@ $CODEX_HOME/models_cache.json 在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。 +執行檢查也會辨識任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移動後的應用程式套件、明確的 +`CODEX_CLI_PATH` 或設定的執行階段命令,以及符號連結別名。名稱只觸發驗證,不代表可信。 +每次執行前,正規化的套件和確切二進位檔都必須通過嚴格簽章驗證、OpenAI Team ID 及擁有權、 +權限和祖先目錄政策。二進位檔必須位於套件的 `Contents/` 內(包含 Codex.app 的 +`Contents/Resources/codex-aarch64-apple-darwin`);無法正規化套件或二進位檔時拒絕執行。 +一般非應用程式 CLI 和第三方套件保留原有執行政策。 + 在 WSL 中,如果未設定 `CODEX_HOME`,且 Linux 的 `~/.codex` 目錄不存在或不含任何 Codex 狀態(`config.toml`, `auth.json`, `sessions`, `history.jsonl`),opencodex 也會檢查 `/mnt/c/Users/*/.codex/config.toml` 下是否只有一個 Windows Codex Desktop home。候選項恰好只有一個時, 會使用該目錄,讓 WSL app-server mode 與 Windows Codex Desktop 共用相同的 config 與 auth 檔案。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-prompt.md b/docs-site/src/content/docs/zh-tw/guides/codex-prompt.md index 66b30558dd3..a2d96c2b092 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-prompt.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-prompt.md @@ -34,6 +34,10 @@ Codex 會分層組裝提示詞:它自己的基礎指令、你的專案文件 點擊層名稱即可查看它傳送的文字。對話框會從 `codex debug prompt-input` 讀取內容, 所以顯示的是真實內容,而不是描述。 +每次讀取都有固定的 15 秒時限,包括讀取中繼資料與等待共用探測所花的時間。 +同步中繼資料讀取結束後才能檢查是否逾時;如果時限已用盡,就不會啟動新的探測。 +逾時後,對話框會提示無法讀取提示詞,但仍會傳回已從本機讀取的基礎指示。 + 有時沒有內容可顯示,對話框會說明具體原因: - **檔案存在但為空。** 你的 `~/.codex/AGENTS.md` 是零位元組,因此該層沒有內容可傳送。 diff --git a/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs b/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs index f7edded76da..dd498b89ff2 100644 --- a/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs +++ b/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs @@ -342,24 +342,42 @@ fn invalidation_and_stopped_source_discard_late_catalogs() { let result = contract( "invalidation", r#" -let release; -let current = true; -const deps = {commandCandidates:()=>['C:\\fixture\\codex.exe'], - execFile:()=>new Promise(resolve=>{release=resolve})}; -const pending = bundled.loadBundledCodexCatalogAsync(deps); -bundled.invalidateBundledCatalogCache(); -release(JSON.stringify(catalog)); -const invalidated = await pending; -const stoppedPending = bundled.loadBundledCodexCatalogAsync(deps,()=>current); -current = false; -release(JSON.stringify(catalog)); -return {invalidated:invalidated===null, stopped:(await stoppedPending)===null, +async function loadCase(change,phase) { + let current = phase!=='before-call', release, entered, executions=0; + const enteredPromise=new Promise(resolve=>{entered=resolve}); + const deps={commandCandidates:()=>['C:\\fixture\\codex.exe'], + execFile:()=>new Promise(resolve=>{executions++;release=resolve;entered()})}; + const pending=bundled.loadBundledCodexCatalogAsync(deps,()=>current); + const mutate=()=>{if(change==='invalidate')bundled.invalidateBundledCatalogCache();else current=false}; + if(phase==='immediate')mutate(); // before the first await completes + // Each load owns its entry/release: a no-entry completion cannot shift another case. + const outcome=await Promise.race([ + enteredPromise.then(()=>'entered'),pending.then(()=>'settled',()=>'settled')]); + if(phase==='entered' && outcome==='entered')mutate(); + if(outcome==='entered')release(JSON.stringify(catalog)); + return {rejected:(await pending)===null,outcome,executions}; +} +// Exercise a real no-entry completion before the in-flight cases, not just their race helper. +const beforeCall=await loadCase('stop','before-call'); +const invalidatedImmediate=await loadCase('invalidate','immediate'); +const invalidatedInflight=await loadCase('invalidate','entered'); +const stoppedImmediate=await loadCase('stop','immediate'); +const stoppedInflight=await loadCase('stop','entered'); +return {stoppedBeforeCall:beforeCall.rejected,beforeCallOutcome:beforeCall.outcome,beforeCallExecutions:beforeCall.executions, + invalidatedImmediate:invalidatedImmediate.rejected, + invalidatedInflight:invalidatedInflight.rejected, + stoppedImmediate:stoppedImmediate.rejected, + stoppedInflight:stoppedInflight.rejected, + inflightOutcome:invalidatedInflight.outcome,stopInflightOutcome:stoppedInflight.outcome, empty:bundled.bundledCatalogCacheState().valueIdentity===null}; "#, ); assert_eq!( result, - serde_json::json!({"invalidated":true,"stopped":true,"empty":true}) + serde_json::json!({"stoppedBeforeCall":true,"beforeCallOutcome":"settled","beforeCallExecutions":0, + "invalidatedImmediate":true,"invalidatedInflight":true, + "stoppedImmediate":true,"stoppedInflight":true, + "inflightOutcome":"entered","stopInflightOutcome":"entered","empty":true}) ); } @@ -370,34 +388,57 @@ fn async_runtime_persistence_rejects_pin_changes_cache_clear_and_stopped_sources r#" const initial = {version:1,command:'C:\\fixture\\codex.exe',source:'environment',selectedVersion:'0.159.0', origin:'discovered',updatedAt:'2026-10-08T00:00:00Z'}; -async function resolveCase(change) { +async function resolveCase(change,phase) { let release; + let entered; + const enteredPromise = new Promise(resolve=>{entered=resolve}); let bytes = JSON.stringify(initial); let current = true; - const dir = join(root,change); fs.mkdirSync(dir); + const dir = join(root,change+'-'+phase); fs.mkdirSync(dir); const deps = {configDir:dir,env:{CODEX_CLI_PATH:initial.command,PATH:''},platform:'win32', discoverAlternatives:false,existsSync:()=>true,readdirSync:()=>[], readFileSync:path=>{if(path.endsWith('codex-runtime.json')) return bytes; throw Error('absent')}, - execFile:()=>new Promise(resolve=>{release=resolve})}; + execFile:()=>new Promise(resolve=>{release=resolve;entered()})}; const pending = runtime.resolveAndPersistCodexRuntimeAsync(deps,()=>current); - if(change==='pin') bytes = JSON.stringify({...initial,origin:'pinned'}); // same timestamp and command - if(change==='clear') runtime.clearCodexRuntimeResolveCache(); - if(change==='stop') current = false; - release('codex-cli 0.160.0'); + const mutate=()=>{ + if(change==='pin') bytes = JSON.stringify({...initial,origin:'pinned'}); // same timestamp and command + if(change==='clear') runtime.clearCodexRuntimeResolveCache(); + if(change==='stop') current = false; + }; + // immediate mutations land before the first await completes - the original ordering. + if(phase==='immediate')mutate(); + // Early-return guards may settle before the executor runs: race entry against settle. + const outcome=await Promise.race([enteredPromise.then(()=>'entered'),pending.then(()=>'settled',()=>'settled')]); + if(phase==='entered')mutate(); // while the executor is in-flight + if(outcome==='entered')release('codex-cli 0.160.0'); const resolved = await pending; const file = join(dir,'codex-runtime.json'); return {accepted:resolved!==null,written:fs.existsSync(file), - version:fs.existsSync(file)?JSON.parse(fs.readFileSync(file,'utf8')).selectedVersion:null}; + version:fs.existsSync(file)?JSON.parse(fs.readFileSync(file,'utf8')).selectedVersion:null,outcome}; } -return {pin:await resolveCase('pin'),clear:await resolveCase('clear'),stop:await resolveCase('stop'),valid:await resolveCase('valid')}; +return {pinImmediate:await resolveCase('pin','immediate'),pinInflight:await resolveCase('pin','entered'), + clearImmediate:await resolveCase('clear','immediate'),clearInflight:await resolveCase('clear','entered'), + stopImmediate:await resolveCase('stop','immediate'),stopInflight:await resolveCase('stop','entered'), + valid:await resolveCase('valid','entered')}; "#, ); - for name in ["pin", "clear", "stop"] { + for name in [ + "pinImmediate", + "pinInflight", + "clearImmediate", + "clearInflight", + "stopImmediate", + "stopInflight", + ] { assert_eq!(result[name]["accepted"], false); assert_eq!(result[name]["written"], false); } assert_eq!(result["valid"]["accepted"], true); assert_eq!(result["valid"]["version"], "0.160.0"); + // An in-flight mutation only exercises the guard if the executor was actually running. + for name in ["pinInflight", "clearInflight", "stopInflight", "valid"] { + assert_eq!(result[name]["outcome"], "entered"); + } } #[test] fn failed_refresh_keeps_confirmed_rows_without_a_request_retry_storm() { diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 93d0a0f8034..7888e8bdd09 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -21,6 +21,7 @@ "droid-normalized-reasoning-defaults.test.ts": "clients", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-bundle-trust-async.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "desktop-supervised-restart.test.ts": "clients", "link-home-settings.test.ts": "clients", diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json new file mode 100644 index 00000000000..e64ba5579f4 --- /dev/null +++ b/scripts/test-layout/layout-exec-boundary.json @@ -0,0 +1,5 @@ +{ + "exec-trust-boundary.test.ts": "codex-integration", + "exec-boundary-launch.test.ts": "codex-integration", + "exec-known-app-bundles.test.ts": "codex-integration" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bcc30c7985a..360111f20fb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json"], "explicit": { "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", "server-combo-stream-required-tool.test.ts": "server", "combo-required-tool-stream-preflight.test.ts": "routing", diff --git a/src/chatgpt/app-server-shim/bundle-trust-async.ts b/src/chatgpt/app-server-shim/bundle-trust-async.ts new file mode 100644 index 00000000000..cd3e6bdcad6 --- /dev/null +++ b/src/chatgpt/app-server-shim/bundle-trust-async.ts @@ -0,0 +1,111 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { join } from "node:path"; +import { SUBPROCESS_KILL_GRACE_MS, waitForSubprocessExit } from "../../lib/bounded-subprocess"; +import { bundleSignatureDetailsReason, bundleTrustStat, untrustedBundleFilesystemReason, type AsyncBundleTrustDeps } from "./bundle-trust-policy"; + +export type { AsyncBundleTrustDeps } from "./bundle-trust-policy"; + +type CommandResult = { status: number | null; output: string; stdout: string }; +// Match spawnSync's default per-stream maxBuffer. Overflow never supplies successful trust evidence. +const MAX_OUTPUT_BYTES = 1024 * 1024; + +/** Spawn contract used by the trust runner; injectable so cancellation/reaping can be verified anywhere. */ +export type TrustCommandSpawn = ( + file: string, + args: readonly string[], + options: { stdio: ["ignore", "pipe", "pipe"]; windowsHide: boolean }, +) => ChildProcess; + +function runTrustCommand(file: string, args: readonly string[], timeout: number, signal?: AbortSignal, spawnImpl: TrustCommandSpawn = spawn): Promise { + signal?.throwIfAborted(); + return new Promise(resolve => { + const child = spawnImpl(file, [...args], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + const chunks: Buffer[][] = [[], []]; + const sizes = [0, 0]; + let settled = false; + let stopping = false; + let timer: ReturnType | undefined; + const finish = (status: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + const stdout = Buffer.concat(chunks[0]!).toString("utf8"); + resolve({ status, stdout, output: stdout + Buffer.concat(chunks[1]!).toString("utf8") }); + }; + const exited = new Promise(resolveExit => { + child.once("exit", code => resolveExit(code ?? 1)); + child.once("error", () => { if (!child.pid) resolveExit(1); }); + }); + const stop = () => { + if (stopping || settled) return; + stopping = true; + clearTimeout(timer); + try { child.kill(); } catch { /* exit observation owns reaping */ } + child.stdout?.destroy(); + child.stderr?.destroy(); + void waitForSubprocessExit({ exited, kill: () => child.kill("SIGKILL") }, SUBPROCESS_KILL_GRACE_MS) + .then(() => finish(null)); + }; + const abort = () => stop(); + for (const [index, stream] of [child.stdout, child.stderr].entries()) { + if (stream === null) continue; + stream.on("data", (chunk: Buffer) => { + if (stopping || settled) return; + sizes[index] = sizes[index]! + chunk.length; + if (sizes[index]! > MAX_OUTPUT_BYTES) { stop(); return; } + chunks[index]!.push(chunk); + }); + stream.on("error", stop); + } + child.once("error", () => { if (child.pid) stop(); else finish(null); }); + child.once("close", code => { if (!stopping) finish(code); }); + timer = setTimeout(stop, timeout); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) stop(); + }); +} + +/** Per-call lookup: no directory-service subprocess or signature check blocks the event loop. */ +export async function defaultAsyncBundleTrustDeps( + signal?: AbortSignal, + overrides?: { spawn?: TrustCommandSpawn }, +): Promise { + signal?.throwIfAborted(); + const spawnImpl = overrides?.spawn ?? spawn; + const admin = await runTrustCommand("/usr/bin/dscl", [".", "-read", "/Groups/admin", "PrimaryGroupID"], 5_000, signal, spawnImpl); + signal?.throwIfAborted(); + const adminId = admin.status === 0 ? /^PrimaryGroupID: ([0-9]+)\s*$/.exec(admin.stdout)?.[1] : undefined; + return { + adminGroupId: adminId === undefined ? undefined : Number(adminId), + uid: typeof process.getuid === "function" ? process.getuid() : -1, + stat: bundleTrustStat, + codesign: (args, codeSignal) => runTrustCommand("/usr/bin/codesign", args, 30_000, codeSignal ?? signal, spawnImpl), + }; +} + +/** Same trust policy as the sync API; cancellation rejects only after the owned child is reaped. */ +export async function untrustedChatgptBundleReasonAsync( + bundleRoot: string, + binary: string | undefined = undefined, + deps?: AsyncBundleTrustDeps, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const checks = deps ?? await defaultAsyncBundleTrustDeps(signal); + signal?.throwIfAborted(); + const filesystemProblem = untrustedBundleFilesystemReason(bundleRoot, binary, checks); + if (filesystemProblem) return filesystemProblem; + binary ??= join(bundleRoot, "Contents", "MacOS", "ChatGPT"); + for (const target of [bundleRoot, binary]) { + signal?.throwIfAborted(); + const verified = await checks.codesign(["--verify", "--strict", target], signal); + signal?.throwIfAborted(); + if (verified.status !== 0) return `${target} failed code-signature verification`; + const details = await checks.codesign(["-dv", "--verbose=2", target], signal); + signal?.throwIfAborted(); + const detailsProblem = bundleSignatureDetailsReason(target, details); + if (detailsProblem) return detailsProblem; + } + return null; +} diff --git a/src/chatgpt/app-server-shim/bundle-trust-policy.ts b/src/chatgpt/app-server-shim/bundle-trust-policy.ts new file mode 100644 index 00000000000..4bb2c1b93a6 --- /dev/null +++ b/src/chatgpt/app-server-shim/bundle-trust-policy.ts @@ -0,0 +1,83 @@ +import { lstatSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; + +/** + * Launch and restore execute code from a discovered bundle, so the bundle must be + * OpenAI-signed and pass ownership/permission checks along its filesystem path. Bundle + * identifier alone (what discovery checks) is plain text in Info.plist that anyone can copy. + */ +export const OPENAI_TEAM_ID = "2DC432GLL2"; + +export interface BundleTrustDeps { + readonly uid: number; + readonly adminGroupId?: number; + stat(path: string): { uid: number; gid?: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null; + codesign(args: readonly string[]): { status: number | null; output: string }; +} + +export interface AsyncBundleTrustDeps extends Omit { + codesign(args: readonly string[], signal?: AbortSignal): Promise<{ status: number | null; output: string }>; +} + +export function bundleTrustStat(path: string): ReturnType { + try { + const s = lstatSync(path); + return { uid: s.uid, gid: s.gid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() }; + } catch { return null; } +} + +type FilesystemTrustDeps = Omit; + +function ownedSafely(deps: FilesystemTrustDeps, path: string, kind: "file" | "directory"): string | null { + const s = deps.stat(path); + if (!s) return "is missing"; + if (s.isSymbolicLink) return "is a symbolic link"; + if (kind === "file" ? !s.isFile : !s.isDirectory) return `is not a ${kind}`; + if (s.uid !== 0 && s.uid !== deps.uid) return "is owned by another user"; + if ((s.mode & 0o022) !== 0) return "is writable by group or others"; + return null; +} + +/** Protect the bundle's directory entry all the way to the filesystem root. */ +function untrustedAncestorReason(bundleRoot: string, deps: FilesystemTrustDeps): string | null { + for (let dir = dirname(bundleRoot); ; dir = dirname(dir)) { + const s = deps.stat(dir); + if (!s || !s.isDirectory || s.isSymbolicLink) return `${dir} is not a regular ancestor directory`; + if (s.uid !== 0 && s.uid !== deps.uid) return `${dir} is owned by another user`; + // A trusted sticky parent protects the already-checked child from replacement. + // Root-owned admin-group install containers are privileged OS-controlled paths; + // resolve that group from the local directory service rather than assuming a GID. + const sticky = (s.mode & 0o1000) !== 0; + const privileged = s.uid === 0 && (s.mode & 0o002) === 0 + && deps.adminGroupId !== undefined && s.gid === deps.adminGroupId; + if ((s.mode & 0o022) !== 0 && !sticky && !privileged) return `${dir} is writable by group or others`; + if (dirname(dir) === dir) return null; + } +} + +/** Shared filesystem policy; the callers separately verify both code signatures. */ +export function untrustedBundleFilesystemReason( + bundleRoot: string, + binary: string | undefined = undefined, + deps: FilesystemTrustDeps, +): string | null { + const label = binary === undefined ? "app executable" : "app-server binary"; + binary ??= join(bundleRoot, "Contents", "MacOS", "ChatGPT"); + const inside = relative(join(bundleRoot, "Contents"), binary); + if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return `the ${label} is outside the bundle`; + const fileProblem = ownedSafely(deps, binary, "file"); + if (fileProblem) return `the ${label} ${fileProblem}`; + for (let dir = dirname(binary); ; dir = dirname(dir)) { + const dirProblem = ownedSafely(deps, dir, "directory"); + if (dirProblem) return `${dir} ${dirProblem}`; + if (dir === bundleRoot || dirname(dir) === dir) break; + } + const ancestorProblem = untrustedAncestorReason(bundleRoot, deps); + if (ancestorProblem) return ancestorProblem; + return null; +} + +export function bundleSignatureDetailsReason(target: string, details: { status: number | null; output: string }): string | null { + return details.status !== 0 || !new RegExp(`^TeamIdentifier=${OPENAI_TEAM_ID}$`, "m").test(details.output) + ? `${target} is not signed by OpenAI (team ${OPENAI_TEAM_ID})` : null; +} diff --git a/src/chatgpt/app-server-shim/bundle-trust.ts b/src/chatgpt/app-server-shim/bundle-trust.ts index ee8d9fd17ad..bee6d0196a5 100644 --- a/src/chatgpt/app-server-shim/bundle-trust.ts +++ b/src/chatgpt/app-server-shim/bundle-trust.ts @@ -1,23 +1,15 @@ import { spawnSync } from "node:child_process"; -import { lstatSync } from "node:fs"; -import { dirname, join, relative, sep } from "node:path"; +import { join } from "node:path"; +import { bundleSignatureDetailsReason, bundleTrustStat, untrustedBundleFilesystemReason, type BundleTrustDeps } from "./bundle-trust-policy"; -/** - * Launch and restore execute code from a discovered bundle, so the bundle must be - * OpenAI-signed and pass ownership/permission checks along its filesystem path. Bundle - * identifier alone (what discovery checks) is plain text in Info.plist that anyone can copy. - */ -export const OPENAI_TEAM_ID = "2DC432GLL2"; +export { OPENAI_TEAM_ID, bundleSignatureDetailsReason, bundleTrustStat, untrustedBundleFilesystemReason } from "./bundle-trust-policy"; +export type { BundleTrustDeps } from "./bundle-trust-policy"; const CODESIGN = "/usr/bin/codesign"; const CODESIGN_TIMEOUT_MS = 30_000; -export interface BundleTrustDeps { - readonly uid: number; - readonly adminGroupId?: number; - stat(path: string): { uid: number; gid?: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null; - codesign(args: readonly string[]): { status: number | null; output: string }; -} +export { defaultAsyncBundleTrustDeps, untrustedChatgptBundleReasonAsync } from "./bundle-trust-async"; +export type { AsyncBundleTrustDeps, TrustCommandSpawn } from "./bundle-trust-async"; export function defaultBundleTrustDeps(): BundleTrustDeps { const admin = spawnSync("/usr/bin/dscl", [".", "-read", "/Groups/admin", "PrimaryGroupID"], { @@ -27,14 +19,7 @@ export function defaultBundleTrustDeps(): BundleTrustDeps { return { adminGroupId: adminId === undefined ? undefined : Number(adminId), uid: typeof process.getuid === "function" ? process.getuid() : -1, - stat(path) { - try { - const s = lstatSync(path); - return { uid: s.uid, gid: s.gid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() }; - } catch { - return null; - } - }, + stat: bundleTrustStat, codesign(args) { const result = spawnSync(CODESIGN, [...args], { encoding: "utf8", timeout: CODESIGN_TIMEOUT_MS }); return { status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}` }; @@ -42,66 +27,24 @@ export function defaultBundleTrustDeps(): BundleTrustDeps { }; } -function ownedSafely(deps: BundleTrustDeps, path: string, kind: "file" | "directory"): string | null { - const s = deps.stat(path); - if (!s) return "is missing"; - if (s.isSymbolicLink) return "is a symbolic link"; - if (kind === "file" ? !s.isFile : !s.isDirectory) return `is not a ${kind}`; - if (s.uid !== 0 && s.uid !== deps.uid) return "is owned by another user"; - if ((s.mode & 0o022) !== 0) return "is writable by group or others"; - return null; -} - -/** Protect the bundle's directory entry all the way to the filesystem root. */ -function untrustedAncestorReason(bundleRoot: string, deps: BundleTrustDeps): string | null { - for (let dir = dirname(bundleRoot); ; dir = dirname(dir)) { - const s = deps.stat(dir); - if (!s || !s.isDirectory || s.isSymbolicLink) return `${dir} is not a regular ancestor directory`; - if (s.uid !== 0 && s.uid !== deps.uid) return `${dir} is owned by another user`; - // A trusted sticky parent protects the already-checked child from replacement. - // Root-owned admin-group install containers are privileged OS-controlled paths; - // resolve that group from the local directory service rather than assuming a GID. - const sticky = (s.mode & 0o1000) !== 0; - const privileged = s.uid === 0 && (s.mode & 0o002) === 0 - && deps.adminGroupId !== undefined && s.gid === deps.adminGroupId; - if ((s.mode & 0o022) !== 0 && !sticky && !privileged) return `${dir} is writable by group or others`; - if (dirname(dir) === dir) return null; - } -} - /** - * Null when the bundle root and the selected executable may be run by this user. - * Restore omits the app-server binary and validates the app shell instead, so a missing - * app-server does not prevent returning to a normal launch. Otherwise returns a short reason. - * Every directory from the binary up to the bundle root, and the - * binary itself, must be owned by root or this user and not group/other-writable, and both the - * bundle and the binary must pass strict code-signature verification under OpenAI's team ID. - * Ancestors above the bundle are also checked for POSIX directory-entry replacement rights. + * Null when the bundle root and selected executable pass filesystem and OpenAI signature checks. + * Restore omits the app-server binary and validates the app shell instead. Directories and the + * binary must be owned by root or this user, non-symlink, and not group/other-writable; + * ancestors also pass POSIX directory-entry replacement checks through the filesystem root. */ export function untrustedChatgptBundleReason( bundleRoot: string, binary: string | undefined = undefined, deps: BundleTrustDeps = defaultBundleTrustDeps(), ): string | null { - const label = binary === undefined ? "app executable" : "app-server binary"; + const filesystemProblem = untrustedBundleFilesystemReason(bundleRoot, binary, deps); + if (filesystemProblem) return filesystemProblem; binary ??= join(bundleRoot, "Contents", "MacOS", "ChatGPT"); - const inside = relative(bundleRoot, binary); - if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return `the ${label} is outside the bundle`; - const fileProblem = ownedSafely(deps, binary, "file"); - if (fileProblem) return `the ${label} ${fileProblem}`; - for (let dir = dirname(binary); ; dir = dirname(dir)) { - const dirProblem = ownedSafely(deps, dir, "directory"); - if (dirProblem) return `${dir} ${dirProblem}`; - if (dir === bundleRoot || dirname(dir) === dir) break; - } - const ancestorProblem = untrustedAncestorReason(bundleRoot, deps); - if (ancestorProblem) return ancestorProblem; for (const target of [bundleRoot, binary]) { if (deps.codesign(["--verify", "--strict", target]).status !== 0) return `${target} failed code-signature verification`; - const details = deps.codesign(["-dv", "--verbose=2", target]); - if (details.status !== 0 || !new RegExp(`^TeamIdentifier=${OPENAI_TEAM_ID}$`, "m").test(details.output)) { - return `${target} is not signed by OpenAI (team ${OPENAI_TEAM_ID})`; - } + const detailsProblem = bundleSignatureDetailsReason(target, deps.codesign(["-dv", "--verbose=2", target])); + if (detailsProblem) return detailsProblem; } return null; } diff --git a/src/cli/account-main.ts b/src/cli/account-main.ts index 7ab8f83fb44..08978824f10 100644 --- a/src/cli/account-main.ts +++ b/src/cli/account-main.ts @@ -1,6 +1,7 @@ import { isAbsolute } from "node:path"; import { redactSecretArgs } from "./secret-args"; import { codexExecInvocation } from "../codex/exec-invocation"; +import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; import type { ResolveDeps, SpawnInvocation } from "../lib/win-exec"; import { @@ -46,6 +47,41 @@ export type NativeMainCodexLoginInvocationDeps = & ResolveDeps & Pick; +export type NativeMainCodexLoginValidatedDeps = + & NativeMainCodexLoginInvocationDeps + & CodexExecBoundaryAsyncDeps; + +/** + * Resolve+persist the runtime selection, then let the exec boundary produce the + * invocation that is spawned. Validating the raw command matters: re-wrapping + * the already-prepared invocation would launch cmd.exe as if it were Codex and + * drop windowsVerbatimArguments. + */ +export async function nativeMainCodexLoginValidatedInvocation( + platform: NodeJS.Platform = process.platform, + deps: NativeMainCodexLoginValidatedDeps = {}, +): Promise { + const env = deps.env ?? process.env; + const command = resolveAndPersistCodexRuntime({ + env, + platform, + existsSync: deps.existsSync, + execFileSync: deps.execFileSync, + configDir: deps.configDir, + readFileSync: deps.readFileSync, + discoverAlternatives: false, + }).runtime.command || "codex"; + return validatedCodexExecInvocationAsync(command, ["login"], { + env, + platform, + exists: deps.exists, + executable: deps.executable, + realpath: deps.realpath, + macBundleTrustReasonAsync: deps.macBundleTrustReasonAsync, + signal: deps.signal, + }); +} + function flag(args: string[], name: string): boolean { const index = args.indexOf(name); if (index < 0) return false; @@ -92,14 +128,16 @@ export function nativeMainCodexLoginInvocation( return codexExecInvocation(command, ["login"], platform, deps); } -function spawnOfficialCodexLogin(codexHome: string): NativeMainLoginChild { - const invocation = nativeMainCodexLoginInvocation(); - const child = Bun.spawn([invocation.file, ...invocation.args], { +async function spawnOfficialCodexLogin(codexHome: string): Promise { + // The boundary validates the raw resolved command and returns the invocation + // to spawn; the prepared-invocation builder above stays trust-free on purpose. + const validated = await nativeMainCodexLoginValidatedInvocation(); + const child = Bun.spawn([validated.file, ...validated.args], { env: { ...process.env, CODEX_HOME: codexHome }, stdin: "inherit", stdout: "inherit", stderr: "inherit", - ...(invocation.options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + ...(validated.options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), }); return child; } @@ -323,7 +361,7 @@ export async function cmdNativeMainAccount(args: string[], deps: AccountDeps): P ? deps.spawnCodexLoginImpl(stagingHome) : deps.runCodexLoginImpl ? { exited: deps.runCodexLoginImpl(stagingHome), kill: () => {} } - : spawnOfficialCodexLogin(stagingHome); + : await spawnOfficialCodexLogin(stagingHome); const heartbeatAbort = new AbortController(); const heartbeat = maintainStageLease( deps, diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 09e8927915e..116cc1be0dc 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -17,7 +17,8 @@ import { localV2State, v2LocalSyncResult, type V2LocalSync } from "./v2-local-ou import { execFileSync } from "node:child_process"; import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features"; -import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; +import type { ResolveDeps, SpawnInvocation } from "../lib/win-exec"; +import { validatedCodexExecInvocation } from "../codex/exec-boundary"; import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; @@ -33,8 +34,9 @@ export interface V2CliDeps { } export type CodexFeaturesInvocationDeps = - & Parameters[3] - & Pick + & ResolveDeps + & Pick & { requireAvailable?: boolean }; /** @@ -58,12 +60,23 @@ export function codexFeaturesInvocation( execFileSync: deps.execFileSync, configDir: deps.configDir, readFileSync: deps.readFileSync, + readBundleIdentifier: deps.readBundleIdentifier, + macBundleTrustReason: deps.macBundleTrustReason, + executable: deps.executable, + realpath: deps.realpath, }); if (deps.requireAvailable && resolved.runtime.source === "fallback" && resolved.runtime.version === null) { throw Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT" }); } const command = resolved.runtime.command || "codex"; - return commandInvocation(command, ["features", action, feature], platform, deps); + return validatedCodexExecInvocation(command, ["features", action, feature], { + env: deps.env, + platform, + exists: deps.exists, + executable: deps.executable, + realpath: deps.realpath, + macBundleTrustReason: deps.macBundleTrustReason, + }); } /** diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index b6d859c7e3e..f2b896bfa7a 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -35,8 +35,9 @@ import { decodeOverlayState } from "../shim-state-file"; import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; import type { RawCatalog, RawEntry } from "./parsing"; +import { isSpawnableCodexCandidate } from "../exec-invocation"; +import { validatedCodexExecInvocation, validatedCodexExecInvocationAsync } from "../exec-boundary"; import { isPlainObject } from "../../lib/plain-data"; -import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation"; import { parsePersistedCodexRuntime, peekCodexRuntimeProcessCache, @@ -194,6 +195,14 @@ export interface BundledCatalogDeps { platform?: NodeJS.Platform; existsSync?: (path: string) => boolean; readFileSync?: (path: string, encoding: "utf8") => string; + /** Exec-boundary bundle trust for sync drivers (resolve + debug exec). */ + macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; + /** Exec-boundary bundle trust for async drivers; injected checks bypass the runtime memo. */ + macBundleTrustReasonAsync?: (bundleRoot: string, binary: string, signal?: AbortSignal) => Promise; + /** Exec-boundary spawnability predicate (regular file + X_OK). */ + executable?: (path: string) => boolean; + /** Exec-boundary canonicalizer; returns null when the path cannot be resolved. */ + realpath?: (path: string) => string | null; now?: () => number; discoverAlternatives?: boolean; } @@ -247,12 +256,43 @@ export function codexShimCommandCandidates(): string[] { export function runCodexDebugModels( command: string, execFile: (...args: Parameters) => T, - deps: Pick = {}, + deps: Pick = {}, ): T { const args = ["debug", "models", "--bundled"]; - const invocation = codexExecInvocation(command, args, deps.platform ?? process.platform, { + const invocation = validatedCodexExecInvocation(command, args, { env: deps.env, + platform: deps.platform, exists: deps.existsSync, + executable: deps.executable, + realpath: deps.realpath, + macBundleTrustReason: deps.macBundleTrustReason, + }); + return execFile(invocation.file, invocation.args, { + encoding: "utf8" as const, + stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"], + timeout: 10_000, + maxBuffer: 64 * 1024 * 1024, + windowsHide: true, + ...invocation.options, + }); +} + +/** Async twin of runCodexDebugModels: same args/options, bundle trust checked off the event loop. */ +export async function runCodexDebugModelsAsync( + command: string, + execFile: (...args: Parameters) => Promise, + deps: Pick = {}, + signal?: AbortSignal, +): Promise { + const args = ["debug", "models", "--bundled"]; + const invocation = await validatedCodexExecInvocationAsync(command, args, { + env: deps.env, + platform: deps.platform, + exists: deps.existsSync, + executable: deps.executable, + realpath: deps.realpath, + macBundleTrustReasonAsync: deps.macBundleTrustReasonAsync, + signal, }); return execFile(invocation.file, invocation.args, { encoding: "utf8" as const, @@ -276,6 +316,12 @@ function bundledRuntimeDeps(deps: BundledCatalogDeps) { platform: deps.platform, existsSync: deps.existsSync, readFileSync: deps.readFileSync, + // Exec-boundary trust deps forwarded so the runtime's own --version probes + // use the same injected checks as the catalog exec below. + ...(deps.macBundleTrustReason ? { macBundleTrustReason: deps.macBundleTrustReason } : {}), + ...(deps.macBundleTrustReasonAsync ? { macBundleTrustReasonAsync: deps.macBundleTrustReasonAsync } : {}), + ...(deps.executable ? { executable: deps.executable } : {}), + ...(deps.realpath ? { realpath: deps.realpath } : {}), now: deps.now, // Catalog loading only consumes `resolved.runtime.command`, never `newerAvailable`. // Full PATH discovery probes every candidate launcher (100+ on a dev machine, ~1.2s), @@ -407,8 +453,9 @@ export function loadBundledCodexCatalogAsync( let step = steps.next(); while (!step.done) { let output: string | null = null; - try { output = await runCodexDebugModels(step.value, - (file, args, options) => (deps.execFile ?? execCodexFileAsync)(file, args, { ...options, signal: controller.signal }), deps); } + try { output = await runCodexDebugModelsAsync(step.value, + (file, args, options) => (deps.execFile ?? execCodexFileAsync)(file, args, { ...options, signal: controller.signal }), + deps, controller.signal); } catch { /* preserve last confirmed evidence on failure */ } step = steps.next(output); } diff --git a/src/codex/exec-boundary.ts b/src/codex/exec-boundary.ts new file mode 100644 index 00000000000..26f869fdf27 --- /dev/null +++ b/src/codex/exec-boundary.ts @@ -0,0 +1,208 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { posix } from "node:path"; +import { codexExecInvocation } from "./exec-invocation"; +import type { SpawnInvocation } from "../lib/win-exec"; +import { macCodexAppBundleFor, realpathOrNull } from "./mac-app-bundle"; +import { + untrustedChatgptBundleReason, + untrustedChatgptBundleReasonAsync, +} from "../chatgpt/app-server-shim/bundle-trust"; + +/** + * macOS execution-boundary trust for a resolved Codex target. + * + * Discovery and the resolver memo answer "which command would we run"; they are + * deliberately exec-free and never prove trust. Every actual spawn/exec of a + * Codex binary goes through this module instead: on darwin the command is + * resolved with real execvp semantics (empty PATH segment = spawn cwd, + * relative PATH entries resolve against that cwd, a slash in the command makes + * it cwd/absolute-relative and PATH is skipped), classified by the shared + * known-Codex-bundle classifier, and — when the lexical or canonical path sits + * inside a ChatGPT.app or Codex.app bundle at any location — checked by the shared + * signature/ownership policy before the same canonical target is returned for + * execution. Anything else, including binaries inside third-party app bundles, + * is passed through to the shared launcher unchanged. + * + * The check is one-shot per invocation and deliberately not memoized: + * directory-service group membership, signature state, and directory-entry + * ownership can change between resolve and exec, so a cached "trusted" answer + * would invent a trust lifetime the platform does not provide. + */ +export class CodexExecTrustError extends Error { + readonly reason: string; + readonly code = "CODEX_EXEC_UNTRUSTED"; + constructor(reason: string) { + super("untrusted Codex exec target: " + reason); + this.name = "CodexExecTrustError"; + this.reason = reason; + } +} + +/** Default execvp search path on darwin when the spawn env carries no PATH (_CS_PATH). */ +const DARWIN_DEFAULT_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"; + +export interface CodexExecBoundaryDeps { + /** Spawn environment; PATH resolution uses exactly this list. */ + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Working directory the spawn will use; empty/relative PATH segments resolve against it. */ + cwd?: string; + /** Existence probe: win32 passthrough PATH resolution and POSIX EACCES classification. */ + exists?: (path: string) => boolean; + /** POSIX spawnability predicate: regular file + X_OK. */ + executable?: (path: string) => boolean; + /** Canonicalizer; returns null when the path cannot be resolved. */ + realpath?: (path: string) => string | null; + macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; +} + +export interface CodexExecBoundaryAsyncDeps extends Omit { + signal?: AbortSignal; + /** + * Async twin of the sync trust check. It MUST honor the forwarded signal: + * once aborted it settles promptly, and any children it owns (dscl/codesign) + * are killed and reaped before it settles - the boundary awaits it, so a + * check that ignores the signal stalls the caller; one that abandons its + * children leaks processes. + */ + macBundleTrustReasonAsync?: (bundleRoot: string, binary: string, signal?: AbortSignal) => Promise; +} + +/** Same contract as workspace-executable's candidate check: regular file and executable bit. */ +function executableTarget(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function spawnError(command: string, code: "ENOENT" | "EACCES"): Error { + return Object.assign(new Error("spawn " + command + " " + code), { code }); +} + +/** Non-executability alone must not masquerade as "not found"; existence is probed separately. */ +function existsTarget(path: string, deps: CodexExecBoundaryDeps): boolean { + if (deps.exists) { try { return deps.exists(path); } catch { return false; } } + try { statSync(path); return true; } catch { return false; } +} + +/** execvp-style PATH resolution: first executable regular file wins; empty segment means cwd. */ +function resolveDarwinExecPath(command: string, deps: CodexExecBoundaryDeps): { path: string } | { error: Error } { + const env = deps.env ?? process.env; + const cwd = deps.cwd ?? process.cwd(); + const executable = deps.executable ?? executableTarget; + const dirOf = (segment: string): string => + segment === "" ? cwd : segment.startsWith("/") ? segment : posix.resolve(cwd, segment); + if (command.includes("/")) { + const candidate = dirOf(command); + if (executable(candidate)) return { path: candidate }; + return { error: spawnError(command, existsTarget(candidate, deps) ? "EACCES" : "ENOENT") }; + } + const pathValue = env.PATH === undefined ? DARWIN_DEFAULT_PATH : env.PATH; + let denied = false; + for (const segment of pathValue.split(":")) { + const candidate = dirOf(segment) + "/" + command; + if (executable(candidate)) return { path: candidate }; + if (existsTarget(candidate, deps)) denied = true; + } + return { error: spawnError(command, denied ? "EACCES" : "ENOENT") }; +} + +/** + * Canonical (bundleRoot, binary) pair when the resolved path classifies into a + * known Codex desktop bundle, else null. The pair is always consistent: + * both halves are canonicalized together so a symlinked HOME or a PATH alias + * cannot split the containment check, and a bundle member that cannot be + * canonicalized refuses rather than escaping the known-bundle classification. + */ +function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bundle: string; binary: string } | null { + const realpath = deps.realpath ?? realpathOrNull; + const bundle = macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); + if (!bundle) return null; + const binary = realpath(resolved); + if (binary === null) throw new CodexExecTrustError("cannot canonicalize the app-bundle exec target"); + const canonicalBundle = realpath(bundle); + if (canonicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); + if (!binary.startsWith(canonicalBundle + "/Contents/")) { + throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); + } + return { bundle: canonicalBundle, binary }; +} + +/** Classification of this exact validated invocation, not a reusable trust grant. */ +interface ValidatedCodexExecTarget { + readonly invocation: SpawnInvocation; + readonly macAppBundle: boolean; +} + +export function validatedCodexExecTarget( + command: string, + args: readonly string[], + deps: CodexExecBoundaryDeps = {}, +): ValidatedCodexExecTarget { + if ((deps.platform ?? process.platform) !== "darwin") { + return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; + } + const resolved = resolveDarwinExecPath(command, deps); + if ("error" in resolved) throw resolved.error; + const target = darwinBundleTarget(resolved.path, deps); + if (target) { + const reason = (deps.macBundleTrustReason ?? untrustedChatgptBundleReason)(target.bundle, target.binary); + if (reason) throw new CodexExecTrustError(reason); + return { invocation: { file: target.binary, args: [...args], options: {} }, macAppBundle: true }; + } + return { invocation: { file: resolved.path, args: [...args], options: {} }, macAppBundle: false }; +} + +export async function validatedCodexExecTargetAsync( + command: string, + args: readonly string[], + deps: CodexExecBoundaryAsyncDeps = {}, +): Promise { + deps.signal?.throwIfAborted(); + if ((deps.platform ?? process.platform) !== "darwin") { + return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; + } + const resolved = resolveDarwinExecPath(command, deps); + if ("error" in resolved) throw resolved.error; + const target = darwinBundleTarget(resolved.path, deps); + if (target) { + const reason = await (deps.macBundleTrustReasonAsync + ? deps.macBundleTrustReasonAsync(target.bundle, target.binary, deps.signal) + : untrustedChatgptBundleReasonAsync(target.bundle, target.binary, undefined, deps.signal)); + if (reason) throw new CodexExecTrustError(reason); + deps.signal?.throwIfAborted(); + return { invocation: { file: target.binary, args: [...args], options: {} }, macAppBundle: true }; + } + deps.signal?.throwIfAborted(); + return { invocation: { file: resolved.path, args: [...args], options: {} }, macAppBundle: false }; +} + +export function validatedCodexExecInvocation( + command: string, args: readonly string[], deps: CodexExecBoundaryDeps = {}, +): SpawnInvocation { + return validatedCodexExecTarget(command, args, deps).invocation; +} + +export async function validatedCodexExecInvocationAsync( + command: string, args: readonly string[], deps: CodexExecBoundaryAsyncDeps = {}, +): Promise { + const target = await validatedCodexExecTargetAsync(command, args, deps); + deps.signal?.throwIfAborted(); + return target.invocation; +} + +/** Assert a single already-located executable; returns the canonical target to exec. */ +export function assertCodexExecTargetTrusted(command: string, deps: CodexExecBoundaryDeps = {}): string { + return validatedCodexExecInvocation(command, [], deps).file; +} + +export async function assertCodexExecTargetTrustedAsync( + command: string, + deps: CodexExecBoundaryAsyncDeps = {}, +): Promise { + return (await validatedCodexExecInvocationAsync(command, [], deps)).file; +} diff --git a/src/codex/mac-app-bundle.ts b/src/codex/mac-app-bundle.ts new file mode 100644 index 00000000000..c20748c9473 --- /dev/null +++ b/src/codex/mac-app-bundle.ts @@ -0,0 +1,52 @@ +import { realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix } from "node:path"; + +/** + * Codex desktop-app locations shared by runtime discovery and the exec + * boundary. Kept in one small module so runtime.ts (selection) and + * exec-boundary.ts (trust gate) classify the same canonical paths without a + * module cycle. + */ +export interface MacCodexAppDeps { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Canonicalizer; the exec boundary injects its own so both classify identically. */ + realpath?: (path: string) => string | null; +} + +/** Conventional Codex desktop bundles, ordered system before user. */ +export function macCodexAppBundles(deps: MacCodexAppDeps): string[] { + const env = deps.env ?? process.env; + const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir(); + return ["/Applications/ChatGPT.app", posix.join(home, "Applications/ChatGPT.app")]; +} + +export function macCodexAppCommands(deps: MacCodexAppDeps): string[] { + return macCodexAppBundles(deps).flatMap(bundle => [ + bundle + "/Contents/Resources/codex-cli/bin/codex", bundle + "/Contents/Resources/codex", + ]); +} + +export function realpathOrNull(path: string): string | null { + try { return realpathSync(path); } catch { return null; } +} + +/** + * A known app name only routes to verification; it never grants trust. Inspect both the + * lexical and canonical ancestors so relocated bundles and CLI symlinks cannot skip the + * gate. Keep conventional-root aliases even when their canonical root has another name. + */ +export function macCodexAppBundleFor(command: string, deps: MacCodexAppDeps): string | undefined { + if ((deps.platform ?? process.platform) !== "darwin" || !posix.isAbsolute(command)) return undefined; + const realpath = deps.realpath ?? realpathOrNull; + const forms = [posix.normalize(command), realpath(command)].filter((form): form is string => Boolean(form)); + for (const form of forms) { + const match = /^(.*?\/(?:ChatGPT|Codex)\.app)(?:\/|$)/i.exec(form); + if (match) return match[1]; + } + return macCodexAppBundles(deps).find(bundle => { + const roots = [posix.normalize(bundle), realpath(bundle)].filter((root): root is string => Boolean(root)); + return forms.some(form => roots.some(root => form.startsWith(root + "/"))); + }); +} diff --git a/src/codex/native-main-refresh-mode.ts b/src/codex/native-main-refresh-mode.ts index a22a8639b44..9a43f0f5a0d 100644 --- a/src/codex/native-main-refresh-mode.ts +++ b/src/codex/native-main-refresh-mode.ts @@ -10,7 +10,11 @@ * The default is "codex" only when a version-checked binary is available. Without one, or * when the operator sets `nativeMainRefresh: "opencodex"`, the guarded writer stays. */ -import { codexExecInvocation } from "./exec-invocation"; +import { + CodexExecTrustError, + validatedCodexExecInvocationAsync, + type CodexExecBoundaryAsyncDeps, +} from "./exec-boundary"; import { resolveCodexHomeDir } from "./home"; import { compareCodexVersions, displayCodexRuntimePath, resolveCodexRuntimeAsync } from "./runtime"; import type { ResolveCodexRuntimeResult } from "./runtime"; @@ -97,10 +101,21 @@ export async function resolveNativeMainRefreshMode( */ export async function requestCodexAuthStatus( command: string, - options: { refresh: boolean; signal: AbortSignal; codexHome?: string }, + options: { refresh: boolean; signal: AbortSignal; codexHome?: string; execDeps?: CodexExecBoundaryAsyncDeps }, ): Promise { if (options.signal.aborted) throw options.signal.reason; - const invocation = codexExecInvocation(command, ["-c", 'model_provider="openai"', "app-server"]); + let invocation; + try { + invocation = await validatedCodexExecInvocationAsync( + command, ["-c", 'model_provider="openai"', "app-server"], + { env: process.env, signal: options.signal, ...(options.execDeps ?? {}) }); + } catch (cause) { + if (options.signal.aborted) throw options.signal.reason; + if (cause instanceof CodexExecTrustError) { + throw new CodexAuthStatusError("codex app-server target failed trust verification", { cause }); + } + throw new CodexAuthStatusError("could not start codex app-server", { cause }); + } let proc: ReturnType>; try { proc = Bun.spawn([invocation.file, ...invocation.args], { diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 8acb47a391f..0bf280366f9 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -23,8 +23,14 @@ import { spawn } from "node:child_process"; import { closeSync, constants, existsSync, fstatSync, openSync, readSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { expandUserPath } from "../config"; +import { clearableDeadline, type ClearableDeadline } from "../lib/abort"; import { parseCatalogJson, readCodexCatalogPathForHome, type RawCatalog, type RawEntry } from "./catalog/parsing"; -import { codexExecInvocation } from "./exec-invocation"; +import { + CodexExecTrustError, + validatedCodexExecInvocationAsync, + type CodexExecBoundaryAsyncDeps, +} from "./exec-boundary"; +import type { SpawnInvocation } from "../lib/win-exec"; import { resolveCodexHomeDir } from "./home"; import { CODEX_PROGRAM_NOT_FOUND_REASON, @@ -430,11 +436,21 @@ type SharedPromptProbeOutcome = | { kind: "failed"; failure?: PromptProbeFailure } | { kind: "busy" }; +/** + * The fixed phrase that distinguishes a deadline expiry from every other + * termination. The shared flight's timer and a caller's own deadline both + * report it, so a waiter can tell "the budget ran out" from "the probe was + * stopped" without reading process output - which this response never carries. + */ +const PROMPT_PROBE_TIMEOUT_PHRASE = "codex prompt probe timed out"; + let activePromptProbe: PromptProbeFlight | null = null; let probeCommandForTests: { binary: string; args: string[] } | null = null; let probeRuntimeForTests: { command: string; source: CodexRuntimeSource } | null | undefined; let probeSpawnAttemptsForTests = 0; let probeCloseBarrierForTests: Promise | null = null; +let probeExecBoundaryDepsForTests: CodexExecBoundaryAsyncDeps | null = null; +let probeMetadataBarrierForTests: Promise | null = null; function commandKey(command: ProbeCommand): string { return JSON.stringify([ @@ -513,9 +529,10 @@ function classifyRuntimeFailure(result: ResolveCodexRuntimeResult): PromptProbeF function runProbe( command: ProbeCommand, - signal: AbortSignal, + controller: AbortController, onStopping: () => void, ): PromptProbeExecution { + const signal = controller.signal; if (signal.aborted) return completedExecution({ raw: null, failure: null }); let resolveResult!: (value: PromptProbeExecutionResult | null) => void; let resolveClosed!: () => void; @@ -535,17 +552,98 @@ function runProbe( resolveClosed(); }; - try { + // One deadline covers trust verification AND the probe child. The timer + // aborts the flight controller rather than terminating directly, so the + // signal reaches an in-flight trust check (which must stop and reap its own + // helper) exactly like it reaches the probe child. The slot is never + // released while a verification promise is still unsettled. + let child: ReturnType | undefined; + let settled = false; + let stopping = false; + let stoppingFailure: PromptProbeFailure | null = null; + let timer: ReturnType | undefined; + + const finish = (value: PromptProbeExecutionResult) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + finishResult(value); + finishClosed(); + }; + + // Keep the flight admitted until `close`: kill() only requests termination + // and does not prove the exact child has released its process and stdio. + const terminate = ( + failure = probeFailure( + command, + "execution-failed", + // The flight timer aborts with a TimeoutError; every other abort reason + // is a stop, not an expired budget. Callers match on the fixed phrase to + // report "timed out" instead of a generic termination. + signal.reason instanceof DOMException && signal.reason.name === "TimeoutError" + ? `${PROMPT_PROBE_TIMEOUT_PHRASE}: ${commandDescription(command)}` + : `codex probe was terminated: ${commandDescription(command)}`, + ), + ) => { + if (settled || stopping) return; + stopping = true; + stoppingFailure = failure; + onStopping(); + if (timer) clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + child?.stdout?.destroy(); + // The caller is bounded even if OS termination later fails. Admission is + // retained separately by `closed`, and later probes fail soft while this + // exact execution remains unproven terminal. + finishResult({ raw: null, failure }); + if (!child || child.exitCode !== null || child.signalCode !== null) return; + try { + child.kill("SIGKILL"); + } catch { + // Exact child state is ambiguous. Keep admission non-joinable until its + // own `close` proves terminal instead of targeting a reusable numeric PID. + } + }; + const onAbort = () => terminate(); + + const start = async () => { + // The flight owns trust verification too: it runs under the shared + // deadline and the controller's abort, and a rejected target becomes this + // flight's shared failure instead of spawning beside the single slot. + let invocation: SpawnInvocation; + try { + invocation = await validatedCodexExecInvocationAsync(command.binary, command.args, { + env: process.env, + cwd: command.cwd, + signal, + ...(probeExecBoundaryDepsForTests ?? {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + finish({ + raw: null, + failure: probeFailure( + command, + error instanceof CodexExecTrustError + ? "execution-failed" + : /ENOENT|not found/i.test(message) ? "program-not-found" : "execution-failed", + `codex probe could not start: ${commandDescription(command)}`, + ), + }); + return; + } + if (stopping || settled) { + // Terminated while verification was in flight; its await has now settled + // and no probe child exists, so the slot can be released. + finishClosed(); + return; + } // A probe must never hang OR balloon the management API: it is bounded in - // time AND in bytes, and every failure degrades to "unavailable" rather than - // an error page. - let child: ReturnType; + // time AND in bytes, and every failure degrades to "unavailable" rather + // than an error page. try { if (probeCommandForTests) probeSpawnAttemptsForTests += 1; - // Route through the shared invocation helper: on Windows a resolved - // `codex.cmd` cannot be spawned directly and must go through cmd.exe, - // which `commandInvocation` does with correct metacharacter escaping. - const invocation = codexExecInvocation(command.binary, command.args, process.platform); child = spawn(invocation.file, invocation.args, { cwd: command.cwd, stdio: ["ignore", "pipe", "pipe"], @@ -554,7 +652,7 @@ function runProbe( }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - finishResult({ + finish({ raw: null, failure: probeFailure( command, @@ -562,71 +660,26 @@ function runProbe( `codex probe could not start: ${commandDescription(command)}`, ), }); - finishClosed(); - return { result, closed }; + return; } + const proc = child; const chunks: Buffer[] = []; const errorChunks: Buffer[] = []; let size = 0; let errorSize = 0; - let settled = false; - let stopping = false; - let stoppingFailure: PromptProbeFailure | null = null; - let timer: ReturnType | undefined; - const finish = (value: PromptProbeExecutionResult) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - finishResult(value); - finishClosed(); - }; - - // Keep the flight admitted until `close`: kill() only requests termination - // and does not prove the exact child has released its process and stdio. - const terminate = ( - failure = probeFailure( - command, - "execution-failed", - `codex probe was terminated: ${commandDescription(command)}`, - ), - ) => { - if (settled || stopping) return; - stopping = true; - stoppingFailure = failure; - onStopping(); - if (timer) clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - child.stdout?.destroy(); - // The caller is bounded even if OS termination later fails. Admission is - // retained separately by `closed`, and later probes fail soft while this - // exact child remains unproven terminal. - finishResult({ raw: null, failure }); - if (child.exitCode !== null || child.signalCode !== null) return; - try { - child.kill("SIGKILL"); - } catch { - // Exact child state is ambiguous. Keep admission non-joinable until its - // own `close` proves terminal instead of targeting a reusable numeric PID. - } - }; - const onAbort = () => terminate(); - - timer = setTimeout(terminate, command.timeoutMs); - signal.addEventListener("abort", onAbort, { once: true }); - child.stdout?.on("data", (chunk: Buffer) => { + proc.stdout?.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_PROBE_OUTPUT_BYTES) { terminate(); return; } chunks.push(chunk); }); - child.stderr?.on("data", (chunk: Buffer) => { + proc.stderr?.on("data", (chunk: Buffer) => { errorSize += chunk.length; if (errorSize <= MAX_PROBE_STDERR_BYTES) errorChunks.push(chunk); }); - child.on("error", error => { + proc.on("error", error => { // No PID means spawn itself failed, so there is no live child to drain. - if (child.pid === undefined) { + if (proc.pid === undefined) { const message = error instanceof Error ? error.message : String(error); finish({ raw: null, @@ -643,7 +696,7 @@ function runProbe( `codex probe process error: ${commandDescription(command)}`, )); }); - child.on("close", code => { + proc.on("close", code => { // Decode once, at the end: `String(chunk)` per chunk corrupts any UTF-8 // character that straddles a chunk boundary. const recordClose = () => { @@ -671,8 +724,17 @@ function runProbe( }); // Close the race between the pre-spawn check and listener registration. if (signal.aborted) terminate(); - } catch { - finishResult({ + }; + + timer = setTimeout(() => { + // Abort rather than terminate directly so the signal also reaches a trust + // check that is still running: it must stop and reap its helper before the + // flight releases the slot. + controller.abort(new DOMException("prompt probe timed out", "TimeoutError")); + }, command.timeoutMs); + signal.addEventListener("abort", onAbort, { once: true }); + void start().catch(() => { + finish({ raw: null, failure: probeFailure( command, @@ -680,8 +742,7 @@ function runProbe( `codex probe failed: ${commandDescription(command)}`, ), }); - finishClosed(); - } + }); return { result, closed }; } @@ -697,7 +758,7 @@ function startPromptProbeFlight(command: ProbeCommand): PromptProbeFlight { resultSettled: false, settled: false, }; - const execution = runProbe(command, controller.signal, () => { + const execution = runProbe(command, controller, () => { flight.joinable = false; }); flight.result = execution.result @@ -722,6 +783,9 @@ async function runSharedPromptProbe( if (signal?.aborted) return { kind: "failed" }; const active = activePromptProbe; if (!active) { + // The new flight claims the sole slot synchronously, before any await: + // trust verification runs inside it under the probe deadline, and a + // rejected target becomes the flight's shared failure for every joiner. const result = await waitForPromptProbeFlight(startPromptProbeFlight(command), signal); if (!result) return { kind: "failed" }; if (result.failure) return { kind: "failed", failure: result.failure }; @@ -860,6 +924,39 @@ export async function probePromptText( signal?: AbortSignal, promptStateFingerprint: string | null = null, ): Promise { + // Metadata time counts against the caller budget; synchronous reads cannot + // be interrupted, so expiry blocks admission after they return. Waiting and + // trust verification are cancellable. The flight keeps its fixed deadline: + // caller expiry ends only that wait, without extending the shared backstop. + // The child also stops on output overflow, process errors or last-waiter exit. + const deadlineAt = Date.now() + timeoutMs; + const deadline = clearableDeadline(timeoutMs, signal); + try { + const result = await probePromptTextWithinDeadline(timeoutMs, deadlineAt, deadline, promptStateFingerprint); + if (!deadline.didExpire()) return result; + const command = `${result.runtime?.command ?? "codex"} debug prompt-input`; + return { + ...result, + ok: false, + detail: "prompt probe timed out", + failure: { + kind: "execution-failed", + command, + detail: `${PROMPT_PROBE_TIMEOUT_PHRASE}: ${command}`, + }, + }; + } finally { + deadline.clear(); + } +} + +async function probePromptTextWithinDeadline( + timeoutMs: number, + deadlineAt: number, + deadline: ClearableDeadline, + promptStateFingerprint: string | null, +): Promise { + const signal = deadline.signal; // The probe runs in CODEX_HOME, never in a caller-supplied directory. A `cwd` // parameter let an authenticated request read any readable folder's AGENTS.md, // and it also described a prompt that depends on where Codex happened to run. @@ -927,6 +1024,37 @@ export async function probePromptText( timeoutMs, promptStateFingerprint, }; + // The absolute check complements the abort signal: a synchronous metadata + // read that consumed the whole budget starves the timer callback, so the + // signal can still be quiet here after the absolute deadline passed. An + // expired caller must never start a flight. + const metadataBarrier = probeMetadataBarrierForTests; + if (metadataBarrier) await metadataBarrier; + if (signal.aborted && !deadline.didExpire()) { + return { + ok: false, + codexHome, + layers: { ...baseLayers }, + base, + ...(reportedRuntime ? { runtime: reportedRuntime } : {}), + detail: "prompt probe cancelled", + }; + } + if (deadline.didExpire() || Date.now() >= deadlineAt) { + return { + ok: false, + codexHome, + layers: { ...baseLayers }, + base, + ...(reportedRuntime ? { runtime: reportedRuntime } : {}), + detail: "prompt probe timed out", + failure: probeFailure( + command, + "execution-failed", + `${PROMPT_PROBE_TIMEOUT_PHRASE}: ${commandDescription(command)}`, + ), + }; + } const outcome = await runSharedPromptProbe(command, signal); if (outcome.kind !== "output") { return { @@ -940,7 +1068,9 @@ export async function probePromptText( ? "prompt probe cancelled" : outcome.kind === "busy" ? "another prompt probe is still finishing; retry shortly" - : "codex debug prompt-input failed", + : outcome.kind === "failed" && outcome.failure?.detail.startsWith(PROMPT_PROBE_TIMEOUT_PHRASE) + ? "prompt probe timed out" + : "codex debug prompt-input failed", }; } const raw = outcome.raw; @@ -1015,6 +1145,16 @@ export function setPromptTextProbeCloseBarrierForTests(barrier: Promise | probeCloseBarrierForTests = barrier; } +/** Test-only metadata-phase barrier; models elapsed work before flight admission. */ +export function setPromptTextProbeMetadataBarrierForTests(barrier: Promise | null): void { + probeMetadataBarrierForTests = barrier; +} + +/** Test-only exec-boundary dep seam; production resolves the real environment. */ +export function setPromptTextProbeExecBoundaryDepsForTests(deps: CodexExecBoundaryAsyncDeps | null): void { + probeExecBoundaryDepsForTests = deps; +} + /** Test-only fail-closed drain so one failed lifecycle case cannot poison another. */ export async function resetPromptTextProbeForTests(): Promise { const active = activePromptProbe; @@ -1031,4 +1171,6 @@ export async function resetPromptTextProbeForTests(): Promise { probeRuntimeForTests = undefined; probeSpawnAttemptsForTests = 0; probeCloseBarrierForTests = null; + probeExecBoundaryDepsForTests = null; + probeMetadataBarrierForTests = null; } diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index e1a37f3b4d5..d0e9d1538ef 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -1,9 +1,16 @@ import { execFile, execFileSync } from "node:child_process"; -import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { delimiter, join, posix } from "node:path"; +import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; -import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; +import { isSpawnableCodexCandidate } from "./exec-invocation"; +import type { SpawnInvocation } from "../lib/win-exec"; +import { + CodexExecTrustError, + validatedCodexExecTarget, + validatedCodexExecTargetAsync, +} from "./exec-boundary"; +import { macCodexAppBundleFor, macCodexAppCommands } from "./mac-app-bundle"; import { beginCodexCliSelectionParentWrite, CodexCliSelectionMutationBusyError, @@ -96,6 +103,14 @@ export interface ResolveCodexRuntimeDeps { readFileSync?: (path: string, encoding: "utf8") => string; /** Bounded in-process macOS bundle identity reader; injected readers bypass the memo. */ readBundleIdentifier?: (infoPlistPath: string) => string | null; + /** macOS bundle publisher/filesystem trust check; injected checks bypass the memo. */ + macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; + /** Async twin used by resolveCodexRuntimeAsync; injected checks bypass the memo. */ + macBundleTrustReasonAsync?: (bundleRoot: string, binary: string, signal?: AbortSignal) => Promise; + /** POSIX spawnability predicate at the exec boundary (regular file + X_OK). */ + executable?: (path: string) => boolean; + /** Canonicalizer at the exec boundary; returns null when the path cannot be resolved. */ + realpath?: (path: string) => string | null; now?: () => number; /** * When false, stop after the first valid priority candidate (skip PATH-wide @@ -446,7 +461,7 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): } } -type VersionProbeResult = { ok: true; version: string | null } | { ok: false; reason: string }; +type VersionProbeResult = { ok: true; version: string | null; macAppBundle?: boolean } | { ok: false; reason: string }; /** Checks that need no exec; a non-null answer settles the candidate without running `--version`. */ function versionProbePrecheck(command: string, deps: ResolveCodexRuntimeDeps): VersionProbeResult | null { @@ -469,11 +484,19 @@ function versionProbePrecheck(command: string, deps: ResolveCodexRuntimeDeps): V return null; } -function versionProbeInvocation(command: string, deps: ResolveCodexRuntimeDeps, probeHome: string) { - const invocation = codexExecInvocation(command, ["--version"], deps.platform ?? process.platform, { +/** The sync and async probes differ only in which exec-boundary check builds this invocation. */ +function codexExecBoundaryDeps(deps: ResolveCodexRuntimeDeps) { + return { env: deps.env, + platform: deps.platform, exists: deps.existsSync, - }); + executable: deps.executable, + realpath: deps.realpath, + macBundleTrustReason: deps.macBundleTrustReason, + }; +} + +function versionProbeInvocation(deps: ResolveCodexRuntimeDeps, probeHome: string, invocation: SpawnInvocation) { return { file: invocation.file, args: invocation.args, @@ -488,10 +511,10 @@ function versionProbeInvocation(command: string, deps: ResolveCodexRuntimeDeps, }; } -function versionProbeOutput(output: string): VersionProbeResult { +function versionProbeOutput(output: string, macAppBundle: boolean): VersionProbeResult { const version = parseCodexVersionOutput(output); if (!version) return { ok: false, reason: "unrecognized --version output" }; - return { ok: true, version }; + return { ok: true, version, macAppBundle }; } function versionProbeError(error: unknown, probeHome: string | undefined): VersionProbeResult { @@ -522,9 +545,13 @@ function probeVersion(command: string, deps: ResolveCodexRuntimeDeps): VersionPr let probeHome: string | undefined; try { probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-")); - const { file, args, options } = versionProbeInvocation(command, deps, probeHome); - return versionProbeOutput(exec(file, args, options)); + const target = validatedCodexExecTarget(command, ["--version"], codexExecBoundaryDeps(deps)); + const { file, args, options } = versionProbeInvocation(deps, probeHome, target.invocation); + return versionProbeOutput(exec(file, args, options), target.macAppBundle); } catch (error) { + if (error instanceof CodexExecTrustError) { + return { ok: false, reason: redactUserPath(redactSecretString(error.message)).slice(0, 160) }; + } return versionProbeError(error, probeHome); } finally { removeProbeHome(probeHome); @@ -585,9 +612,17 @@ async function probeVersionAsync(command: string, deps: ResolveCodexRuntimeDeps) let probeHome: string | undefined; try { probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-")); - const { file, args, options } = versionProbeInvocation(command, deps, probeHome); - return versionProbeOutput(await exec(file, args, { ...options, signal: deps.signal })); + const target = await validatedCodexExecTargetAsync(command, ["--version"], { + ...codexExecBoundaryDeps(deps), + macBundleTrustReasonAsync: deps.macBundleTrustReasonAsync, + signal: deps.signal, + }); + const { file, args, options } = versionProbeInvocation(deps, probeHome, target.invocation); + return versionProbeOutput(await exec(file, args, { ...options, signal: deps.signal }), target.macAppBundle); } catch (error) { + if (error instanceof CodexExecTrustError) { + return { ok: false, reason: redactUserPath(redactSecretString(error.message)).slice(0, 160) }; + } return versionProbeError(error, probeHome); } finally { removeProbeHome(probeHome); @@ -639,37 +674,12 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] { return [...new Set(out)]; } -/** Conventional Codex desktop bundles, ordered system before user. */ -function macCodexAppBundles(deps: ResolveCodexRuntimeDeps): string[] { - const env = deps.env ?? process.env; - const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir(); - return ["/Applications/ChatGPT.app", posix.join(home, "Applications/ChatGPT.app")]; -} - -function macCodexAppCommands(deps: ResolveCodexRuntimeDeps): string[] { - return macCodexAppBundles(deps).flatMap(bundle => [ - `${bundle}/Contents/Resources/codex-cli/bin/codex`, `${bundle}/Contents/Resources/codex`, - ]); -} - -function realpathOrNull(path: string): string | null { - try { return realpathSync(path); } catch { return null; } -} - /** - * Classify any path inside either bundle, including backing executables, by normalized and - * canonical forms with a segment boundary: a symlinked HOME (or /tmp -> /private/tmp) must not - * let a canonical bundle path skip the identity check or the handover exclusion. + * Bundle-identity eligibility only. This runs inside the exec-free precheck and + * inside deferred/passive enumeration, so it must never spawn: signature and + * ownership trust is enforced at the execution boundary (exec-boundary.ts) + * every time the selected command is actually run, not once at selection. */ -function macCodexAppBundleFor(command: string, deps: ResolveCodexRuntimeDeps): string | undefined { - if ((deps.platform ?? process.platform) !== "darwin" || !posix.isAbsolute(command)) return undefined; - const forms = [posix.normalize(command), realpathOrNull(command)].filter((form): form is string => Boolean(form)); - return macCodexAppBundles(deps).find(bundle => { - const roots = [posix.normalize(bundle), realpathOrNull(bundle)].filter((root): root is string => Boolean(root)); - return forms.some(form => roots.some(root => form.startsWith(`${root}/`))); - }); -} - function macCodexAppIdentityMatches(command: string, deps: ResolveCodexRuntimeDeps): boolean { const bundle = macCodexAppBundleFor(command, deps); if (!bundle) return true; @@ -1039,6 +1049,10 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { || deps.existsSync || deps.readFileSync || deps.readBundleIdentifier + || deps.macBundleTrustReason + || deps.macBundleTrustReasonAsync + || deps.executable + || deps.realpath || deps.readdirSync || deps.statSync || deps.configDir @@ -1236,6 +1250,7 @@ function* resolveCodexRuntimeSteps( const seen = new Set(); const valid: ResolvedCodexRuntime[] = []; + const probedAppCommands = new Set(); // A caller that declined PATH-wide discovery normally gets the first valid // candidate and nothing else, which is right for a hot path and wrong for // exactly one arrangement: an unpinned persisted selection sitting in front of @@ -1273,6 +1288,9 @@ function* resolveCodexRuntimeSteps( continue; } valid.push({ command: candidate.command, version: probed.version, source: candidate.source }); + // Keep the actual executed target's classification, including a bare PATH alias. + // Deferred candidates have no version and cannot trigger a version-based takeover. + if (probed.macAppBundle) probedAppCommands.add(candidate.command); } if (valid.length === 0) { @@ -1323,7 +1341,7 @@ function* resolveCodexRuntimeSteps( const newerDiscovered = valid .filter(item => !sameRuntimeCommand(item.command, selected.command) - && !macCodexAppBundleFor(item.command, deps) + && !probedAppCommands.has(item.command) && typeof item.version === "string" && typeof selected.version === "string" && compareCodexVersions(item.version, selected.version) > 0) @@ -1387,9 +1405,16 @@ export async function resolveAndPersistCodexRuntimeAsync( deps: ResolveCodexRuntimeDeps = {}, isCurrent: () => boolean = () => true, ): Promise { - const observation = await resolveCodexRuntimeObservationAsync(deps); - if (!isCurrent() || observation.epoch === null || observation.epoch !== resolveCacheEpoch + if (deps.signal?.aborted) return null; + let observation: AsyncRuntimeObservation; + try { observation = await resolveCodexRuntimeObservationAsync(deps); } + catch (error) { + if (deps.signal?.aborted) return null; + throw error; + } + if (deps.signal?.aborted || !isCurrent() || observation.epoch === null || observation.epoch !== resolveCacheEpoch || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; + if (deps.signal?.aborted) return null; return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps)); } diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts index cc7c6681e45..9962477bc93 100644 --- a/src/remote-control/workspace-codex-runtime.ts +++ b/src/remote-control/workspace-codex-runtime.ts @@ -2,6 +2,10 @@ import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { resolveCodexRuntimeAsync } from "../codex/runtime"; +import { + assertCodexExecTargetTrustedAsync, + type CodexExecBoundaryAsyncDeps, +} from "../codex/exec-boundary"; import { beginCodexCliSelectionParentWrite, CodexCliSelectionMutationBusyError, @@ -292,6 +296,8 @@ export interface CodexRemoteWorkspaceRuntimeOptions { command?: readonly string[]; env?: Record; version?: string; + /** Test seam for the exec-boundary platform check; production uses the real platform. */ + execDeps?: CodexExecBoundaryAsyncDeps; } export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { @@ -319,7 +325,7 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim if (command.length < 1) throw new Error("Codex CLI is unavailable on this Hub"); const executablePath = isAbsolute(command[0]!) ? command[0]! : findExecutableOnPath(command[0]!); if (!executablePath) throw new Error("Codex CLI executable could not be resolved on this Hub"); - command[0] = executablePath; + const platform = this.options.execDeps?.platform ?? process.platform; const runtimeDirectory = dirname(realpathSync(executablePath)); const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-codex-")); let processPath = process.env.PATH ?? "/usr/bin:/bin"; @@ -360,15 +366,6 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim const tokenEnvVar = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; const childEnv = { ...process.env, ...this.options.env, PATH: processPath, [tokenEnvVar]: bridge.token }; - const invocation = remoteWorkspaceProcessInvocation([ - ...command, - "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, - "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(tokenEnvVar)}`, - "-c", `${mcpPrefix}.required=true`, - "-c", `${mcpPrefix}.enabled_tools=${JSON.stringify(options.tools)}`, - "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, - "app-server", "--listen", "stdio://", - ], { env: childEnv }); let child: Bun.Subprocess<"pipe", "pipe", "pipe">; // A cli-update worker holds this gate from its final process scan through // installer termination/readback; a new session must not appear inside that @@ -388,21 +385,44 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim throw new CodexCliSelectionMutationBusyError(); } try { - child = Bun.spawn([invocation.file, ...invocation.args], { - cwd: isolation, - env: childEnv, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - ...invocation.options, - }); + try { + // Hold selection stable across async trust validation and spawn. A failed + // check releases admission too; no unchecked target reaches the launcher. + const trustedTarget = await assertCodexExecTargetTrustedAsync(executablePath, { + env: childEnv, + cwd: isolation, + ...(this.options.execDeps ?? {}), + }); + // Windows returns ComSpec for .cmd/.bat; retain the located command so + // the launcher rebuilds its escaped wrapper. macOS executes the checked path. + command[0] = platform === "win32" ? executablePath : trustedTarget; + const invocation = remoteWorkspaceProcessInvocation([ + ...command, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(tokenEnvVar)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=${JSON.stringify(options.tools)}`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { env: childEnv, platform }); + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } finally { + selectionWrite.exit(); + } } catch (error) { - selectionWrite.exit(); - await bridge.stop(); - removeRemoteWorkspaceIsolation(isolation); + await runRemoteWorkspaceCleanupSteps([ + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]).catch(() => {}); throw error; } - selectionWrite.exit(); const peer = new JsonLineRpcProcess(child); let activeTurn: ActiveTurn | null = null; let stopped = false; diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 3cfec90def8..b647bd7af5d 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -52,7 +52,8 @@ The app is discovered and confirmed by bundle identifier through `darwinDesktopAppAdapter.discover` (`src/codex/desktop-app/darwin.ts`). Launch derives the bundled app-server binary from that root (`resolveChatgptCodexBinary`) and refuses when none exists or when `untrustedChatgptBundleReason` -(`src/chatgpt/app-server-shim/bundle-trust.ts`) reports the bundle or binary as owned +(`src/chatgpt/app-server-shim/bundle-trust.ts`; the signal-aware async twin in +`bundle-trust-async.ts` serves the exec boundary) reports the bundle or binary as owned by another user, group/other-writable, unsigned, or signed by a team other than OpenAI's. It writes the mode-0755 executable through an exclusive temp file and a rename (never through a symbolic link), quits the bundle by id, waits for this user's @@ -64,6 +65,11 @@ through the filesystem root. Ancestors must be owned by this user or root; group is accepted only for trusted sticky ancestors or root-owned, non-world-writable containers whose group matches the local directory service's admin group. An unreadable admin-group lookup does not grant that exception. These checks do not attest ACL or mount-policy restrictions. +Both drivers share filesystem/signature policy and dependency types from +`src/chatgpt/app-server-shim/bundle-trust-policy.ts`, which imports neither driver and starts no subprocess. +The selected executable must be strictly inside the bundle's `Contents/`; both the ChatGPT CLI +layout and Codex.app's `Contents/Resources/codex-aarch64-apple-darwin` layout use this policy. + Restore does not require the experimental flag or a bundled app-server binary. It relaunches without that override and removes the launcher only after open succeeds; when no `com.openai.codex` bundle is found it removes the launcher, relaunches nothing and @@ -84,3 +90,16 @@ Tests cover rewriting, byte framing, passthrough degradation, source/compiled launcher text, stub launcher execution, hidden preflight, and strict config writes in tests/clients/desktop-*.test.ts. Pipe-crash behavior is mock evidence only; no bundled-app respawn guarantee is asserted. + +## Codex app execution trust + +`src/codex/mac-app-bundle.ts` classifies lexical and canonical ancestors named +`Codex.app` or `ChatGPT.app` at any location, including relocated bundles and executable aliases. +The name only routes to verification. `src/codex/exec-boundary.ts` requires a canonical bundle/binary +pair with the binary strictly inside that bundle's `Contents/`, then applies the same synchronous +or asynchronous OpenAI signature and POSIX filesystem policy before returning an invocation. +Canonicalization or verification failure refuses execution. This covers environment/configured +commands and catalog, prompt, login, refresh and remote-workspace consumers; ordinary CLI and +third-party app paths retain their existing policy. Conventional automatic discovery remains +limited to the two ChatGPT installation roots. Regression coverage is in +`tests/codex-integration/exec-known-app-bundles.test.ts`. diff --git a/structure/codex-prompts.md b/structure/codex-prompts.md index cc4106080b5..0d7cbebd9d5 100644 --- a/structure/codex-prompts.md +++ b/structure/codex-prompts.md @@ -58,6 +58,16 @@ matches the submitted input. Refusals and newer edits keep their drafts. runs `codex debug prompt-input` in that home, bounded in time and in bytes, maps each rendered section onto a layer, and takes no caller-supplied directory. Captured process output is never serialized back: a failure is a classified kind plus a fixed phrase and the resolved command. +Each caller's budget includes metadata time, the shared-flight wait and verification. +Synchronous metadata reads cannot be interrupted; an elapsed check after they return stops a +budget-exhausted caller from starting a flight even when a stalled event loop kept the timer +quiet. Expiry preserves the locally read base prompt and reports `execution-failed` with a +timed-out detail; an external cancellation that won first retains its cancelled status. Equal callers share one flight +whose fixed deadline covers asynchronous execution-trust validation and the probe child. The flight claims the sole +slot before validation begins; a rejected target becomes the shared failure for every joined caller. +A caller's expiry ends only its own wait and never extends the shared bound. The flight's deadline timer, +last-waiter exit, output overflow or process errors can stop the flight. Before a child is spawned, the slot +is held until asynchronous trust validation settles; after spawn, it is held until the exact child closes. Non-object messages and content items are skipped, and only string text is extracted; output with no recognized sections returns `output-invalid`, while a resolved installed runtime that cannot spawn returns `program-not-found`. The base prompt is absent from that output, because Codex discards `base_instructions` before diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 4095a7591c7..6ed532f9cb4 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -25,7 +25,7 @@ RPC v1 peers fail closed and must upgrade together rather than fall back to imme `src/remote-control/workspace-hub.ts`, `src/remote-control/workspace-device.ts` and `src/remote-control/workspace-sessions.ts` own separate persisted state. `src/remote-control/workspace-secret-store.ts` requires private permissions and rejects access failures rather than treating them as first-run absence. Publication reuses `src/config/atomic-write.ts`; workspace file publication uses the remote-workspace publisher in `src/lib/windows-atomic-replace.ts`. -`src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains registered executor pair/agent/status handling; the machine-readable entries live in `src/cli/capabilities.ts`. +`src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. On macOS the Codex adapter re-verifies its resolved executable against the shared app-bundle trust policy before the app-server child is spawned (src/codex/exec-boundary.ts). Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains registered executor pair/agent/status handling; the machine-readable entries live in `src/cli/capabilities.ts`. Pi prompt admission and turn settlement are observed concurrently in `src/remote-control/workspace-pi-runtime.ts`. Either failure rejects the prompt; success requires a successful prompt response and the turn's settlement event. An extension error or child exit before admission cannot leave an unobserved rejection. `tests/clients/remote-workspace-cli-runtimes.test.ts` covers both event orders and failure paths with isolated RPC children; this is not live Pi confinement evidence. @@ -59,7 +59,7 @@ A track that holds a nested split reserves that subtree's minimum width (`minLay side-by-side splits never scroll inside a pane column; an oversized tree scrolls the pane surface once. Hub runtime admission counts pending create/resume starts as well as live handles against global and per-device limits; every outcome releases its reservation. Stop and shutdown reclaim late resumed handles before clearing ownership. Coordinator result size limits normalize both response text and success, so bridge and MCP callers receive consistent errors. -`src/remote-control/workspace-codex-runtime.ts` takes the shared Codex-CLI-update selection gate immediately before its app-server `Bun.spawn`; while a cli-update worker holds admission through installer termination and readback the launch refuses with `codex_cli_selection_busy` after running the same bridge/isolation cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). +`src/remote-control/workspace-codex-runtime.ts` takes the shared Codex-CLI-update selection gate before execution-boundary trust validation and holds it across the asynchronous check through its app-server `Bun.spawn`. macOS executes the canonical checked target; validation or spawn failure releases the gate before bridge/isolation cleanup, attempting both cleanup steps while preserving the original error. While a cli-update worker holds admission through installer termination and readback the launch refuses with `codex_cli_selection_busy` after the same cleanup. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). Prompt HTTP admission returns 202 with the existing session/event cursor; tracked operations publish terminal status for polling and attach a rejection observer immediately. Reconnect/resume cannot publish ready while a turn is active. The dashboard prefers newer local event cursors over stale polling, favors authoritative polling on ties, retains unconfirmed draft text with an uncertainty notice, and never retries prompt POSTs automatically. diff --git a/structure/runtime.md b/structure/runtime.md index 3a96bd8f7ec..c2c2d2b4d70 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; signal cancellation makes the persistence wrapper return null without writing, while raw async resolver rejections and exceptions without signal cancellation retain their rejection behavior; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Known Codex.app and ChatGPT.app commands at any location or alias follow the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust); automatic discovery also requires matching bundle identity; the existing ChatGPT bundle trust policy (safe ownership, permissions, non-symlink paths, and OpenAI signatures for both the bundle and the exact executable) is enforced at every execution boundary in src/codex/exec-boundary.ts before the resolved canonical target is spawned, not once at selection. Version probes retain that execution's bundle classification for selection, so a bare PATH alias of an app cannot supersede a discovered persisted runtime; selection does not resolve PATH again after the probe. Stored and displayed command spelling remains unchanged, and this observation grants no trust to a later execution. Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. ## Bun runtime and test runner diff --git a/tests/clients/desktop-bundle-trust-async.test.ts b/tests/clients/desktop-bundle-trust-async.test.ts new file mode 100644 index 00000000000..0f51ce186a3 --- /dev/null +++ b/tests/clients/desktop-bundle-trust-async.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, test } from "bun:test"; +import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { + OPENAI_TEAM_ID, untrustedChatgptBundleReason, untrustedChatgptBundleReasonAsync, + defaultAsyncBundleTrustDeps, + type AsyncBundleTrustDeps, type BundleTrustDeps, type TrustCommandSpawn, +} from "../../src/chatgpt/app-server-shim/bundle-trust"; + +const root = resolve("/Applications/ChatGPT.app"); +const binary = join(root, "Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"); +const shell = join(root, "Contents", "MacOS", "ChatGPT"); +type Stat = NonNullable>; +type Reply = ReturnType; +const trustedReply = (args: readonly string[]): Reply => ({ status: 0, + output: args[0] === "--verify" ? "" : `Identifier=codex\nTeamIdentifier=${OPENAI_TEAM_ID}\n` }); + +function fixture(overrides = new Map | null>(), adminGroupId: number | null = 80, + reply: (args: readonly string[]) => Reply = trustedReply) { + const syncCalls: string[][] = [], asyncCalls: string[][] = [], observed: string[] = []; + const common = { + uid: 501, adminGroupId: adminGroupId ?? undefined, + stat(path: string): Stat | null { + observed.push(path); + const change = overrides.get(path); + if (change === null) return null; + const isFile = path === binary || path === shell; + return { uid: isFile || path === root ? 501 : 0, gid: 80, mode: 0o755, + isFile, isDirectory: !isFile, isSymbolicLink: false, ...change }; + }, + }; + const sync: BundleTrustDeps = { ...common, codesign: args => { syncCalls.push([...args]); return reply(args); } }; + const async: AsyncBundleTrustDeps = { ...common, codesign: async args => { asyncCalls.push([...args]); return reply(args); } }; + return { sync, async, syncCalls, asyncCalls, observed }; +} + +describe("async bundle trust policy", () => { + for (const selected of [binary, undefined]) test(`verifies the bundle and ${selected ? "selected binary" : "restore shell"}`, async () => { + const f = fixture(), target = selected ?? shell; + expect(untrustedChatgptBundleReason(root, selected, f.sync)).toBeNull(); + expect(await untrustedChatgptBundleReasonAsync(root, selected, f.async)).toBeNull(); + const expected = [ + ["--verify", "--strict", root], ["-dv", "--verbose=2", root], + ["--verify", "--strict", target], ["-dv", "--verbose=2", target], + ]; + expect(f.syncCalls).toEqual(expected); + expect(f.asyncCalls).toEqual(expected); + expect(f.observed).toContain(dirname(dirname(root))); + }); + + test("root ownership retains the same trusted result as current-user ownership", async () => { + const f = fixture(new Map([[root, { uid: 0 }], [binary, { uid: 0 }]])); + expect(untrustedChatgptBundleReason(root, binary, f.sync)).toBeNull(); + expect(await untrustedChatgptBundleReasonAsync(root, binary, f.async)).toBeNull(); + expect(f.asyncCalls).toEqual(f.syncCalls); + }); + + const rejectedPaths: Array<[string, string, Partial | null, string]> = [ + ["missing executable", binary, null, "missing"], + ["foreign executable", binary, { uid: 502 }, "another user"], + ["group-writable executable", binary, { mode: 0o775 }, "writable"], + ["world-writable bundle", root, { mode: 0o757 }, "writable"], + ["symlink executable", binary, { isSymbolicLink: true }, "symbolic link"], + ["symlink bundle", root, { isSymbolicLink: true }, "symbolic link"], + ["symlink ancestor", dirname(root), { isSymbolicLink: true }, "ancestor"], + ["foreign ancestor", dirname(root), { uid: 502 }, "another user"], + ["writable interior", dirname(binary), { mode: 0o777 }, "writable"], + ["directory executable", binary, { isFile: false, isDirectory: true }, "not a file"], + ]; + for (const [name, path, change, reason] of rejectedPaths) test(`${name} rejects before any signature call`, async () => { + const f = fixture(new Map([[path, change]])); + const syncReason = untrustedChatgptBundleReason(root, binary, f.sync); + expect(syncReason).toContain(reason); + expect(await untrustedChatgptBundleReasonAsync(root, binary, f.async)).toBe(syncReason); + expect(f.syncCalls).toEqual([]); + expect(f.asyncCalls).toEqual([]); + }); + + test("an outside executable is not trusted or inspected", async () => { + const f = fixture(), outside = join(dirname(root), "codex"); + const syncReason = untrustedChatgptBundleReason(root, outside, f.sync); + expect(syncReason).toContain("outside the bundle"); + expect(await untrustedChatgptBundleReasonAsync(root, outside, f.async)).toBe(syncReason); + expect(f.observed).toEqual([]); + expect(f.asyncCalls).toEqual([]); + }); + + const ancestorCases: Array<[string, Partial, number | null, boolean]> = [ + ["local admin group", { mode: 0o775, gid: 456 }, 456, true], + ["other group", { mode: 0o775, gid: 20 }, 80, false], + ["unavailable admin lookup", { mode: 0o775 }, null, false], + ["world-writable admin container", { mode: 0o777 }, 80, false], + ["trusted sticky container", { mode: 0o1777 }, null, true], + ["foreign sticky owner", { uid: 502, mode: 0o1777 }, 80, false], + ]; + for (const [name, change, admin, trusted] of ancestorCases) test(`ancestor policy parity: ${name}`, async () => { + const f = fixture(new Map([[dirname(root), change]]), admin); + const syncReason = untrustedChatgptBundleReason(root, binary, f.sync); + expect(await untrustedChatgptBundleReasonAsync(root, binary, f.async)).toBe(syncReason); + if (trusted) expect(syncReason).toBeNull(); + else { expect(syncReason).not.toBeNull(); expect(f.asyncCalls).toEqual([]); } + }); + + for (const target of [root, binary]) { + for (const [name, stage, bad] of [ + ["failed verification", "--verify", { status: 1, output: "" }], + ["unknown verification status", "--verify", { status: null, output: "" }], + ["foreign team", "-dv", { status: 0, output: "TeamIdentifier=OTHERTEAM\n" }], + ["embedded team marker", "-dv", { status: 0, output: `NotTeamIdentifier=${OPENAI_TEAM_ID}\n` }], + ["unknown detail status", "-dv", { status: null, output: `TeamIdentifier=${OPENAI_TEAM_ID}\n` }], + ] as const) test(`${name} on ${target === root ? "bundle" : "executable"} is rejected`, async () => { + const f = fixture(undefined, 80, args => args.at(-1) === target && args[0] === stage ? bad : trustedReply(args)); + const syncReason = untrustedChatgptBundleReason(root, binary, f.sync); + expect(syncReason).toContain(stage === "--verify" ? "code-signature" : "not signed by OpenAI"); + expect(await untrustedChatgptBundleReasonAsync(root, binary, f.async)).toBe(syncReason); + expect(f.asyncCalls).toEqual(f.syncCalls); + expect(f.asyncCalls.at(-1)).toEqual(stage === "--verify" ? ["--verify", "--strict", target] : ["-dv", "--verbose=2", target]); + }); + } + + test("an already-aborted request does not inspect files or call injected codesign", async () => { + const f = fixture(), controller = new AbortController(), reason = new Error("cancel trust"); + controller.abort(reason); + const settled = await untrustedChatgptBundleReasonAsync(root, binary, f.async, controller.signal).then( + () => null, + cause => cause, + ); + expect(settled).toBe(reason); + expect(f.observed).toEqual([]); + expect(f.asyncCalls).toEqual([]); + }); + + for (const pendingCall of [1, 4]) test(`abort while signature call ${pendingCall} is pending cannot grant trust`, async () => { + const f = fixture(), controller = new AbortController(), reason = new Error("cancel pending trust"); + let enter!: () => void, release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + f.async.codesign = async (args, signal) => { + f.asyncCalls.push([...args]); + expect(signal).toBe(controller.signal); + if (f.asyncCalls.length === pendingCall) { enter(); await pending; } + return trustedReply(args); + }; + const result = untrustedChatgptBundleReasonAsync(root, binary, f.async, controller.signal); + await entered; + controller.abort(reason); + // Open the gate before observing the product promise: observing the + // pending result must never be what the signature call waits on. + release(); + const settled = await result.then(() => null, cause => cause); + expect(settled).toBe(reason); + expect(f.asyncCalls).toHaveLength(pendingCall); + }); +}); + +// The injected-spawn seam substitutes a long-lived local process for the trust +// command, keeping the cancellation contract meaningful on hosts without +// directory services and without touching real macOS trust commands. The test +// owns that real child directly and reaps it in its cleanup: nothing nested +// between the test and the process can strand it. +const IDLE_CHILD = 'process.on("SIGTERM", () => {}); console.log("ready"); setInterval(() => {}, 1000);'; + +interface TrustChildState { + child: ChildProcess | null; + observedExit: boolean; + kills: string[]; + spawns: number; + spawnedAt?: number; + killElapsedFromSpawn: number[]; +} + +// Every product-promise wait below runs under a watchdog inside the 20s test +// timeout: a wedged lookup must not strand its owned child or skip cleanup. +const TEST_WATCHDOG_MS = 15_000; +const bounded = (promise: Promise, label: string): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`test watchdog: ${label} still pending`)), + TEST_WATCHDOG_MS, + ); + (timer as { unref?: () => void }).unref?.(); + promise.then(resolve, reject).finally(() => clearTimeout(timer)); + }); + +/** When body is null the injected spawn refuses like a failed exec. */ +function trustChildHarness(body: string | null): { + state: TrustChildState; + spawn: TrustCommandSpawn; + cleanup: (controller?: AbortController) => Promise; +} { + const state: TrustChildState = { child: null, observedExit: false, kills: [], spawns: 0, killElapsedFromSpawn: [] }; + const spawn: TrustCommandSpawn = file => { + state.spawns += 1; + if (file !== "/usr/bin/dscl" && file !== "/usr/bin/codesign") { + throw new Error("unexpected trust command: " + file); + } + if (body === null) throw new Error("spawn refused"); + const child = nodeSpawn(process.execPath, ["--eval", body], { stdio: ["ignore", "pipe", "pipe"] }); + state.child = child; + state.spawnedAt = Date.now(); + child.once("exit", () => { state.observedExit = true; }); + const kill = child.kill.bind(child); + child.kill = signal => { + state.kills.push(String(signal ?? "SIGTERM")); + if (state.spawnedAt !== undefined) state.killElapsedFromSpawn.push(Date.now() - state.spawnedAt); + return kill(signal); + }; + return child; + }; + const cleanup = async (controller?: AbortController) => { + controller?.abort(new Error("test cleanup")); + const child = state.child; + if (child && child.exitCode === null && child.signalCode === null) { + const exited = bounded(new Promise(resolve => child.once("exit", resolve)), "owned child reaped"); + child.kill("SIGKILL"); + await exited; + } + }; + return { state, spawn, cleanup }; +} + +describe("default async bundle trust cancellation", () => { + test("already-aborted default calls start zero subprocesses", async () => { + const h = trustChildHarness(IDLE_CHILD); + const controller = new AbortController(); + const reason = new Error("cancel default trust"); + controller.abort(reason); + try { + const depsError = await bounded( + defaultAsyncBundleTrustDeps(controller.signal, { spawn: h.spawn }).then(() => null, cause => cause), + "pre-aborted deps rejection", + ); + expect(depsError).toBe(reason); + const trustError = await bounded( + untrustedChatgptBundleReasonAsync(root, undefined, undefined, controller.signal).then(() => null, cause => cause), + "pre-aborted trust rejection", + ); + expect(trustError).toBe(reason); + expect(h.state.spawns).toBe(0); + } finally { + await h.cleanup(controller); + } + }); + + test("abort of a live default lookup reaps its child before rejecting", async () => { + const h = trustChildHarness(IDLE_CHILD); + const controller = new AbortController(); + const reason = new Error("cancel default trust"); + try { + const run = defaultAsyncBundleTrustDeps(controller.signal, { spawn: h.spawn }); + // The injected spawn ran synchronously inside the first await, so the + // child exists already; "ready" proves the lookup is genuinely pending. + await bounded( + new Promise((resolve, reject) => { + const child = h.state.child; + if (!child) return reject(new Error("trust child was not spawned")); + if (child.exitCode !== null || child.signalCode !== null) return resolve(); + child.stdout?.once("data", () => resolve()); + }), + "lookup readiness", + ); + controller.abort(reason); + const settled = await bounded(run.then(() => null, cause => cause), "abort rejection"); + expect(settled).toBe(reason); + expect(h.state.spawns).toBe(1); + expect(h.state.observedExit).toBe(true); + expect(h.state.kills).toContain("SIGTERM"); + if (process.platform !== "win32") expect(h.state.kills).toContain("SIGKILL"); + } finally { + await h.cleanup(controller); + } + }, 20_000); +}); + +describe("default trust command runner bounds", () => { + test("a lookup that outlives its deadline is killed and reaped", async () => { + const h = trustChildHarness(IDLE_CHILD); + const startedAt = Date.now(); + try { + const deps = await bounded( + defaultAsyncBundleTrustDeps(undefined, { spawn: h.spawn }), + "deadline lookup", + ); + const elapsed = Date.now() - startedAt; + // The 5s dscl deadline fired and supplied no admin-group evidence. + expect(deps.adminGroupId).toBeUndefined(); + expect(h.state.spawns).toBe(1); + expect(h.state.observedExit).toBe(true); + expect(h.state.kills).toContain("SIGTERM"); + if (process.platform !== "win32") expect(h.state.kills).toContain("SIGKILL"); + expect(elapsed).toBeGreaterThanOrEqual(4_500); + expect(elapsed).toBeLessThan(14_000); + } finally { + await h.cleanup(); + } + }, 20_000); + + test("output past the shared 1 MiB cap stops the lookup before its deadline", async () => { + const h = trustChildHarness(IDLE_CHILD); + try { + const run = bounded( + defaultAsyncBundleTrustDeps(undefined, { spawn: h.spawn }), + "overflow lookup", + ); + const child = h.state.child; + if (!child) throw new Error("trust child was not spawned"); + // The synchronous emit delivers the overflow inside this call: any kill + // it records comes from the cap handler, since no timer can fire mid-tick. + expect(h.state.kills).toHaveLength(0); + child.stdout?.emit("data", Buffer.alloc(1_048_577)); + expect(h.state.kills).toContain("SIGTERM"); + const deps = await run; + // Overflow is not trust evidence: the lookup ends killed and reaped. + expect(deps.adminGroupId).toBeUndefined(); + expect(h.state.observedExit).toBe(true); + } finally { + await h.cleanup(); + } + }, 20_000); + + test("output under the cap reaches the deadline instead of an early stop", async () => { + const h = trustChildHarness(IDLE_CHILD); + try { + const run = bounded( + defaultAsyncBundleTrustDeps(undefined, { spawn: h.spawn }), + "under-cap lookup", + ); + const child = h.state.child; + if (!child) throw new Error("trust child was not spawned"); + // A synchronous under-cap burst must not stop the lookup: no kill during + // the emit. The eventual kill can then only come from the 5s deadline. + child.stdout?.emit("data", Buffer.alloc(1_048_000)); + expect(h.state.kills).toHaveLength(0); + const deps = await run; + expect(deps.adminGroupId).toBeUndefined(); + expect(h.state.observedExit).toBe(true); + expect(h.state.kills).toContain("SIGTERM"); + } finally { + await h.cleanup(); + } + }, 20_000); + + // Covers the synchronous throw path of a refused spawn; an asynchronous + // "error"-event spawn failure is a different runner contract. + test("a spawn failure rejects the lookup without leaving a child", async () => { + const h = trustChildHarness(null); + const error = await bounded( + defaultAsyncBundleTrustDeps(undefined, { spawn: h.spawn }).then( + () => null, + cause => (cause instanceof Error ? cause : new Error(String(cause))), + ), + "spawn failure rejection", + ); + expect(error?.message).toBe("spawn refused"); + expect(h.state.spawns).toBe(1); + expect(h.state.child).toBeNull(); + expect(h.state.observedExit).toBe(false); + }); +}); diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts index d773a4d9569..82f3ef1639d 100644 --- a/tests/clients/remote-workspace-codex-runtime.test.ts +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -1,17 +1,21 @@ import { repoPath } from "../helpers/repo-root"; import { expect, spyOn, test } from "bun:test"; +import { copyFileSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, createWorkerCommitWindow, createCodexCliUpdateFence, } from "../../src/codex/cli-update-selection-gate"; -import { resolve } from "node:path"; import { CodexRemoteWorkspaceRuntimeFactory, RemoteWorkspaceCoordinator, + type RemoteWorkspaceRuntimeFactory, type RemoteWorkspaceSessionEvent, type RemoteWorkspaceTransport, } from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; test("Codex Remote Workspace runtime owns the model process on the Hub", async () => { const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; @@ -124,6 +128,159 @@ test("Codex Remote Workspace resumes the persisted App Server thread ID", async await handle.stop(); }); +/** A stdio JSON-RPC double: answers the exact handshake start() performs, spawns nothing real. */ +function fakeCodexAppServerChild(received: Record[]) { + const encoder = new TextEncoder(); + let stdoutController!: ReadableStreamDefaultController; + const stdout = new ReadableStream({ start(controller) { stdoutController = controller; } }); + let stderrController!: ReadableStreamDefaultController; + const stderr = new ReadableStream({ start(controller) { stderrController = controller; } }); + let exitedResolve!: (code: number) => void; + const exited = new Promise(resolvePromise => { exitedResolve = resolvePromise; }); + let finished = false; + function finish(): void { + if (finished) return; + finished = true; + child.exitCode = 0; + stdoutController.close(); + stderrController.close(); + exitedResolve(0); + } + const child = { + pid: 4242, + exitCode: null as number | null, + signalCode: null, + stdin: { + write(chunk: string | Uint8Array) { + const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const message = JSON.parse(line) as Record; + received.push(message); + if (message.id === undefined) continue; + const result = message.method === "initialize" ? {} + : message.method === "config/read" ? { config: {} } + : message.method === "thread/start" ? { thread: { id: "thread-fake" } } + : {}; + stdoutController.enqueue(encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n`)); + } + return 1; + }, + flush() {}, + end() { finish(); }, + }, + stdout, + stderr, + exited, + kill() { finish(); }, + }; + return child as unknown as Bun.Subprocess<"pipe", "pipe", "pipe">; +} + +interface SpawnedInvocation { argv: string[]; options: unknown } + +function captureSpawns(received: Record[]) { + const spawns: SpawnedInvocation[] = []; + const spawn = spyOn(Bun, "spawn").mockImplementation(((argv: readonly string[], options: unknown) => { + spawns.push({ argv: [...argv], options }); + return fakeCodexAppServerChild(received); + }) as never); + return { spawns, spawn }; +} + +function remoteWorkspaceStartOptions(): Parameters[0] { + return { + sessionId: "session-boundary", + deviceId: "device-3", + deviceName: "Computer 3", + rootId: "root-3", + rootLabel: "Project", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + coordinator: new RemoteWorkspaceCoordinator({ isOnline: () => true, async invoke() { return { ok: true }; } }), + emit: () => {}, + }; +} + +test("a Windows .cmd target reaches spawn inside its ComSpec /d /s /c wrapper", async () => { + const comspec = "C:\\Windows\\System32\\cmd.exe"; + const root = mkdtempSync(join(tmpdir(), "ocx-remote-cmd-")); + // The target must be a real native binary: on Linux the permission-profile helper check + // opens it, so a plain-text .cmd would fail before the spawn composition is reached. + const codexCmd = join(root, "codex.cmd"); + try { linkSync(process.execPath, codexCmd); } catch { copyFileSync(process.execPath, codexCmd); } + const received: Record[] = []; + const { spawns, spawn } = captureSpawns(received); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [codexCmd], + env: { ComSpec: comspec }, + execDeps: { platform: "win32", env: { ComSpec: comspec, PATH: "", PATHEXT: ".CMD;.EXE" } }, + }); + let handle: Awaited> | undefined; + try { + handle = await factory.start(remoteWorkspaceStartOptions()); + expect(handle.threadId).toBe("thread-fake"); + } finally { + try { await handle?.stop(); } + finally { + spawn.mockRestore(); + removeTreeWithRetry(root); + } + } + // The boundary answers ComSpec for a .cmd target; command[0] must keep the located + // executablePath so this launcher rebuilds the /d /s /c line the payload rides in. + expect(spawns).toHaveLength(1); + expect(spawns[0]!.argv[0]).toBe(comspec); + expect(spawns[0]!.argv.slice(1, 4)).toEqual(["/d", "/s", "/c"]); + expect(spawns[0]!.argv[4]).toContain("codex.cmd"); + expect(spawns[0]!.argv[4]).toContain("app-server"); + expect(spawns[0]!.options).toMatchObject({ windowsVerbatimArguments: true }); + expect(received.map(message => message.method)).toEqual(["initialize", "initialized", "config/read", "thread/start"]); +}); + +// The fake bundle must be real on disk: start() canonicalizes executablePath with real +// realpathSync, and macOS path grammar cannot be expressed with Windows-shaped paths. +test.skipIf(process.platform === "win32")("a macOS bundle alias is replaced by the canonical checked binary", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-remote-mac-home-")); + const bundleBinary = join(home, "Applications", "ChatGPT.app", "Contents", "Resources", "codex-cli", "bin", "codex"); + mkdirSync(dirname(bundleBinary), { recursive: true }); + try { linkSync(process.execPath, bundleBinary); } catch { copyFileSync(process.execPath, bundleBinary); } + const canonicalBinary = realpathSync(bundleBinary); + const aliasRoot = mkdtempSync(join(tmpdir(), "ocx-remote-mac-alias-")); + const alias = join(aliasRoot, "codex"); + symlinkSync(bundleBinary, alias); + const received: Record[] = []; + const { spawns, spawn } = captureSpawns(received); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [alias], + execDeps: { + platform: "darwin", + env: { HOME: home, PATH: "" }, + exists: () => true, + executable: () => true, + realpath: path => { try { return realpathSync(path); } catch { return null; } }, + macBundleTrustReasonAsync: async () => null, + }, + }); + let handle: Awaited> | undefined; + try { + handle = await factory.start(remoteWorkspaceStartOptions()); + expect(handle.threadId).toBe("thread-fake"); + } finally { + try { await handle?.stop(); } + finally { + spawn.mockRestore(); + try { removeTreeWithRetry(home); } + finally { removeTreeWithRetry(aliasRoot); } + } + } + // A symlinked alias cannot carry the trust decision: the Hub execs only the canonical + // binary the boundary checked. + expect(spawns).toHaveLength(1); + expect(spawns[0]!.argv[0]).toBe(canonicalBinary); + expect(spawns[0]!.argv[0]).not.toBe(alias); +}); + function launchGateStartOptions(emit: (type: RemoteWorkspaceSessionEvent["type"], text: string) => void = () => {}): Parameters[0] { return { sessionId: "launch-gate-session", @@ -141,6 +298,57 @@ function launchGateStartOptions(emit: (type: RemoteWorkspaceSessionEvent["type"] }; } +test("Remote Workspace holds selection through async trust validation and releases on refusal or spawn failure", async () => { + const canonical = "/Applications/Codex.app/Contents/Resources/codex"; + for (const outcome of ["untrusted", "spawn-failed", "started"] as const) { + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence: createCodexCliUpdateFence() }); + let checked!: () => void; + const checking = new Promise(resolveCheck => { checked = resolveCheck; }); + let finishCheck!: (reason: string | null) => void; + const trust = new Promise(resolveTrust => { finishCheck = resolveTrust; }); + const received: Record[] = []; + const { spawns, spawn } = captureSpawns(received); + if (outcome === "spawn-failed") spawn.mockImplementation(() => { throw new Error("fixture spawn failure"); }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath], + execDeps: { + platform: "darwin", exists: () => true, executable: () => true, + realpath: path => path === "/Applications/Codex.app" ? path : canonical, + macBundleTrustReasonAsync: async () => { checked(); return trust; }, + }, + }); + let handle: Awaited> | undefined; + const starting = factory.start(launchGateStartOptions()); + // Observe rejection immediately, including assertion-failure cleanup below. + const result = starting.then(value => ({ handle: value }), error => ({ error })); + try { + await Promise.race([checking, result.then(() => { throw new Error("start settled before trust validation"); })]); + expect(spawns).toHaveLength(0); + const entered = window.tryEnter(); + if (entered) window.exit(); + expect(entered).toBe(false); + finishCheck(outcome === "untrusted" ? "fixture untrusted bundle" : null); + const settled = await result; + if ("error" in settled) { + expect(String(settled.error)).toContain(outcome === "untrusted" ? "untrusted Codex exec target" : "fixture spawn failure"); + } else { + handle = settled.handle; + expect(outcome).toBe("started"); + expect(spawns[0]!.argv[0]).toBe(canonical); + } + expect(spawn).toHaveBeenCalledTimes(outcome === "untrusted" ? 0 : 1); + expect(window.tryEnter()).toBe(true); + window.exit(); + } finally { + finishCheck(null); + const settled = await result; + if ("handle" in settled) handle = settled.handle; + try { await handle?.stop(); } + finally { window.exit(); spawn.mockRestore(); } + } + } +}); + // Uses the REAL factory and its existing explicit command seam. A broken gate // reaches the spawn spy, which throws; it can never launch a real Codex app. test("CLI commit window blocks Remote Workspace native spawn and release restores the caller", async () => { diff --git a/tests/codex-integration/catalog-async-lifetime.test.ts b/tests/codex-integration/catalog-async-lifetime.test.ts index 8e296e53e81..4aa26b6443e 100644 --- a/tests/codex-integration/catalog-async-lifetime.test.ts +++ b/tests/codex-integration/catalog-async-lifetime.test.ts @@ -18,7 +18,8 @@ beforeEach(() => { mkdirSync(repoPath(".tmp"), { recursive: true }); root = mkdtempSync(repoPath(".tmp/catalog-async-")); selected = { command: join(root, "codex.exe"), version: "0.160.0", source: "environment" }; - writeFileSync(selected.command, "synthetic executable"); + // Darwin's execution boundary checks X_OK before the mocked executor is called. + writeFileSync(selected.command, "synthetic executable", { mode: 0o700 }); saved = Object.fromEntries(["OPENCODEX_HOME", "CODEX_HOME", "CODEX_CLI_PATH"].map(key => [key, process.env[key]])); process.env.OPENCODEX_HOME = root; process.env.CODEX_HOME = join(root, "codex-home"); @@ -49,6 +50,53 @@ function warmRuntime() { runtime.setCodexRuntimeResolveCacheForTests({ runtime: selected, failures: [] }, { discoverAlternatives: false }); } +for (const phase of ["cold", "warm", "prepublication"] as const) { + test(`runtime wrapper cancellation is null and write-free at ${phase}`, async () => { + const file = join(root, "codex-runtime.json"); + const before = JSON.stringify({ version: 1, command: selected.command, source: "environment", selectedVersion: "0.159.0", origin: "discovered", updatedAt: "2026-10-08T00:00:00Z" }); + writeFileSync(file, before); + if (phase !== "cold") warmRuntime(); + const controller = new AbortController(); + if (phase !== "prepublication") controller.abort(new Error("fixture cancellation")); + const outcome = await runtime.resolveAndPersistCodexRuntimeAsync({ discoverAlternatives: false, signal: controller.signal }, () => { + if (phase === "prepublication") controller.abort(new Error("publication cancelled")); + return true; + }).then(value => ({ value, error: null }), error => ({ value: undefined, error })); + expect({ ...outcome, bytes: readFileSync(file, "utf8"), probes: exec.mock.calls.length }) + .toEqual({ value: null, error: null, bytes: before, probes: 0 }); + }); +} + +test("runtime wrapper contains an entered probe abort but raw resolver still rejects", async () => { + const entered = hold(), released = hold(), controller = new AbortController(); + const deps: runtime.ResolveCodexRuntimeDeps = { + discoverAlternatives: false, signal: controller.signal, + execFile: async () => { entered.resolve(); await released.promise; throw new Error("probe cancelled"); }, + }; + const pending = runtime.resolveAndPersistCodexRuntimeAsync(deps); + const observed = pending.then(value => ({ value, error: null }), error => ({ value: undefined, error })); + try { + await Promise.race([entered.promise, observed.then(() => { throw new Error("wrapper settled before probe entry"); })]); + controller.abort(new Error("fixture cancellation")); + } finally { released.resolve(); } + const result = await observed; + expect({ ...result, persisted: existsSync(join(root, "codex-runtime.json")) }) + .toEqual({ value: null, error: null, persisted: false }); + const rawError = await runtime.resolveCodexRuntimeAsync(deps).then(() => null, error => error); + expect(rawError).toBeInstanceOf(Error); +}); + +test("runtime wrapper preserves non-cancellation exceptions", async () => { + warmRuntime(); + const sentinel = new Error("currentness callback failed"); + const error = await runtime.resolveAndPersistCodexRuntimeAsync({ discoverAlternatives: false }, () => { throw sentinel; }) + .then(() => null, error => error); + expect(error).toBe(sentinel); + const deps = Object.defineProperty({}, "env", { get() { throw sentinel; } }); + expect(await runtime.resolveAndPersistCodexRuntimeAsync(deps).then(() => null, error => error)).toBe(sentinel); + expect(existsSync(join(root, "codex-runtime.json"))).toBe(false); +}); + test("concurrent async loads share one version/model flight and warm reload runs no child", async () => { const results = await Promise.all(Array.from({ length: 12 }, () => bundled.loadBundledCodexCatalogAsync())); expect(results.every(value => value?.models[0]?.slug === "gpt-5.5")).toBe(true); @@ -140,9 +188,12 @@ test("a scoped source abort does not cancel an independent shared model flight", test("scoped flight abort propagates to its executor and cannot publish late output", async () => { const held = hold(); + const entered = hold(); const controller = new AbortController(); let signal: AbortSignal | undefined; - const pending = bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: (_file, _args, options) => { signal = options.signal; return held.promise; } }, () => true, controller.signal); + const pending = bundled.loadBundledCodexCatalogAsync({ commandCandidates: () => [selected.command], execFile: (_file, _args, options) => { signal = options.signal; entered.resolve(); return held.promise; } }, () => true, controller.signal); + // The async exec boundary yields first; this case aborts an executor already in flight. + await Promise.race([entered.promise, pending.then(() => { throw new Error("catalog settled before executor entry"); })]); controller.abort(); expect(await pending).toBeNull(); expect(signal?.aborted).toBe(true); @@ -280,7 +331,7 @@ test("a new shared selection aborts obsolete model work and refuses its late pub const obsolete = bundled.loadBundledCodexCatalogAsync(); await started.promise; process.env.CODEX_CLI_PATH = join(root, "next.exe"); - writeFileSync(process.env.CODEX_CLI_PATH, "synthetic executable"); + writeFileSync(process.env.CODEX_CLI_PATH, "synthetic executable", { mode: 0o700 }); const next = bundled.loadBundledCodexCatalogAsync(); expect(oldSignal?.aborted).toBe(true); held.resolve(JSON.stringify(catalog)); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 5222c0364cb..504eb703576 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -4,6 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy import { tmpdir } from "node:os"; import { join } from "node:path"; import { codexAccountGatedCanonicalWireModel } from "../../src/server/responses/core"; +import type { BundledCatalogDeps } from "../../src/codex/catalog/bundled"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../src/codex/catalog/native-models"; import { isGpt56NativeSlug } from "../../src/codex/catalog/effort"; import { nativeOpenAiContextTier, nativeOpenAiMaxInputTokens } from "../../src/codex/catalog"; @@ -70,6 +71,19 @@ import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; const originalFetch = globalThis.fetch; +function withBundledCatalogFixture(models: unknown[], read: (deps: BundledCatalogDeps) => T): T { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-bundled-catalog-"))); + const command = join(root, process.platform === "win32" ? "codex.exe" : "codex"), calls: unknown[][] = []; + try { + writeFileSync(command, "fixture only; execution must stay injected", { mode: 0o700 }); + const result = read({ commandCandidates: () => [command], execFileSync: (file, args) => { + calls.push([file, args]); return JSON.stringify({ models }); + } }); + expect(calls).toEqual([[command, ["debug", "models", "--bundled"]]]); + return result; + } finally { removeTreeWithRetry(root); } +} + /** * Discovery runs on the pinned outbound transport, which does not read * `globalThis.fetch`. These tests stub that global, so every config gets the @@ -3362,10 +3376,7 @@ describe("Codex catalog routed normalization", () => { }); test("loads bundled Codex catalog from debug models output", () => { - const catalog = loadBundledCodexCatalog({ - commandCandidates: () => ["codex"], - execFileSync: () => JSON.stringify({ models: [nativeTemplate()] }), - }); + const catalog = withBundledCatalogFixture([nativeTemplate()], deps => loadBundledCodexCatalog(deps)); expect(catalog?.models?.[0]?.slug).toBe("gpt-5.5"); }); @@ -3374,10 +3385,7 @@ describe("Codex catalog routed normalization", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-catalog-")); const path = join(dir, "nested", "opencodex-catalog.json"); try { - const catalog = materializeBundledCodexCatalog(path, { - commandCandidates: () => ["codex"], - execFileSync: () => JSON.stringify({ models: [nativeTemplate()] }), - }); + const catalog = withBundledCatalogFixture([nativeTemplate()], deps => materializeBundledCodexCatalog(path, deps)); expect(catalog?.models?.[0]?.slug).toBe("gpt-5.5"); expect(existsSync(path)).toBe(true); @@ -7107,18 +7115,13 @@ describe("shouldExposeRoutedModel — Gemini image-capable exemption", () => { }); describe("Codex reasoning-effort capability clamp", () => { - function bundledCatalogDeps(efforts: string[]) { - return { - commandCandidates: () => ["codex"], - execFileSync: () => JSON.stringify({ - models: [{ - slug: "gpt-5.5", - base_instructions: "test", - supported_reasoning_levels: efforts.map(effort => ({ effort, description: effort })), - default_reasoning_level: "medium", - }], - }), - }; + function bundledCatalogDeps(efforts: string[], read: (deps: BundledCatalogDeps) => void) { + withBundledCatalogFixture([{ + slug: "gpt-5.5", + base_instructions: "test", + supported_reasoning_levels: efforts.map(effort => ({ effort, description: effort })), + default_reasoning_level: "medium", + }], read); } function routedEntry() { @@ -7161,7 +7164,7 @@ describe("Codex reasoning-effort capability clamp", () => { test("keeps max and ultra when the installed Codex ladder stops at xhigh", () => { const models = [routedEntry()]; - clampCatalogModelsToCodexSupport(models, bundledCatalogDeps(["low", "medium", "high", "xhigh"])); + bundledCatalogDeps(["low", "medium", "high", "xhigh"], deps => clampCatalogModelsToCodexSupport(models, deps)); expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); @@ -7170,7 +7173,7 @@ describe("Codex reasoning-effort capability clamp", () => { test("preserves max and ultra when the installed Codex ladder includes them", () => { const models = [routedEntry()]; - clampCatalogModelsToCodexSupport(models, bundledCatalogDeps(["low", "medium", "high", "xhigh", "max", "ultra"])); + bundledCatalogDeps(["low", "medium", "high", "xhigh", "max", "ultra"], deps => clampCatalogModelsToCodexSupport(models, deps)); expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 01d2a7e5181..d9f1540e4f6 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -18,6 +18,8 @@ import { resetPromptTextProbeForTests, setPromptTextProbeCloseBarrierForTests, setPromptTextProbeCommandForTests, + setPromptTextProbeExecBoundaryDepsForTests, + setPromptTextProbeMetadataBarrierForTests, setPromptTextProbeRuntimeForTests, type PromptTextProbe, } from "../../src/codex/prompt-text-probe"; @@ -547,6 +549,142 @@ describe("prompt probe process lifecycle", () => { expect(readFileSync(started, "utf8").trim().split(/\r?\n/)).toHaveLength(1); }); + for (const cancelFirst of [false, true]) { + test(`metadata admission preserves ${cancelFirst ? "earlier external cancellation" : "elapsed-only timeout"} and starts no child`, async () => { + const home = promptHome({ + "config.toml": 'model = "gpt-test"\nmodel_instructions_file = "base.md"\n', + "base.md": "Base prompt body.", + }); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + let release!: () => void; + setPromptTextProbeMetadataBarrierForTests(new Promise(resolve => { release = resolve; })); + let now = Date.now(), fired = false; + const clock = spyOn(Date, "now").mockImplementation(() => now); + const realSetTimeout = globalThis.setTimeout; + const controller = new AbortController(); + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn, ms, ...args) => + realSetTimeout(() => { if (ms === 60_000) fired = true; fn(...args); }, ms)) as typeof setTimeout); + try { + const pending = probePromptText(60_000, controller.signal); + if (cancelFirst) controller.abort(new Error("external cancellation won first")); + now += 60_001; + release(); + const result = await pending; + + expect(fired).toBe(false); + expect(result).toMatchObject({ + ok: false, + detail: cancelFirst ? "prompt probe cancelled" : "prompt probe timed out", + }); + if (!cancelFirst) expect(result.failure?.kind).toBe("execution-failed"); + else expect(result.failure).toBeUndefined(); + expect(result.base).toMatchObject({ text: "Base prompt body.", reason: "ok" }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + + timer.mockRestore(); clock.mockRestore(); + setPromptTextProbeMetadataBarrierForTests(null); + expect((await probePromptText(2_000)).ok).toBe(true); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); + } finally { + release(); timer.mockRestore(); clock.mockRestore(); + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); + } + + test("a caller whose deadline expires mid-flight reports timed out while a joined caller keeps the shared child", async () => { + // Drive only the owner's deadline after both waiters joined. The child + // releases output on a file barrier, independent of scheduler timing. + const started = join(root(), "shared-deadline-start.txt"); + const released = join(root(), "shared-deadline-release.txt"); + const source = [ + `require("node:fs").writeFileSync(${JSON.stringify(started)}, "started");`, + `const timer = setInterval(() => { if (require("node:fs").existsSync(${JSON.stringify(released)})) { clearInterval(timer); process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)}); } }, 10);`, + ].join(""); + setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); + const callbacks: Array<() => void> = []; + const realSetTimeout = globalThis.setTimeout; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn, ms, ...args) => { + if (ms === 30_000) callbacks.push(() => fn(...args)); + return realSetTimeout(fn, ms, ...args); + }) as typeof setTimeout); + try { + const first = probePromptText(30_000); + await waitUntil(() => existsSync(started), "shared child started"); + const second = probePromptText(30_000); + expect(callbacks).toHaveLength(3); // owner, fixed flight, joined caller + callbacks[0]!(); + + const expired = await first; + expect(expired).toMatchObject({ + ok: false, + detail: "prompt probe timed out", + failure: { kind: "execution-failed" }, + }); + writeFileSync(released, "release"); + expect((await second).ok).toBe(true); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); + expect(readFileSync(started, "utf8").trim()).toBe("started"); + } finally { writeFileSync(released, "release"); timer.mockRestore(); } + }); + + test("the shared flight's own deadline surfaces as timed out for a joined caller still inside its budget", async () => { + // Hold verification at an entered barrier and expire only the fixed + // flight timer after both callers joined; caller deadlines remain quiet. + let entered!: () => void; + const verifying = new Promise(resolve => { entered = resolve; }); + setPromptTextProbeCommandForTests({ + binary: "/Applications/ChatGPT.app/Contents/Resources/codex", + args: ["-e", "process.exit(0)"], + }); + setPromptTextProbeExecBoundaryDepsForTests({ + platform: "darwin", + executable: () => true, + realpath: path => path, + macBundleTrustReasonAsync: (_bundle, _binary, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + entered(); + }), + }); + + const callbacks: Array<() => void> = []; + const realSetTimeout = globalThis.setTimeout; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn, ms, ...args) => { + if (ms === 30_000) callbacks.push(() => fn(...args)); + return realSetTimeout(fn, ms, ...args); + }) as typeof setTimeout); + try { + const first = probePromptText(30_000); + await verifying; + const second = probePromptText(30_000); + expect(callbacks).toHaveLength(3); + callbacks[1]!(); // fixed flight, neither caller deadline + + const [owner, joined] = await Promise.all([first, second]); + expect(owner).toMatchObject({ + ok: false, + detail: "prompt probe timed out", + failure: { kind: "execution-failed" }, + }); + expect(joined).toMatchObject({ + ok: false, + detail: "prompt probe timed out", + failure: { kind: "execution-failed" }, + }); + // The joined caller was handed the flight's own timeout failure, proving + // the shared backstop surfaced it rather than the caller deadline wrapper. + expect(joined.failure?.command).toContain("ChatGPT.app"); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + } finally { timer.mockRestore(); } + }); + test("concurrent callers share one failure and a later caller retries", async () => { const started = join(root(), "failed-starts.txt"); const source = [ diff --git a/tests/codex-integration/codex-runtime-macos-desktop.test.ts b/tests/codex-integration/codex-runtime-macos-desktop.test.ts index 40f93fb3a95..764eee42643 100644 --- a/tests/codex-integration/codex-runtime-macos-desktop.test.ts +++ b/tests/codex-integration/codex-runtime-macos-desktop.test.ts @@ -19,7 +19,7 @@ const ID = "CFBundleIdentifiercom.openai.codex"; const plist = (body = ID) => `${body}`; function fixture() { - const home = mkdtempSync(join(tmpdir(), "ocx-mac-runtime-")); + const home = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mac-runtime-"))); roots.push(home); const bundle = posix.join(home.replaceAll("\\", "/"), "Applications/ChatGPT.app"); const configDir = join(home, "config"); @@ -38,6 +38,12 @@ function fixture() { const deps: ResolveCodexRuntimeDeps = { platform: "darwin", env: { HOME: home.replaceAll("\\", "/"), PATH: "" }, configDir, existsSync: path => versions.has(path), execFileSync: exec, execFile: async file => exec(file), + // Virtual executables stay injected; real fixture symlinks retain their + // canonical targets. Both trust halves are stubbed so no dscl/codesign runs. + executable: path => versions.has(path), + realpath: path => { try { return realpathSync(path); } catch { return path; } }, + macBundleTrustReason: () => null, + macBundleTrustReasonAsync: async () => null, }; const pin = (command: string, origin?: "pinned" | "discovered") => writeFileSync(codexRuntimeStatePath(configDir), JSON.stringify({ version: 1, command, source: "installed", selectedVersion: "0.150.0", updatedAt: "2026-10-13T00:00:00Z", origin, @@ -66,16 +72,28 @@ describe.skipIf(process.platform === "win32")("macOS Codex app runtime discovery expect(resolveCodexRuntime(f.deps).runtime.command).toBe(f.current); }); + test("rejects an app CLI that fails publisher or filesystem trust before probing", () => { + const f = fixture(); f.versions.set(f.current, "0.162.0"); + const checked: string[][] = []; + f.deps.macBundleTrustReason = (bundle, binary) => { checked.push([bundle, binary]); return "untrusted"; }; + expect(resolveCodexRuntime(f.deps).runtime.source).toBe("fallback"); + expect(checked).toContainEqual([f.bundle, f.current]); + expect(f.probed).not.toContain(f.current); + }); + for (const source of ["environment", "configured", "shim", "path", "installed", "fallback"] as const) { test(`${source} precedes the app during version discovery`, () => { const f = fixture(); const command = source === "fallback" ? "codex" : source === "installed" ? "/usr/local/bin/codex" : "/fixture/bin/codex"; - f.versions.set(command, "0.150.0"); f.versions.set(f.current, "0.162.0"); + f.versions.set(source === "fallback" ? "/usr/bin/codex" : command, "0.150.0"); f.versions.set(f.current, "0.162.0"); + // No enumerated PATH candidates: execvp still finds the bare fallback on its default path. + if (source === "fallback") delete f.deps.env!.PATH; if (source === "environment") f.deps.env!.CODEX_CLI_PATH = command; if (source === "configured") f.pin(command, "pinned"); if (source === "shim") writeFileSync(join(f.deps.configDir!, "codex-shim.json"), JSON.stringify({ originalPath: command })); if (source === "path") f.deps.env!.PATH = "/fixture/bin"; expect(resolveCodexRuntime(f.deps).runtime).toEqual({ command, source, version: "0.150.0" }); + if (source === "fallback") expect(f.probed).toContain("/usr/bin/codex"); }); } @@ -180,13 +198,17 @@ describe.skipIf(process.platform === "win32")("macOS Codex app runtime discovery expect(reads).toBeGreaterThan(0); }); - test("a canonical app path on PATH under a symlinked HOME never takes over a persisted selection", () => { + test("a canonical app path on PATH under a symlinked HOME never takes over a persisted selection", async () => { const f = symlinkedHome(), persisted = "/fixture/persisted/codex"; f.pin(persisted, "discovered"); f.versions.set(persisted, "0.150.0"); f.versions.set(f.canonical, "0.162.0"); f.deps.env!.PATH = posix.dirname(f.canonical); const result = resolveCodexRuntime(f.deps); expect(result.runtime.command).toBe(persisted); expect(result.supersededDiscovered).toBeUndefined(); + expect(result.replacedConfigured).toBeUndefined(); + expect(f.probed).toContain(persisted); + expect(f.probed).toContain(f.canonical); + expect(await resolveCodexRuntimeAsync(f.deps)).toEqual(result); }); test("an established install path that links into the app keeps its precedence", () => { @@ -196,11 +218,14 @@ describe.skipIf(process.platform === "win32")("macOS Codex app runtime discovery const link = posix.join(f.deps.env!.HOME!, ".local/bin/codex"); mkdirSync(posix.dirname(link), { recursive: true }); symlinkSync(f.current, link); - f.versions.set(link, "0.150.0"); f.versions.set(f.current, "0.162.0"); + // The alias and its backing executable are one binary and expose one version. + f.versions.set(link, "0.150.0"); f.versions.set(f.current, "0.150.0"); let reads = 0; f.deps.readBundleIdentifier = () => { reads++; return "com.openai.codex"; }; // The link is an established install: it precedes the app candidates and is identity-checked. expect(resolveCodexRuntime(f.deps).runtime).toEqual({ command: link, source: "installed", version: "0.150.0" }); + expect(f.probed).toContain(f.current); + expect(f.probed).not.toContain(link); expect(reads).toBeGreaterThan(0); f.deps.readBundleIdentifier = () => "com.example.other"; expect(resolveCodexRuntime(f.deps).runtime.source).toBe("fallback"); @@ -255,6 +280,11 @@ describe.skipIf(process.platform === "win32")("macOS Codex app runtime discovery expect(() => setCodexRuntimeResolveCacheForTests({ runtime: { command: "codex", version: null, source: "fallback" }, failures: [] }, { readBundleIdentifier: () => "com.openai.codex" })).toThrow("Injected runtime dependencies"); }); + + test("an injected bundle trust check cannot populate the process memo", () => { + expect(() => setCodexRuntimeResolveCacheForTests({ runtime: { command: "codex", version: null, source: "fallback" }, failures: [] }, + { macBundleTrustReason: () => null })).toThrow("Injected runtime dependencies"); + }); }); describe("bundled app catalog consumer", () => { @@ -267,7 +297,18 @@ describe("bundled app catalog consumer", () => { if (kind === "spawn-failure") throw Object.assign(new Error("not found"), { code: "ENOENT" }); return JSON.stringify({ models: kind === "null-row" ? [null] : kind === "mixed-null-row" ? [native, null] : kind === "array-row" ? [[]] : [native] }); }; - const deps = { platform: "darwin" as const, commandCandidates: () => [f.current], configDir: f.deps.configDir, execFileSync: exec, execFile: async () => exec() }; + // A POSIX-absolute non-bundle candidate: the boundary resolves it on any + // host (f.current is a Windows-flavored path this describe cannot use), + // and it classifies outside the known bundles so output handling - not + // trust - is what is exercised. Fixture versions drive exists/executable. + const command = "/fixture/bin/codex"; + f.versions.set(command, "0.162.0"); + const deps = { + platform: "darwin" as const, commandCandidates: () => [command], configDir: f.deps.configDir, + execFileSync: exec, execFile: async () => exec(), env: f.deps.env, + existsSync: f.deps.existsSync, executable: f.deps.executable, realpath: f.deps.realpath, + macBundleTrustReason: f.deps.macBundleTrustReason, macBundleTrustReasonAsync: f.deps.macBundleTrustReasonAsync, + }; const sync = loadBundledCodexCatalog(deps), asyncResult = await loadBundledCodexCatalogAsync(deps); expect(asyncResult).toEqual(sync); if (kind === "valid") expect(sync?.models[0]?.slug).toBe("gpt-test"); diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index f7dad943d19..7c1059f0c75 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -15,7 +15,8 @@ import { describe, expect, test } from "bun:test"; * qualification (#1691). */ const NO_CODEX_PATH = "/usr/bin:/bin"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { @@ -53,6 +54,16 @@ function tempConfigDir(): string { return mkdtempSync(join(tmpdir(), "ocx-runtime-")); } +function withBundledClampFixture(run: (command: string, calls: unknown[][]) => void): void { + const root = realpathSync(tempConfigDir()); + const command = join(root, process.platform === "win32" ? "codex.exe" : "codex"), calls: unknown[][] = []; + try { + writeFileSync(command, "fixture only; execution must stay injected", { mode: 0o700 }); + run(command, calls); + expect(calls).toEqual([[command, ["debug", "models", "--bundled"]]]); + } finally { removeTreeWithRetry(root); } +} + function persistedRuntimeBytes( command: string, version: string | null = "0.145.0", @@ -1033,25 +1044,27 @@ describe("resolveCodexRuntime", () => { ], default_reasoning_level: "xhigh", }]; - clampCatalogModelsToCodexSupport(models, { - commandCandidates: () => ["stub"], - execFileSync: () => JSON.stringify({ - models: [{ - slug: "gpt-5.5", - base_instructions: "x", - supported_reasoning_levels: [ - { effort: "low", description: "low" }, - { effort: "medium", description: "medium" }, - { effort: "high", description: "high" }, - ], - default_reasoning_level: "medium", - }], - }), - onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), + withBundledClampFixture((command, calls) => { + clampCatalogModelsToCodexSupport(models, { + commandCandidates: () => [command], + execFileSync: (file, args) => { calls.push([file, args]); return JSON.stringify({ + models: [{ + slug: "gpt-5.5", + base_instructions: "x", + supported_reasoning_levels: [ + { effort: "low", description: "low" }, + { effort: "medium", description: "medium" }, + { effort: "high", description: "high" }, + ], + default_reasoning_level: "medium", + }], + }); }, + onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), + }); + expect(models[0]!.default_reasoning_level).toBe("high"); + expect(diagnostics[0]?.removedEfforts).toContain("xhigh"); + expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]); }); - expect(models[0]!.default_reasoning_level).toBe("high"); - expect(diagnostics[0]?.removedEfforts).toContain("xhigh"); - expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]); }); // An exempt default only survives when the surviving ladder advertises it; an orphaned ultra @@ -1069,24 +1082,26 @@ describe("resolveCodexRuntime", () => { ], default_reasoning_level: "ultra", }]; - clampCatalogModelsToCodexSupport(models, { - commandCandidates: () => ["stub"], - execFileSync: () => JSON.stringify({ - models: [{ - slug: "gpt-5.5", - base_instructions: "x", - supported_reasoning_levels: [ - { effort: "low", description: "low" }, - { effort: "medium", description: "medium" }, - { effort: "high", description: "high" }, - ], - default_reasoning_level: "medium", - }], - }), - onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), + withBundledClampFixture((command, calls) => { + clampCatalogModelsToCodexSupport(models, { + commandCandidates: () => [command], + execFileSync: (file, args) => { calls.push([file, args]); return JSON.stringify({ + models: [{ + slug: "gpt-5.5", + base_instructions: "x", + supported_reasoning_levels: [ + { effort: "low", description: "low" }, + { effort: "medium", description: "medium" }, + { effort: "high", description: "high" }, + ], + default_reasoning_level: "medium", + }], + }); }, + onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), + }); + expect(models[0]!.default_reasoning_level).toBe("high"); + expect(diagnostics).toEqual([]); }); - expect(models[0]!.default_reasoning_level).toBe("high"); - expect(diagnostics).toEqual([]); }); }); diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 66f7525a45a..2afe52fe888 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -1703,20 +1703,17 @@ describe("cli surface", () => { expect(out).not.toContain("V1-only"); }); - test("codexFeaturesInvocation: POSIX passthrough; win32 .cmd routed through cmd.exe (devlog 260715 020)", () => { + test("codexFeaturesInvocation: Darwin resolves, Linux passes through, win32 .cmd uses cmd.exe", () => { const execFileSync = () => "codex-cli 0.145.0"; - expect(codexFeaturesInvocation("enable", "multi_agent_v2", "darwin", { - env: { PATH: "" }, - configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-posix-")), - existsSync: () => false, - execFileSync, - })).toEqual({ file: "codex", args: ["features", "enable", "multi_agent_v2"], options: {} }); - expect(codexFeaturesInvocation("enable", "default_mode_request_user_input", "darwin", { - env: { PATH: "" }, - configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-posix-")), - existsSync: () => false, - execFileSync, - })).toEqual({ file: "codex", args: ["features", "enable", "default_mode_request_user_input"], options: {} }); + for (const platform of ["darwin", "linux"] as const) for (const feature of ["multi_agent_v2", "default_mode_request_user_input"]) { + expect(codexFeaturesInvocation("enable", feature, platform, { + env: { CODEX_CLI_PATH: "codex", PATH: "/fixture/bin", HOME: "/fixture/home" }, + configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-posix-")), + existsSync: path => path === "/fixture/bin/codex", + executable: path => path === "/fixture/bin/codex", realpath: path => path, + execFileSync, + })).toEqual({ file: platform === "darwin" ? "/fixture/bin/codex" : "codex", args: ["features", "enable", feature], options: {} }); + } // Explicit CODEX_CLI_PATH pointing at a .cmd (npm-only Windows Codex install). const inv = codexFeaturesInvocation("disable", "multi_agent_v2", "win32", { env: { CODEX_CLI_PATH: "C:\\npm\\codex.cmd", ComSpec: "C:\\WINDOWS\\system32\\cmd.exe", PATH: "" }, @@ -1747,6 +1744,8 @@ describe("cli surface", () => { process.env.OPENCODEX_HOME = mkdtempSync(join(tmpdir(), "ocx-cli-config-")); const logs: string[] = []; const deps = { + // This state-transition fixture supplies both invocation and execution. + featuresInvocation: (action: "enable" | "disable") => ({ file: "fixture-codex", args: ["features", action, "multi_agent_v2"], options: {} }), execFile: (_file: string, args: string[]) => { // POSIX: ["features", "enable|disable", ...]; win32 .cmd: ["/d","/s","/c","...enable..."] const joined = args.join(" "); diff --git a/tests/codex-integration/exec-boundary-launch.test.ts b/tests/codex-integration/exec-boundary-launch.test.ts new file mode 100644 index 00000000000..3467e00a609 --- /dev/null +++ b/tests/codex-integration/exec-boundary-launch.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { constants, copyFileSync, existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; +import { + probePromptText, setPromptTextProbeRuntimeForTests, setPromptTextProbeCommandForTests, + setPromptTextProbeExecBoundaryDepsForTests, promptTextProbeSpawnAttemptsForTests, + resetPromptTextProbeForTests, +} from "../../src/codex/prompt-text-probe"; +import { resolveCodexHomeDir } from "../../src/codex/home"; +import type { CodexExecBoundaryAsyncDeps } from "../../src/codex/exec-boundary"; +import { nativeMainCodexLoginValidatedInvocation, type NativeMainCodexLoginInvocationDeps } from "../../src/cli/account-main"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void, reject!: (reason?: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +async function bounded(promise: Promise, description: string, milliseconds = 2_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(description)), milliseconds); + })]); + } finally { clearTimeout(timer); } +} + +const nextTurn = () => new Promise(resolve => setTimeout(resolve, 0)); +const bundle = "/Applications/ChatGPT.app"; +const appBinary = `${bundle}/Contents/Resources/codex-cli/bin/codex`; +const output = JSON.stringify([{ type: "message", role: "developer", content: [ + { type: "input_text", text: "execution boundary fixture" }, +] }]); +let directory: string, previousHome: string | undefined; +const releaseOnCleanup: Array<() => void> = []; + +beforeEach(async () => { + await resetPromptTextProbeForTests(); + previousHome = process.env.CODEX_HOME; + directory = mkdtempSync(join(tmpdir(), "ocx-exec-boundary-launch-")); + process.env.CODEX_HOME = directory; +}); + +afterEach(async () => { + for (const release of releaseOnCleanup.splice(0)) release(); + try { await resetPromptTextProbeForTests(); } + finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + await removeTreeWithRetry(directory); + } +}); + +function controlledTrust() { + const entered = deferred(), verification = deferred(); + let calls = 0, signal: AbortSignal | undefined; + releaseOnCleanup.push(() => verification.resolve(null)); + const check: NonNullable = async (root, binary, nextSignal) => { + calls++; + expect(root).toBe(canonicalFixtureBundle()); + expect(binary).toBe(canonicalFixtureBinary()); + signal = nextSignal; + entered.resolve(); + return verification.promise; + }; + return { entered, verification, check, get calls() { return calls; }, get signal() { return signal; } }; +} + +function canonicalFixtureBundle() { return join(directory, "Codex.app").replaceAll("\\", "/"); } +function canonicalFixtureBinary() { return canonicalFixtureBundle() + "/Contents/MacOS/bun" + (process.platform === "win32" ? ".exe" : ""); } + +function installProbe(check: NonNullable) { + // A real executable inside the canonical bundle keeps admission/lifecycle probes + // valid without letting an accepting injected verifier authorize an escaped target. + const binary = canonicalFixtureBinary(); + if (!existsSync(binary)) { + mkdirSync(join(canonicalFixtureBundle(), "Contents", "MacOS"), { recursive: true }); + copyFileSync(process.execPath, binary, constants.COPYFILE_FICLONE); + } + setPromptTextProbeRuntimeForTests({ command: appBinary, source: "installed" }); + setPromptTextProbeCommandForTests({ binary: appBinary, args: ["-e", `process.stdout.write(${JSON.stringify(output)})`] }); + const deps: CodexExecBoundaryAsyncDeps = { + platform: "darwin", env: { HOME: "/Users/boundary-fixture", PATH: "" }, + executable: candidate => candidate === appBinary, + realpath: candidate => candidate === appBinary ? binary : candidate === bundle ? canonicalFixtureBundle() : candidate, + macBundleTrustReasonAsync: check, + }; + expect(Object.hasOwn(deps, "cwd")).toBe(false); + setPromptTextProbeExecBoundaryDepsForTests(deps); +} + +describe("prompt execution boundary admission", () => { + test("two independently admitted cold callers share trust, spawn, and result", async () => { + const trust = controlledTrust(); installProbe(trust.check); + const first = probePromptText(5_000); + await bounded(trust.entered.promise, "first caller never entered trust"); + // Admit the second only after the first is suspended inside its own trust promise. + const second = probePromptText(5_000); + expect(second).not.toBe(first); + await nextTurn(); + expect(trust.calls).toBe(1); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + trust.verification.resolve(null); + const [a, b] = await bounded(Promise.all([first, second]), "shared probe did not finish", 10_000); + expect(a.ok).toBe(true); + expect(b).toEqual(a); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); + expect(trust.calls).toBe(1); + }, 15_000); + + test("overall timeout bounds the result while trust reaping retains the sole slot", async () => { + const entered = deferred(), lateVerification = deferred(), reap = deferred(), closed = deferred(); + releaseOnCleanup.push(() => { lateVerification.resolve(); reap.resolve(); }); + let calls = 0, signal: AbortSignal | undefined; + installProbe(async (_root, _binary, currentSignal) => { + calls++; signal = currentSignal; entered.resolve(); + const aborted = deferred(); + const onAbort = () => aborted.resolve(); + currentSignal?.addEventListener("abort", onAbort, { once: true }); + if (currentSignal?.aborted) onAbort(); + try { + await Promise.race([lateVerification.promise, aborted.promise]); + if (currentSignal?.aborted) { await reap.promise; throw currentSignal.reason; } + return null; + } finally { currentSignal?.removeEventListener("abort", onAbort); closed.resolve(); } + }); + const first = probePromptText(60); + await bounded(entered.promise, "timeout probe never entered trust"); + const result = await bounded(first, "trust escaped the overall prompt deadline"); + expect(result.ok).toBe(false); + expect(signal?.aborted).toBe(true); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + const blocked = await bounded(probePromptText(60), "terminating trust flight did not fail soft"); + expect(blocked.ok).toBe(false); + expect(blocked.detail).toContain("still finishing"); + expect(calls).toBe(1); + lateVerification.resolve(); await nextTurn(); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + expect((await bounded(probePromptText(60), "slot released before trust reap")).detail).toContain("still finishing"); + expect(calls).toBe(1); + reap.resolve(); await bounded(closed.promise, "trust did not finish reaping"); await nextTurn(); + installProbe(async () => null); + expect((await bounded(probePromptText(5_000), "reaped flight did not release admission", 10_000)).ok).toBe(true); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); + }, 15_000); + + test("cancelling one waiter leaves another waiter and shared trust alive", async () => { + const trust = controlledTrust(); installProbe(trust.check); + const caller = new AbortController(); + const first = probePromptText(5_000, caller.signal); + await bounded(trust.entered.promise, "first waiter never entered trust"); + const second = probePromptText(5_000); await nextTurn(); + caller.abort(); + expect((await bounded(first, "cancelled waiter remained blocked")).detail).toContain("cancelled"); + expect(trust.signal).toBeDefined(); + expect(trust.signal?.aborted).toBe(false); + expect(trust.calls).toBe(1); + trust.verification.resolve(null); + expect((await bounded(second, "remaining waiter was cancelled", 10_000)).ok).toBe(true); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); + }, 15_000); + + test("the final waiter cancellation aborts shared trust and starts no child", async () => { + const entered = deferred(), aborted = deferred(); + let sharedSignal: AbortSignal | undefined, calls = 0; + installProbe(async (_root, _binary, signal) => { + calls++; sharedSignal = signal; entered.resolve(); + return new Promise((_resolve, reject) => { + const stop = () => { aborted.resolve(); reject(signal?.reason ?? new Error("aborted")); }; + signal?.addEventListener("abort", stop, { once: true }); + if (signal?.aborted) stop(); + }); + }); + const a = new AbortController(), b = new AbortController(); + const first = probePromptText(5_000, a.signal); + await bounded(entered.promise, "shared trust did not start"); + const second = probePromptText(5_000, b.signal); await nextTurn(); + a.abort(); await bounded(first, "first cancelled caller did not finish"); + expect(sharedSignal?.aborted).toBe(false); + b.abort(); + expect((await bounded(second, "final cancelled caller did not finish")).ok).toBe(false); + await bounded(aborted.promise, "final waiter did not abort shared controller"); + expect(sharedSignal?.aborted).toBe(true); + expect(calls).toBe(1); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + }); +}); + +describe("prompt execution boundary cwd", () => { + for (const command of ["./relative-codex", "codex"]) test(`${command} resolves from the actual spawn cwd with empty PATH`, async () => { + const observed: string[] = []; + setPromptTextProbeRuntimeForTests({ command, source: "installed" }); + const deps: CodexExecBoundaryAsyncDeps = { + platform: "darwin", env: { HOME: "/Users/cwd-fixture", PATH: "" }, + executable: target => { observed.push(target); return false; }, exists: () => false, + }; + expect(Object.hasOwn(deps, "cwd")).toBe(false); + setPromptTextProbeExecBoundaryDepsForTests(deps); + const home = resolveCodexHomeDir(); + const result = await bounded(probePromptText(2_000), "cwd probe did not return ENOENT"); + expect(result.ok).toBe(false); + expect(result.failure?.kind).toBe("program-not-found"); + const expected = command.includes("/") ? posix.resolve(home, command) : `${home}/${command}`; + expect(observed).toEqual([expected]); + expect(home).not.toBe(process.cwd()); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + }); +}); + +describe("validated native login invocation", () => { + test("Windows .cmd login retains ComSpec dispatch and verbatim argument options", async () => { + const command = "C:/fake/codex.cmd", comspec = "C:/Windows/System32/cmd.exe"; + const configDir = join(directory, "config"); mkdirSync(configDir); + const probed: string[] = []; + const exists = (path: string) => path.replaceAll("\\", "/").toLowerCase() === command.toLowerCase(); + const deps: NativeMainCodexLoginInvocationDeps & CodexExecBoundaryAsyncDeps = { + platform: "win32", env: { CODEX_CLI_PATH: command, ComSpec: comspec, PATH: "", PATHEXT: ".CMD;.EXE" }, + configDir, existsSync: exists, exists, + readFileSync: () => { throw Object.assign(new Error("missing fixture state"), { code: "ENOENT" }); }, + execFileSync: file => { probed.push(file); return "codex-cli 0.162.0"; }, + }; + const invocation = await nativeMainCodexLoginValidatedInvocation("win32", deps); + expect(invocation.file).toBe(comspec); + expect(invocation.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(invocation.args[3]).toContain(command); + expect(invocation.args[3]).toContain("login"); + expect(invocation.options.windowsVerbatimArguments).toBe(true); + expect(probed.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/codex-integration/exec-known-app-bundles.test.ts b/tests/codex-integration/exec-known-app-bundles.test.ts new file mode 100644 index 00000000000..be39454388e --- /dev/null +++ b/tests/codex-integration/exec-known-app-bundles.test.ts @@ -0,0 +1,193 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + CodexExecTrustError, validatedCodexExecInvocation, validatedCodexExecInvocationAsync, + type CodexExecBoundaryDeps, +} from "../../src/codex/exec-boundary"; +import { codexRuntimeStatePath, resolveCodexRuntime, resolveCodexRuntimeAsync } from "../../src/codex/runtime"; +import { loadBundledCodexCatalog, loadBundledCodexCatalogAsync } from "../../src/codex/catalog/bundled"; +import { + OPENAI_TEAM_ID, untrustedChatgptBundleReason, untrustedChatgptBundleReasonAsync, + type BundleTrustDeps, +} from "../../src/chatgpt/app-server-shim/bundle-trust"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await removeTreeWithRetry(root); }); + +const apps = [ + ["/Applications/Codex.app", "/Contents/Resources/codex-aarch64-apple-darwin"], + ["/opt/relocated/Codex.app", "/Contents/Resources/codex-x86_64-apple-darwin"], + ["/opt/relocated/ChatGPT.app", "/Contents/Resources/codex-cli/bin/codex"], +] as const; + +function fixture(bundle: string, layout: string, alias: boolean, refused: boolean) { + const binary = bundle + layout, command = alias ? "/tools/codex-link" : binary; + const syncCalls: string[][] = [], asyncCalls: string[][] = [], spawned: string[] = []; + const env: NodeJS.ProcessEnv = { HOME: "/Users/fixture", PATH: "" }; + const common = { + platform: "darwin" as const, env, + realpath: (path: string) => path === command ? binary : path, + executable: (path: string) => path === command || path === binary, + macBundleTrustReason: (root: string, target: string) => { + syncCalls.push([root, target]); return refused ? "fixture refused" : null; + }, + macBundleTrustReasonAsync: async (root: string, target: string) => { + asyncCalls.push([root, target]); return refused ? "fixture refused" : null; + }, + }; + const boundary = { ...common, exists: common.executable }; + const configDir = mkdtempSync(join(tmpdir(), "ocx-known-app-")); + roots.push(configDir); + const runtime = { + ...common, configDir, existsSync: common.executable, + readBundleIdentifier: () => "com.openai.codex", + execFileSync: (file: string) => { spawned.push(file); return "codex-cli 0.160.0"; }, + execFile: async (file: string) => { spawned.push(file); return "codex-cli 0.160.0"; }, + }; + const assertChecks = (asyncMode: boolean) => { + expect(asyncMode ? asyncCalls : syncCalls).toEqual([[bundle, binary]]); + expect(asyncMode ? syncCalls : asyncCalls).toEqual([]); + }; + return { binary, command, boundary, runtime, spawned, assertChecks }; +} + +// Injected Darwin, executable and realpath answers exercise every case on Windows too. +for (const [bundle, layout] of apps) for (const alias of [false, true]) { + for (const asyncMode of [false, true]) for (const refused of [true, false]) { + const label = `${bundle}, alias=${alias}, async=${asyncMode}, refused=${refused}`; + test(`known app exec: ${label}`, async () => { + const f = fixture(bundle, layout, alias, refused); + const invoke = () => asyncMode + ? validatedCodexExecInvocationAsync(f.command, ["app-server"], f.boundary) + : validatedCodexExecInvocation(f.command, ["app-server"], f.boundary); + if (refused) { + if (asyncMode) await expect(invoke()).rejects.toThrow(CodexExecTrustError); + else expect(invoke).toThrow(CodexExecTrustError); + } else { + expect(await invoke()).toEqual({ file: f.binary, args: ["app-server"], options: {} }); + } + f.assertChecks(asyncMode); + }); + + for (const source of ["environment", "configured"] as const) { + test(`known app runtime ${source}: ${label}`, async () => { + const f = fixture(bundle, layout, alias, refused); + if (source === "environment") f.runtime.env.CODEX_CLI_PATH = f.command; + else writeFileSync(codexRuntimeStatePath(f.runtime.configDir), JSON.stringify({ + version: 1, command: f.command, source: "installed", origin: "pinned", + selectedVersion: "0.160.0", updatedAt: "2026-10-26T00:00:00Z", + })); + const result = asyncMode ? await resolveCodexRuntimeAsync(f.runtime) : resolveCodexRuntime(f.runtime); + f.assertChecks(asyncMode); + if (refused) { + expect(result.failures).toContainEqual(expect.objectContaining({ + command: f.command, source, reason: expect.stringContaining("untrusted Codex exec target"), + })); + expect(f.spawned).toEqual([]); + expect(result.runtime.source).toBe("fallback"); + } else { + expect(result.runtime).toEqual({ command: f.command, source, version: "0.160.0" }); + expect(f.spawned).toEqual([f.binary]); + } + }); + } + + test(`known app catalog consumer: ${label}`, async () => { + const f = fixture(bundle, layout, alias, refused); + const exec = (file: string) => { + f.spawned.push(file); + return JSON.stringify({ models: [{ slug: "gpt-test", base_instructions: "Fixture instructions." }] }); + }; + const deps = { ...f.runtime, commandCandidates: () => [f.command], + execFileSync: exec, execFile: async (file: string) => exec(file) }; + const result = asyncMode ? await loadBundledCodexCatalogAsync(deps) : loadBundledCodexCatalog(deps); + f.assertChecks(asyncMode); + expect(f.spawned).toEqual(refused ? [] : [f.binary]); + if (refused) expect(result).toBeNull(); + else expect(result?.models[0]?.slug).toBe("gpt-test"); + }); + } +} + +for (const asyncMode of [false, true]) { + for (const name of ["Codex", "ChatGPT"]) test(`filesystem-root app: ${name}, async=${asyncMode}`, async () => { + const f = fixture(`/${name}.app`, "/Contents/Resources/codex", false, true); + if (asyncMode) await expect(validatedCodexExecInvocationAsync(f.command, [], f.boundary)).rejects.toThrow(CodexExecTrustError); + else expect(() => validatedCodexExecInvocation(f.command, [], f.boundary)).toThrow(CodexExecTrustError); + f.assertChecks(asyncMode); + }); + for (const [bundle, layout] of apps) for (const missing of ["binary", "bundle", "escape", "outside Contents"] as const) { + test(`known app fails closed: ${bundle}, ${missing}, async=${asyncMode}`, async () => { + const binary = missing === "outside Contents" ? bundle + "/codex" : bundle + layout; + let checks = 0; + const deps = { + platform: "darwin" as const, executable: () => true, + realpath: (path: string) => { + if (missing === "binary" && path === binary || missing === "bundle" && path === bundle) return null; + return missing === "escape" && path === binary ? "/outside/codex" : path; + }, + macBundleTrustReason: () => { checks++; return null; }, + macBundleTrustReasonAsync: async () => { checks++; return null; }, + }; + if (asyncMode) await expect(validatedCodexExecInvocationAsync(binary, [], deps)).rejects.toThrow(CodexExecTrustError); + else expect(() => validatedCodexExecInvocation(binary, [], deps)).toThrow(CodexExecTrustError); + expect(checks).toBe(0); + }); + } + + test(`arbitrary CLI and third-party apps preserve passthrough, async=${asyncMode}`, async () => { + let checks = 0; + const deps = { platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + macBundleTrustReason: () => { checks++; return "refused"; }, + macBundleTrustReasonAsync: async () => { checks++; return "refused"; } }; + for (const binary of ["/opt/bin/codex", "/opt/Other.app/Contents/codex", "/opt/Codex.app-copy/Contents/codex"]) { + const invocation = asyncMode ? await validatedCodexExecInvocationAsync(binary, [], deps) + : validatedCodexExecInvocation(binary, [], deps); + expect(invocation.file).toBe(binary); + } + expect(checks).toBe(0); + }); + + test(`non-Darwin known-app spelling preserves passthrough, async=${asyncMode}`, async () => { + const binary = apps[0][0] + apps[0][1]; + const deps: CodexExecBoundaryDeps = { platform: "linux", macBundleTrustReason: () => { throw new Error("unexpected verifier"); } }; + const invocation = asyncMode ? await validatedCodexExecInvocationAsync(binary, [], deps) + : validatedCodexExecInvocation(binary, [], deps); + expect(invocation.file).toBe(binary); + }); +} + +// Exercise the actual shared verifier for both layouts, without native codesign processes. +for (const [name, layout] of [["Codex", "Resources/codex-aarch64-apple-darwin"], ["ChatGPT", "Resources/codex-cli/bin/codex"]]) { + for (const defect of ["none", "signature", "team", "owner", "mode", "ancestor", "symlink", "outside Contents"]) { + test(`shared sync/async verifier: ${name}, ${defect}`, async () => { + const root = resolve(`/opt/relocated/${name}.app`); + const binary = join(root, defect === "outside Contents" ? "codex" : `Contents/${layout}`); + const syncCalls: string[][] = [], asyncCalls: string[][] = []; + const reply: BundleTrustDeps["codesign"] = () => ({ status: defect === "signature" ? 1 : 0, + output: `TeamIdentifier=${defect === "team" ? "OTHERTEAM" : OPENAI_TEAM_ID}\n` }); + const deps: BundleTrustDeps = { + uid: 501, + stat: path => ({ uid: defect === "owner" && path === binary ? 502 : 0, + mode: defect === "mode" && path === binary || defect === "ancestor" && path === dirname(root) ? 0o777 : 0o755, + isFile: path === binary, isDirectory: path !== binary, + isSymbolicLink: defect === "symlink" && path === binary }), + codesign: args => { syncCalls.push([...args]); return reply(args); }, + }; + const syncReason = untrustedChatgptBundleReason(root, binary, deps); + const asyncReason = await untrustedChatgptBundleReasonAsync(root, binary, { ...deps, codesign: async args => { + asyncCalls.push([...args]); return reply(args); + } }); + expect(asyncReason).toBe(syncReason); + expect(asyncCalls).toEqual(syncCalls); + if (defect === "none") { + expect(syncReason).toBeNull(); + expect(syncCalls).toEqual([["--verify", "--strict", root], ["-dv", "--verbose=2", root], + ["--verify", "--strict", binary], ["-dv", "--verbose=2", binary]]); + } else expect(syncReason).not.toBeNull(); + }); + } +} diff --git a/tests/codex-integration/exec-trust-boundary.test.ts b/tests/codex-integration/exec-trust-boundary.test.ts new file mode 100644 index 00000000000..c62b85956ee --- /dev/null +++ b/tests/codex-integration/exec-trust-boundary.test.ts @@ -0,0 +1,354 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CodexExecTrustError, + assertCodexExecTargetTrustedAsync, + validatedCodexExecInvocation, + validatedCodexExecInvocationAsync, + type CodexExecBoundaryAsyncDeps, + type CodexExecBoundaryDeps, +} from "../../src/codex/exec-boundary"; +import { codexRuntimeStatePath, resolveCodexRuntime, resolveCodexRuntimeAsync, type ResolveCodexRuntimeDeps } from "../../src/codex/runtime"; +import { loadBundledCodexCatalog, loadBundledCodexCatalogAsync } from "../../src/codex/catalog/bundled"; +import { codexFeaturesInvocation } from "../../src/cli/v2"; +import { + probePromptText, + promptTextProbeSpawnAttemptsForTests, + resetPromptTextProbeForTests, + setPromptTextProbeExecBoundaryDepsForTests, + setPromptTextProbeRuntimeForTests, +} from "../../src/codex/prompt-text-probe"; +import { CodexRemoteWorkspaceRuntimeFactory } from "../../src/remote-control/workspace-codex-runtime"; +import type { RemoteWorkspaceCoordinator } from "../../src/remote-control/workspace-coordinator"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Every check is dependency-injected: platform "darwin" with fake PATH/executable/ +// realpath/trust answers, so the boundary contract is exercised on any host OS. +const HOME = "/home/tester"; +const BUNDLE = HOME + "/Applications/ChatGPT.app"; +const APP_BIN = BUNDLE + "/Contents/Resources/codex-cli/bin/codex"; +const THIRD_PARTY_APP_BIN = "/opt/Other.app/Contents/Resources/codex"; + +const roots: string[] = []; +afterEach(async () => { + await resetPromptTextProbeForTests(); + for (const path of roots.splice(0)) await removeTreeWithRetry(path); +}); + +function harness() { + const files = new Set(); + const runnable = new Set(); + const links = new Map(); + const unresolvable = new Set(); + const spawned: string[] = []; + const syncCalls: string[][] = []; + const asyncCalls: string[][] = []; + const env: NodeJS.ProcessEnv = { HOME, PATH: "" }; + const state = { trustReason: null as string | null }; + const configDir = mkdtempSync(join(tmpdir(), "ocx-exec-boundary-")); + roots.push(configDir); + const realpath = (path: string) => unresolvable.has(path) ? null : (links.get(path) ?? path); + const trustSync = (bundle: string, binary: string) => { syncCalls.push([bundle, binary]); return state.trustReason; }; + const trustAsync = async (bundle: string, binary: string) => { asyncCalls.push([bundle, binary]); return state.trustReason; }; + const boundary: CodexExecBoundaryAsyncDeps = { + env, platform: "darwin", + exists: path => files.has(path), + executable: path => runnable.has(path), + realpath, + macBundleTrustReason: trustSync, + macBundleTrustReasonAsync: trustAsync, + }; + const resolve: ResolveCodexRuntimeDeps = { + env, platform: "darwin", configDir, + existsSync: path => files.has(path), + executable: path => runnable.has(path), + realpath, + readBundleIdentifier: () => "com.openai.codex", + macBundleTrustReason: trustSync, + macBundleTrustReasonAsync: trustAsync, + execFileSync: file => { spawned.push(file); return "codex-cli 0.160.0"; }, + execFile: async file => { spawned.push(file); return "codex-cli 0.160.0"; }, + }; + const addApp = () => { files.add(APP_BIN); runnable.add(APP_BIN); }; + return { files, runnable, links, unresolvable, spawned, syncCalls, asyncCalls, env, state, boundary, resolve, addApp }; +} + +describe("macOS Codex exec boundary", () => { + test("an untrusted app-bundle candidate is refused at exec and never probed", () => { + const h = harness(); h.addApp(); h.state.trustReason = "untrusted"; + const result = resolveCodexRuntime(h.resolve); + expect(result.runtime.source).toBe("fallback"); + expect(h.syncCalls).toEqual([[BUNDLE, APP_BIN]]); + expect(h.asyncCalls).toEqual([]); + expect(h.spawned).not.toContain(APP_BIN); + expect(result.failures.some(failure => failure.reason.includes("untrusted Codex exec target"))).toBe(true); + }); + + test("a trusted app-bundle candidate executes its canonical binary", () => { + const h = harness(); h.addApp(); + const result = resolveCodexRuntime(h.resolve); + expect(result.runtime).toEqual({ command: APP_BIN, source: "installed", version: "0.160.0" }); + expect(h.syncCalls).toEqual([[BUNDLE, APP_BIN]]); + expect(h.spawned).toEqual([APP_BIN]); + }); + + test("async resolution uses only the async trust check and forwards the caller signal", async () => { + const h = harness(); h.addApp(); h.state.trustReason = "untrusted"; + const controller = new AbortController(); + let seen: AbortSignal | undefined; + h.resolve.macBundleTrustReasonAsync = async (bundle, binary, signal) => { seen = signal; return "untrusted"; }; + const result = await resolveCodexRuntimeAsync({ ...h.resolve, signal: controller.signal }); + expect(result.runtime.source).toBe("fallback"); + expect(h.syncCalls).toEqual([]); + expect(seen).toBe(controller.signal); + expect(h.spawned).not.toContain(APP_BIN); + }); + + test("a deferred selection stays exec-free and performs no trust subprocess", () => { + const h = harness(); h.addApp(); + const result = resolveCodexRuntime({ ...h.resolve, probeVersion: false, discoverAlternatives: false }); + expect(result.runtime).toEqual({ command: APP_BIN, source: "installed", version: null }); + expect(h.syncCalls).toEqual([]); + expect(h.spawned).toEqual([]); + }); + + test("a selected command is re-verified at the next exec, not trusted by the earlier probe", () => { + const h = harness(); h.addApp(); + expect(resolveCodexRuntime(h.resolve).runtime.command).toBe(APP_BIN); + h.state.trustReason = "signature invalid after replacement"; + expect(() => validatedCodexExecInvocation(APP_BIN, ["--version"], h.boundary)).toThrow(CodexExecTrustError); + expect(h.syncCalls.length).toBeGreaterThan(1); + }); + + test("a PATH alias inside a known bundle is bound to the canonical target it executes", () => { + const h = harness(); h.addApp(); + h.files.add("/fake/bin/codex"); h.runnable.add("/fake/bin/codex"); h.links.set("/fake/bin/codex", APP_BIN); + const invocation = validatedCodexExecInvocation("/fake/bin/codex", ["--version"], h.boundary); + expect(invocation.file).toBe(APP_BIN); + expect(h.syncCalls).toEqual([[BUNDLE, APP_BIN]]); + }); + + for (const asyncMode of [false, true]) for (const bundled of [false, true]) { + test(`bare fallback keeps its executed bundle classification (async=${asyncMode}, bundled=${bundled})`, async () => { + const h = harness(), persisted = "/fixture/persisted/codex", fallback = "/usr/bin/codex"; + // No PATH enumeration: the bare fallback resolves through Darwin's default execvp path. + delete h.env.PATH; + for (const file of [persisted, fallback]) { h.files.add(file); h.runnable.add(file); } + if (bundled) { h.addApp(); h.links.set(fallback, APP_BIN); } + writeFileSync(codexRuntimeStatePath(h.resolve.configDir!), JSON.stringify({ + version: 1, command: persisted, source: "installed", origin: "discovered", + selectedVersion: "0.150.0", updatedAt: "2026-10-13T00:00:00Z", + })); + const exec = (file: string) => { + h.spawned.push(file); + if (file === persisted) return "codex-cli 0.150.0"; + expect(file).toBe(bundled ? APP_BIN : fallback); + // Selection must use this execution's evidence, not resolve the changed PATH again. + h.env.PATH = "/after/probe"; + return "codex-cli 0.162.0"; + }; + const deps = { ...h.resolve, execFileSync: exec, execFile: async (file: string) => exec(file) }; + const result = asyncMode ? await resolveCodexRuntimeAsync(deps) : resolveCodexRuntime(deps); + expect(result.runtime).toEqual(bundled + ? { command: persisted, source: "configured", version: "0.150.0" } + : { command: "codex", source: "fallback", version: "0.162.0" }); + expect(result.replacedConfigured).toBeUndefined(); + if (bundled) expect(result.supersededDiscovered).toBeUndefined(); + else expect(result.supersededDiscovered?.to).toEqual(result.runtime); + expect(h.spawned).toContain(persisted); + expect(h.spawned).toContain(bundled ? APP_BIN : fallback); + expect(asyncMode ? h.syncCalls : h.asyncCalls).toEqual([]); + if (bundled) expect(asyncMode ? h.asyncCalls : h.syncCalls).toContainEqual([BUNDLE, APP_BIN]); + }); + } + + test("a bundle member that cannot be canonicalized refuses instead of escaping the check", () => { + const h = harness(); h.addApp(); h.unresolvable.add(APP_BIN); + expect(() => validatedCodexExecInvocation(APP_BIN, [], h.boundary)).toThrow(CodexExecTrustError); + expect(h.syncCalls).toEqual([]); + }); + + test("a binary inside a third-party app bundle is outside the known-bundle policy", () => { + const h = harness(); + h.files.add(THIRD_PARTY_APP_BIN); h.runnable.add(THIRD_PARTY_APP_BIN); + expect(validatedCodexExecInvocation(THIRD_PARTY_APP_BIN, [], h.boundary).file).toBe(THIRD_PARTY_APP_BIN); + expect(h.syncCalls).toEqual([]); + }); + + test("an ordinary non-app PATH binary needs no trust check", () => { + const h = harness(); + h.files.add("/usr/local/bin/codex"); h.runnable.add("/usr/local/bin/codex"); h.env.PATH = "/usr/local/bin"; + expect(validatedCodexExecInvocation("codex", ["--version"], h.boundary).file).toBe("/usr/local/bin/codex"); + expect(h.syncCalls).toEqual([]); + }); + + test("an empty PATH segment resolves against the spawn working directory", () => { + const h = harness(); + h.files.add("/work/dir/codex"); h.runnable.add("/work/dir/codex"); + h.env.PATH = ""; + const invocation = validatedCodexExecInvocation("codex", [], { ...h.boundary, cwd: "/work/dir" }); + expect(invocation.file).toBe("/work/dir/codex"); + }); + + test("a relative PATH segment resolves against the spawn working directory", () => { + const h = harness(); + h.files.add("/work/dir/tools/codex"); h.runnable.add("/work/dir/tools/codex"); + h.env.PATH = "tools:/none"; + const invocation = validatedCodexExecInvocation("codex", [], { ...h.boundary, cwd: "/work/dir" }); + expect(invocation.file).toBe("/work/dir/tools/codex"); + }); + + test("a slash-bearing command resolves relative to cwd and skips PATH entirely", () => { + const h = harness(); + h.files.add("/work/dir/tool"); h.runnable.add("/work/dir/tool"); + h.env.PATH = "/elsewhere"; + const invocation = validatedCodexExecInvocation("./tool", [], { ...h.boundary, cwd: "/work/dir" }); + expect(invocation.file).toBe("/work/dir/tool"); + }); + + test("a non-executable first PATH entry cannot shadow an executable sibling", () => { + const h = harness(); + h.files.add("/first/codex"); h.files.add("/second/codex"); h.runnable.add("/second/codex"); + h.env.PATH = "/first:/second"; + expect(validatedCodexExecInvocation("codex", [], h.boundary).file).toBe("/second/codex"); + }); + + test("a PATH-free env falls back to the execvp default directories", () => { + const h = harness(); + delete h.env.PATH; + h.files.add("/usr/bin/codex"); h.runnable.add("/usr/bin/codex"); + expect(validatedCodexExecInvocation("codex", [], h.boundary).file).toBe("/usr/bin/codex"); + }); + + test("an unresolvable bare command refuses ENOENT and a non-executable one EACCES", () => { + const h = harness(); + h.env.PATH = "/empty"; + expect(() => validatedCodexExecInvocation("codex", [], h.boundary)).toThrowError(/ENOENT/); + h.files.add("/deny/codex"); h.env.PATH = "/deny"; + expect(() => validatedCodexExecInvocation("codex", [], h.boundary)).toThrowError(/EACCES/); + }); + + test("the async invocation honours a pre-aborted signal and surfaces injected refusals", async () => { + const h = harness(); h.addApp(); h.state.trustReason = "untrusted"; + const controller = new AbortController(); controller.abort(); + await expect(assertCodexExecTargetTrustedAsync(APP_BIN, { ...h.boundary, signal: controller.signal })).rejects.toThrow(); + await expect(assertCodexExecTargetTrustedAsync(APP_BIN, h.boundary)).rejects.toThrow(CodexExecTrustError); + expect(h.asyncCalls).toEqual([[BUNDLE, APP_BIN]]); + }); + + for (const asyncMode of [false, true]) { + test(`an untrusted probe failure reason is redacted and bounded (async=${asyncMode})`, async () => { + const h = harness(); h.addApp(); + h.state.trustReason = "signature mismatch under /home/tester/Applications/ChatGPT.app " + + "with password=hunter2 and sk-livetoken1234567 " + "x".repeat(300); + const result = asyncMode ? await resolveCodexRuntimeAsync(h.resolve) : resolveCodexRuntime(h.resolve); + const reason = result.failures.find(failure => failure.reason.includes("untrusted Codex exec target"))?.reason; + expect(reason).toContain("untrusted Codex exec target"); + expect(reason).not.toContain("/home/tester"); + expect(reason).not.toContain("hunter2"); + expect(reason).not.toContain("sk-livetoken1234567"); + expect(reason?.length).toBeLessThanOrEqual(160); + }); + } +}); + +describe("exec boundary consumers", () => { + const native = { slug: "gpt-test", base_instructions: "Fixture instructions." }; + + test("the bundled catalog skips an untrusted app candidate and uses the next command", async () => { + const h = harness(); h.addApp(); h.state.trustReason = "untrusted"; + h.files.add("/fake/bin/codex"); h.runnable.add("/fake/bin/codex"); + const deps = { + env: h.env, + platform: "darwin" as const, + commandCandidates: () => [APP_BIN, "/fake/bin/codex"], + existsSync: (path: string) => h.files.has(path), + executable: (path: string) => h.runnable.has(path), + realpath: (path: string) => path, + macBundleTrustReason: (bundle: string, binary: string) => { h.syncCalls.push([bundle, binary]); return h.state.trustReason; }, + macBundleTrustReasonAsync: async (bundle: string, binary: string) => { h.asyncCalls.push([bundle, binary]); return h.state.trustReason; }, + execFileSync: (file: string) => { h.spawned.push(file); return JSON.stringify({ models: [native] }); }, + execFile: async (file: string) => { h.spawned.push(file); return JSON.stringify({ models: [native] }); }, + configDir: join(mkdtempSync(join(tmpdir(), "ocx-exec-catalog-"))), + }; + roots.push(deps.configDir); + const sync = loadBundledCodexCatalog(deps); + expect(sync?.models[0]?.slug).toBe("gpt-test"); + const asyncResult = await loadBundledCodexCatalogAsync(deps); + expect(asyncResult?.models[0]?.slug).toBe("gpt-test"); + expect(h.spawned).toEqual(["/fake/bin/codex", "/fake/bin/codex"]); + expect(h.syncCalls).toEqual([[BUNDLE, APP_BIN]]); + expect(h.asyncCalls).toEqual([[BUNDLE, APP_BIN]]); + }); + + test("codex features invocation throws at the boundary even after the probe trusted the selection", () => { + const h = harness(); h.addApp(); h.env.CODEX_CLI_PATH = APP_BIN; + let calls = 0; + const deps = { + env: h.env, + configDir: h.resolve.configDir, + existsSync: (path: string) => h.files.has(path), + execFileSync: (file: string) => { h.spawned.push(file); return "codex-cli 0.160.0"; }, + readBundleIdentifier: () => "com.openai.codex", + executable: (path: string) => h.runnable.has(path), + realpath: (path: string) => path, + macBundleTrustReason: (bundle: string, binary: string) => { calls++; h.syncCalls.push([bundle, binary]); return calls > 1 ? "replaced" : null; }, + }; + expect(() => codexFeaturesInvocation("enable", "multi_agent_v2", "darwin", deps)).toThrow(CodexExecTrustError); + }); + + test("the prompt probe rejects an untrusted target without ever spawning", async () => { + const h = harness(); h.addApp(); h.state.trustReason = "untrusted"; + setPromptTextProbeRuntimeForTests({ command: APP_BIN, source: "installed" }); + setPromptTextProbeExecBoundaryDepsForTests(h.boundary); + const result = await probePromptText(5_000); + expect(result.ok).toBe(false); + expect(result.failure?.kind).toBe("execution-failed"); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + }); + + test("an aborted prompt-probe validation cannot spawn either", async () => { + const h = harness(); h.addApp(); + setPromptTextProbeRuntimeForTests({ command: APP_BIN, source: "installed" }); + setPromptTextProbeExecBoundaryDepsForTests({ + ...h.boundary, + // Signal-honoring stub: stays pending until the forwarded signal aborts, + // then settles - the boundary awaits it rather than abandoning it. + macBundleTrustReasonAsync: (_bundle, _binary, signal) => new Promise((_, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + }); + const controller = new AbortController(); + const pending = probePromptText(5_000, controller.signal); + controller.abort(); + const result = await pending; + expect(result.ok).toBe(false); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(0); + }); + + test("a remote workspace refuses an untrusted Codex executable before any child starts", async () => { + const h = harness(); h.addApp(); h.state.trustReason = "untrusted"; + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + // Workspace prepares its native helper/isolation before taking the launch + // gate. Use a real native binary there, then map its trust check to the app. + command: [process.execPath], + execDeps: { + ...h.boundary, + cwd: "/fixture", // Keep injected macOS PATH semantics independent of the host cwd. + executable: () => true, + realpath: path => path === BUNDLE ? BUNDLE : APP_BIN, + }, + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { throw new Error("unexpected untrusted spawn"); }); + try { + await expect(factory.start({ + sessionId: "s", deviceId: "d", deviceName: "d", rootId: "r", rootLabel: "r", + capabilities: ["workspace.read"], tools: ["read_file"], coordinator: {} as RemoteWorkspaceCoordinator, emit: () => {}, + })).rejects.toThrow(CodexExecTrustError); + expect(h.asyncCalls).toEqual([[BUNDLE, APP_BIN]]); + expect(spawn).toHaveBeenCalledTimes(0); + } finally { spawn.mockRestore(); } + }); +}); diff --git a/tests/codex-integration/multi-agent-keep-native-v1.test.ts b/tests/codex-integration/multi-agent-keep-native-v1.test.ts index b681b76ed10..4b1abceabee 100644 --- a/tests/codex-integration/multi-agent-keep-native-v1.test.ts +++ b/tests/codex-integration/multi-agent-keep-native-v1.test.ts @@ -265,6 +265,8 @@ describe("ocx v2 keep-native-v1", () => { const events: string[] = []; const code = await cmdV2(["keep-native-v1", "on"], { + // This state-transition fixture supplies both invocation and execution. + featuresInvocation: action => ({ file: "fixture-codex", args: ["features", action, "multi_agent_v2"], options: {} }), execFile: (_file, args) => { events.push(featureActionOf(args)); writeFileSync(codexConfig, readFileSync(codexConfig, "utf8").replace("enabled = true", "enabled = false")); @@ -304,6 +306,7 @@ describe("ocx v2 keep-native-v1", () => { const actions: string[] = []; expect(await cmdV2(["mode", "v2"], { + featuresInvocation: action => ({ file: "fixture-codex", args: ["features", action, "multi_agent_v2"], options: {} }), execFile: (_file, args) => { actions.push(featureActionOf(args).split(" ")[1]!); writeFileSync(codexConfig, readFileSync(codexConfig, "utf8").replace("enabled = true", "enabled = false")); diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index b64d441cf74..6f2868e2558 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -36,6 +36,7 @@ import { withNativeMainSharedClaim } from "../../src/codex/native-main-claim"; import { resolveNativeProfileContext } from "../../src/codex/native-profile-store"; import type { OcxConfig } from "../../src/types"; import type { ResolveCodexRuntimeResult } from "../../src/codex/runtime"; +import type { CodexExecBoundaryAsyncDeps } from "../../src/codex/exec-boundary"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -224,6 +225,55 @@ describe.skipIf(process.platform === "win32")("codex app-server getAuthStatus cl }); }); +describe("codex app-server exec boundary abort", () => { + test("an abort during the deferred bundle trust check rethrows the signal reason", async () => { + const controller = new AbortController(); + const reason = new Error("refresh aborted while the trust check was pending"); + let enteredResolve!: () => void; + const entered = new Promise(resolvePromise => { enteredResolve = resolvePromise; }); + let forwardedSignal: AbortSignal | undefined; + let trustChecks = 0; + let rejectTrust = (_reason: unknown) => {}; + // The deferred check honors the forwarded signal and settles with its reason; the + // validation catch must rethrow that exact reason, not wrap it in CodexAuthStatusError. + const execDeps: CodexExecBoundaryAsyncDeps = { + platform: "darwin", + env: { HOME: "/home/tester", PATH: "" }, + exists: () => true, + executable: () => true, + realpath: path => path, + macBundleTrustReasonAsync: (_bundle, _binary, signal) => new Promise((_resolve, reject) => { + trustChecks += 1; + forwardedSignal = signal; + const stop = () => rejectTrust(signal?.reason); + rejectTrust = cause => { + signal?.removeEventListener("abort", stop); + reject(cause); + }; + signal?.addEventListener("abort", stop, { once: true }); + enteredResolve(); + if (signal?.aborted) stop(); + }), + }; + const pending = requestCodexAuthStatus( + "/home/tester/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex", + { refresh: true, signal: controller.signal, execDeps }, + ); + try { + await Promise.race([entered, pending.then(() => { throw new Error("auth status settled before entering trust validation"); })]); + expect(forwardedSignal).toBe(controller.signal); + expect(trustChecks).toBe(1); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(trustChecks).toBe(1); + } finally { + controller.abort(reason); + rejectTrust(reason); + await pending.catch(() => {}); + } + }); +}); + describe("main account in codex refresh mode (#2999)", () => { function seam(answer: () => CodexAuthStatus | Promise) { const calls: { refresh: boolean }[] = []; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 56954974040..14a05b0b02c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -201,6 +201,7 @@ "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", "cli-combo-partial-update.test.ts": "cli", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", + "desktop-bundle-trust-async.test.ts": "clients", "owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers", "usage-partly-measured-cost.test.ts": "usage", "claude-gateway-output-cap.test.ts": "claude-integration", @@ -566,6 +567,9 @@ "codex-entitlement-identity-read-fence.test.ts": "codex-integration", "codex-envkey-admission-substitution.test.ts": "codex-integration", "codex-exec-invocation.test.ts": "codex-integration", + "exec-trust-boundary.test.ts": "codex-integration", + "exec-boundary-launch.test.ts": "codex-integration", + "exec-known-app-bundles.test.ts": "codex-integration", "messaging-local-discovery.test.ts": "codex-integration", "messaging-local-cli.test.ts": "codex-integration", "messaging-local-envelope.test.ts": "codex-integration",