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); + }); +});