From 1bb9c190d719828ea4ce883b5e49d377c93798e8 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 09:37:26 +0900 Subject: [PATCH] fix(codex): probe the app-bundled CLI when no Codex CLI resolves (#8395) A macOS Child whose only Codex is the CLI inside ChatGPT.app reported `ocx connect status` readiness as `unverified` ("the selected local Codex runtime did not report the reasoning levels it supports"). Runtime discovery only offers the app candidate after it classifies the bundle's XML `Info.plist` identity, so a bundle whose identity it cannot read leaves the selection on the bare `codex` sentinel. The bundled-catalog probe then asked a command that does not exist while a real Codex sat inside the app, and the readiness check had no ladder to compare the installed catalog against. When the resolver ends on that bare sentinel, the catalog loader now also tries the known ChatGPT.app locations, system before per-user, reusing `macCodexAppCommands`. The candidate list grants no trust: every execution still passes the app-bundle signature and ownership gate in exec-boundary.ts, so an unverifiable bundle still yields no ladder and stays `unverified`. Windows needs no change because its LOCALAPPDATA Codex install root is already enumerated by `installedCodexCandidates`. Closes #8360 (cherry picked from commit 57cb4bb357df3d7e3c9f225de532959f99630c84) --- .../content/docs/guides/codex-integration.md | 2 +- .../docs/ko/guides/codex-integration.md | 2 +- .../docs/zh-cn/guides/codex-integration.md | 2 +- .../docs/zh-tw/guides/codex-integration.md | 2 +- src/codex/catalog/bundled.ts | 37 +++++++- structure/catalog.md | 2 +- structure/codex-home.md | 2 +- .../codex-runtime-macos-desktop.test.ts | 93 +++++++++++++++++++ 8 files changed, 134 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 0b75d49d6a4..848e3da9c6c 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -445,7 +445,7 @@ $CODEX_HOME/opencodex-catalog.json $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. +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. When no Codex CLI is found at all, the catalog probe behind `ocx connect status` readiness also tries those bundled locations, so a Child whose only Codex is the app’s bundled CLI reports its reasoning levels instead of `unverified`; every execution still passes the checks below. 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 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 9de4a3d9e7f..e5adb56b101 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -213,7 +213,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다. +macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다. Codex CLI가 전혀 발견되지 않으면 `ocx connect status` 준비 상태 뒤의 카탈로그 프로브도 해당 번들 위치를 시도하므로, 앱 번들 CLI만 있는 Child는 `unverified` 대신 지원 추론 수준을 보고합니다. 실행 시에는 아래 검사가 그대로 적용됩니다. 실행 시에는 위치와 관계없이 `Codex.app`과 `ChatGPT.app`을 인식하며, 이동한 번들, 명시적 `CODEX_CLI_PATH`·설정 런타임 명령, 심볼릭 링크 별칭에도 같은 검증을 적용합니다. 앱 이름은 검증 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 05d5718e975..e807acd44e9 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 @@ -149,7 +149,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。 +在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。 如果完全没有发现 Codex CLI,则 `ocx connect status` 就绪状态背后的目录探测也会尝试这些内置位置,因此仅装有应用内置 CLI 的 Child 会报告其支持的推理级别,而不是 `unverified`;执行时仍会应用下述检查。 执行检查还会识别任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移动后的应用包、显式 `CODEX_CLI_PATH` 或配置的运行时命令,以及符号链接别名。名称只触发验证,不代表可信。 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 e8eded5bcaf..2d00f3344aa 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 @@ -146,7 +146,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。 +在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。 若完全找不到 Codex CLI,`ocx connect status` 就緒狀態背後的目錄探測也會嘗試這些內建位置,因此僅有應用程式內建 CLI 的 Child 會回報其支援的推理等級,而不是 `unverified`;執行時仍會套用下列檢查。 執行檢查也會辨識任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移動後的應用程式套件、明確的 `CODEX_CLI_PATH` 或設定的執行階段命令,以及符號連結別名。名稱只觸發驗證,不代表可信。 diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 8b2e88613d7..09178baa28e 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -38,6 +38,7 @@ import { retainedNativeInheritanceTemplate } from "./native-template"; import type { RawCatalog, RawEntry } from "./parsing"; import { isSpawnableCodexCandidate } from "../exec-invocation"; import { validatedCodexExecInvocation, validatedCodexExecInvocationAsync } from "../exec-boundary"; +import { macCodexAppCommands } from "../mac-app-bundle"; import { isPlainObject } from "../../lib/plain-data"; import { parsePersistedCodexRuntime, @@ -51,6 +52,7 @@ import { import type { DeepReadonly, EffortClampDiagnostic, + ResolveCodexRuntimeResult, ResolvedCodexRuntime, } from "../runtime"; import type { @@ -332,6 +334,37 @@ function bundledRuntimeDeps(deps: BundledCatalogDeps) { }; } +/** + * Candidates for the bundled-catalog probe. + * + * When discovery finds no Codex CLI it answers with the bare `codex` sentinel, so the probe + * asks a command that does not exist while a real CLI sits inside ChatGPT.app. Discovery only + * offers that app candidate after classifying the bundle's XML `Info.plist` identity, so a + * bundle it cannot classify (a binary plist, or an identity assertion that moved with an app + * update) left `ocx connect status` reporting `unverified` on a macOS Child whose only Codex + * was the bundled CLI (#8360). + * + * The known app locations are therefore added after a bare sentinel. This list grants no + * trust: every execution still passes the app-bundle signature and ownership gate in + * `exec-boundary.ts`, so an unverifiable bundle yields `null` rather than an invented ladder. + * Windows needs no entry here because its Codex App install root under LOCALAPPDATA is already + * enumerated by `installedCodexCandidates` in `runtime.ts`. + */ +function bundledCatalogCandidates( + deps: BundledCatalogDeps, + resolved: ResolveCodexRuntimeResult | null, +): string[] { + const candidates = deps.commandCandidates?.() ?? [resolved!.runtime.command]; + // An explicit empty list asks for no probe at all. + if (candidates.length === 0) return candidates; + if ((deps.platform ?? process.platform) !== "darwin") return candidates; + // Only the unresolved sentinel is a bare name; a PATH or installed selection is absolute, and + // that selection is the runtime the operator is already running, not a gap to fill. + if (candidates.some(command => command.includes("/") || command.includes("\\"))) return candidates; + const exists = deps.existsSync ?? existsSync; + return [...candidates, ...macCodexAppCommands(deps).filter(command => exists(command))]; +} + function* bundledCatalogSteps( candidates: string[], cacheKey: string | null, @@ -390,7 +423,7 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): Readonly const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.execFile && !deps.configDir && !deps.env; const resolved = deps.commandCandidates ? null : resolveAndPersistCodexRuntime(bundledRuntimeDeps(deps)); const inputs = codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps)); - const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command], + const steps = bundledCatalogSteps(bundledCatalogCandidates(deps, resolved), useCache ? bundledRuntimeKey(resolved!.runtime) : null, () => inputs === codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps))); let step = steps.next(); @@ -449,7 +482,7 @@ export function loadBundledCodexCatalogAsync( const runtimeEpoch = codexRuntimeStateEpoch(); const current = () => active() && runtimeEpoch === codexRuntimeStateEpoch() && flight.inputs === codexRuntimeSelectionIdentity(runtimeDeps); - const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command], + const steps = bundledCatalogSteps(bundledCatalogCandidates(deps, resolved), useCache ? bundledRuntimeKey(resolved!.runtime) : null, current, false); let step = steps.next(); while (!step.done) { diff --git a/structure/catalog.md b/structure/catalog.md index f9193633c27..c0310c0aa7b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -96,7 +96,7 @@ Cache invalidation reports an unchanged derived cache separately from a failed r Custom rows replace discovered rows that encode to the same Codex-facing slug. For a provider whose captured discovery declares an order (`preferFirst`), `src/codex/catalog/routed-gather.ts` puts the replacement in the discovered row's slot; every other provider keeps discovered rows first and custom rows after them, as before. Coverage: `tests/codex-integration/catalog-custom-ordering.test.ts`. -On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. This split is +On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. On macOS, when discovery ends on the bare `codex` sentinel because it found no CLI, the same loader also probes the known ChatGPT.app locations from `src/codex/mac-app-bundle.ts` before reporting unavailable (#8360), so `ocx connect status` readiness can report the bundled CLI's ladder when the bundle identity gate could not classify it; the candidate list grants no trust, and an unverifiable bundle still yields null because every execution passes the exec-boundary checks. This split is required because empty or partial provider discovery must preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. Retained sync and evidence-bound convergence share an explicit observed-state merge policy and resolve unbanded priorities through the [native source-priority contract](subagents.md#native-source-priority), including live/published/cache parity. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex diff --git a/structure/codex-home.md b/structure/codex-home.md index a9ac4855fbd..5b1ae42bb79 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -43,7 +43,7 @@ user off a Windows home they were running against, and a stat failure other than local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly set path that is unreadable or not a directory is an error, not a fallback: silently using a different home than the operator named would write provider state where nobody is looking for it. A proven missing home (including explicit `CODEX_HOME`) resolves lexically; native restore, remove and journal reconcile succeed without creating a directory or acquiring the config write lock. Dangling links do not prove absence, and existing homes retain ownership checks. Lock-path resolution never creates parents; unresolved parents yield a typed, handled refusal. Scalar and multi-agent feature edits refuse missing homes; prompt commits and native feature commands retain their directory creation before locking. The management native feature toggle resolves and retains the spawn invocation before preparing a home; unavailable executables return the toggle failure without creating it. Failed native toggles and prompt commits keep a home they created (`src/codex/prepared-home.ts`): a pathname-based removal cannot prove it still names that directory, and an empty home is what Codex itself creates. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422). -On macOS, `src/codex/runtime.ts` tries the system then user ChatGPT.app bundle after environment, persisted selection, shim, PATH, established installs and a runnable bare fallback, preferring `Contents/Resources/codex-cli/bin/codex` over `Contents/Resources/codex`. Bundle membership compares the normalized and canonical (symlink-resolved) forms of the path and bundle with a segment boundary, including backing executables; candidate ordering keys on the generated app commands, so an established install path that links into the app keeps its precedence. Discovery and probe prechecks require exactly one top-level `CFBundleIdentifier` equal to `com.openai.codex` in a regular XML `Contents/Info.plist`, read in-process with a 64 KiB limit; the whole document must be valid plist XML, so binary, malformed (anywhere, not only in the identity pair), oversized or unavailable metadata fails closed. App candidates never supersede a runnable persisted selection, including legacy/discovered records and the fast path, but full discovery can report them in `newerAvailable`; invalid selections retain `replacedConfigured` recovery. When a verified app candidate exists, deferred discovery skips the bare fallback as a candidate and retains it only as the no-candidate sentinel. +On macOS, `src/codex/runtime.ts` tries the system then user ChatGPT.app bundle after environment, persisted selection, shim, PATH, established installs and a runnable bare fallback, preferring `Contents/Resources/codex-cli/bin/codex` over `Contents/Resources/codex`. Bundle membership compares the normalized and canonical (symlink-resolved) forms of the path and bundle with a segment boundary, including backing executables; candidate ordering keys on the generated app commands, so an established install path that links into the app keeps its precedence. Discovery and probe prechecks require exactly one top-level `CFBundleIdentifier` equal to `com.openai.codex` in a regular XML `Contents/Info.plist`, read in-process with a 64 KiB limit; the whole document must be valid plist XML, so binary, malformed (anywhere, not only in the identity pair), oversized or unavailable metadata fails closed. App candidates never supersede a runnable persisted selection, including legacy/discovered records and the fast path, but full discovery can report them in `newerAvailable`; invalid selections retain `replacedConfigured` recovery. When a verified app candidate exists, deferred discovery skips the bare fallback as a candidate and retains it only as the no-candidate sentinel. When discovery ends on that sentinel because no Codex CLI was found, the bundled-catalog probe in `src/codex/catalog/bundled.ts` also tries the same app locations before reporting unavailable, so `ocx connect status` readiness reports the bundled CLI's ladder even when the identity gate could not classify the bundle (#8360); the probe grants no trust and every execution still passes the exec-boundary signature and ownership checks. `src/codex/home.ts` imports path expansion directly from `src/config/paths.ts`, so a fresh WSL process can resolve its Codex home without re-entering the config facade before the resolver initializes. Provider-ID approvals are home-bound; see the [custom routing contract](codex-provider-routing.md). The managed files are: diff --git a/tests/codex-integration/codex-runtime-macos-desktop.test.ts b/tests/codex-integration/codex-runtime-macos-desktop.test.ts index 764eee42643..a1ebf35ee8c 100644 --- a/tests/codex-integration/codex-runtime-macos-desktop.test.ts +++ b/tests/codex-integration/codex-runtime-macos-desktop.test.ts @@ -7,6 +7,8 @@ import { setCodexRuntimeResolveCacheForTests, type ResolveCodexRuntimeDeps, } from "../../src/codex/runtime"; import { loadBundledCodexCatalog, loadBundledCodexCatalogAsync } from "../../src/codex/catalog/bundled"; +import { codexSupportedReasoningEfforts } from "../../src/codex/catalog/effort"; +import { inspectClientCatalogReadiness } from "../../src/client/catalog-compatibility"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const roots: string[] = []; @@ -316,3 +318,94 @@ describe("bundled app catalog consumer", () => { }); } }); + +/** + * #8360: a macOS Child whose only Codex is the CLI inside ChatGPT.app reported + * `readiness: unverified` ("the selected local Codex runtime did not report the reasoning + * levels it supports"). The runtime resolver only offers the app candidate after it can + * classify the bundle's XML `Info.plist` identity, so a bundle it cannot classify leaves the + * selection on the bare `codex` sentinel. The readiness probe then asked a command that does + * not exist while a real, trusted Codex sat inside the app. + */ +describe("app-bundled CLI fallback for connect readiness (#8360)", () => { + /** A catalog with a distinctive ladder, so a warm fixture value cannot pass by accident. */ + const bundledCatalog = JSON.stringify({ models: [{ + slug: "gpt-5.6-sol", + base_instructions: "Fixture instructions.", + supported_reasoning_levels: [{ effort: "low" }, { effort: "high" }], + }] }); + + /** Exec seam: only the bundled CLI answers, with its version or its bundled catalog. */ + function bundledOnlyExec(appCommand: string, spawned: string[]) { + return (file: string, args: readonly string[]) => { + spawned.push(file); + if (file !== appCommand) throw Object.assign(new Error("not found"), { code: "ENOENT" }); + return args[0] === "debug" ? bundledCatalog : "codex-cli 0.166.0-alpha.6"; + }; + } + + test("an app-bundled-only layout yields the bundled ladder and reports ready", () => { + const f = fixture(); + // The bundle identity is not classifiable (the shape an app update can leave behind), so + // discovery declines the app candidate and the resolver ends on the bare sentinel. + f.deps.readBundleIdentifier = () => null; + f.versions.set(f.current, "0.166.0-alpha.6"); + const spawned: string[] = []; + const exec = bundledOnlyExec(f.current, spawned); + const selected = resolveCodexRuntime({ ...f.deps, discoverAlternatives: false, execFileSync: exec }); + expect(selected.runtime.command).toBe("codex"); + + const efforts = codexSupportedReasoningEfforts({ + ...f.deps, execFileSync: exec, commandCandidates: () => [selected.runtime.command], + }); + expect(efforts).toEqual(new Set(["low", "high"])); + expect(inspectClientCatalogReadiness("present", bundledCatalog, { supportedEfforts: () => efforts })) + .toEqual({ kind: "ready" }); + expect(spawned).toContain(f.current); + }); + + test("app locations that do not exist leave the ladder unverified instead of inventing one", () => { + const f = fixture(); + f.deps.readBundleIdentifier = () => null; + const spawned: string[] = []; + const exec = bundledOnlyExec(f.current, spawned); + const efforts = codexSupportedReasoningEfforts({ + ...f.deps, execFileSync: exec, commandCandidates: () => ["codex"], + }); + expect(efforts).toBeNull(); + // No app binary exists, so none is offered; the bare sentinel cannot resolve at the exec + // boundary either. A missing runtime stays unverified rather than borrowing a ladder. + expect(spawned).toEqual([]); + }); + + test("a resolved absolute command is probed as given, without widening to app candidates", () => { + const f = fixture(); + const standalone = "/fixture/bin/codex"; + f.versions.set(standalone, "0.167.0"); + f.versions.set(f.current, "0.166.0-alpha.6"); + const spawned: string[] = []; + const exec = (file: string, args: readonly string[]) => { + spawned.push(file); + return args[0] === "debug" ? bundledCatalog : "codex-cli 0.167.0"; + }; + expect(codexSupportedReasoningEfforts({ + ...f.deps, execFileSync: exec, commandCandidates: () => [standalone], + })).toEqual(new Set(["low", "high"])); + expect(spawned).toEqual([standalone]); + }); + + test("the async bundled-catalog driver widens a bare sentinel the same way", async () => { + const f = fixture(); + f.versions.set(f.current, "0.166.0-alpha.6"); + const spawned: string[] = []; + const exec = bundledOnlyExec(f.current, spawned); + const catalog = await loadBundledCodexCatalogAsync({ + ...f.deps, + commandCandidates: () => ["codex"], + execFileSync: exec as never, + execFile: async (file, args) => exec(file, args), + }); + expect(catalog?.models[0]?.slug).toBe("gpt-5.6-sol"); + expect(spawned).toContain(f.current); + }); +});