diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index 3c53ff90ebbb..c86cd26b7784 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -107,17 +107,20 @@ With this config, OpenClaw checks Codex app-server before each Codex-mode turn. If Computer Use is missing but Codex app-server has already discovered an installable marketplace, OpenClaw asks Codex app-server to install or re-enable the plugin and reload MCP servers. On macOS, when no matching -marketplace is registered and the standard Codex app bundle exists, OpenClaw +marketplace is registered and a standard desktop app bundle exists, OpenClaw also tries to register the bundled Codex marketplace from -`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled` before it -fails. If setup still cannot make the MCP server available, the turn fails -before the thread starts. +`/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled`, with +`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled` retained +as a fallback for legacy standalone installs. If setup still cannot make the +MCP server available, the turn fails before the thread starts. After changing Computer Use config, use `/new` or `/reset` in the affected chat before testing if an existing Codex thread has already started. -On macOS managed stdio startup, OpenClaw prefers the signed desktop Codex app -bundle at `/Applications/Codex.app/Contents/Resources/codex` when it exists. +On macOS managed stdio startup, OpenClaw prefers the signed desktop app +bundle at `/Applications/ChatGPT.app/Contents/Resources/codex`, then falls +back to `/Applications/Codex.app/Contents/Resources/codex` for legacy +standalone installs. That keeps Computer Use under the app bundle that owns the local desktop-control permissions. If the desktop app is not installed, OpenClaw falls back to the managed Codex binary installed beside the plugin. If an @@ -177,24 +180,27 @@ matches fail closed and ask you to set `marketplaceName` or ## Bundled macOS marketplace -Recent Codex desktop builds bundle Computer Use here: +Current ChatGPT desktop builds bundle Computer Use here; legacy standalone +Codex desktop builds use the same layout under `Codex.app`: ```text +/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use /Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use ``` When `computerUse.autoInstall` is true and no marketplace containing -`computer-use` is registered, OpenClaw tries to add the standard bundled -marketplace root automatically: +`computer-use` is registered, OpenClaw tries to add the first standard +bundled marketplace root that exists: ```text +/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled /Applications/Codex.app/Contents/Resources/plugins/openai-bundled ``` You can also register it explicitly from a shell with Codex: ```bash -codex plugin marketplace add /Applications/Codex.app/Contents/Resources/plugins/openai-bundled +codex plugin marketplace add /Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled ``` If you use a nonstandard Codex app path, run `/codex computer-use install @@ -311,10 +317,11 @@ MCP server are present, but the local Computer Use bridge did not answer. Quit or restart Codex Computer Use, relaunch Codex Desktop if needed, then retry in a fresh OpenClaw session. If the host previously ran Computer Use through an older managed Codex app-server, refresh the installed plugin from -the desktop bundled marketplace: +the desktop bundled marketplace (use the `Codex.app` path for standalone +Codex desktop installs): ```text -/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled +/codex computer-use install --source /Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled ``` **A Computer Use tool says `Native hook relay unavailable`.** The diff --git a/extensions/codex/src/app-server/computer-use.test.ts b/extensions/codex/src/app-server/computer-use.test.ts index 361fd007d1b8..0de2529fadd8 100644 --- a/extensions/codex/src/app-server/computer-use.test.ts +++ b/extensions/codex/src/app-server/computer-use.test.ts @@ -247,6 +247,32 @@ describe("Codex Computer Use setup", () => { expectRequestMethodNotCalled(request, "plugin/install"); }); + it("does not inspect bundled app paths when a registered marketplace is ready", async () => { + const request = createComputerUseRequest({ installed: true }); + const forbiddenCandidates = new Proxy(["/unused/bundled-marketplace"], { + get() { + throw new Error("bundled marketplace candidates must stay lazy"); + }, + }); + + const status = await ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + }, + }, + request, + defaultBundledMarketplacePaths: forbiddenCandidates, + }); + + expectStatusFields(status, { + ready: true, + reason: "ready", + }); + expectRequestMethodNotCalled(request, "marketplace/add"); + }); + it("uses setup writes when auto-install needs to install", async () => { const request = createComputerUseRequest({ installed: false }); @@ -280,6 +306,9 @@ describe("Codex Computer Use setup", () => { path.join(os.tmpdir(), "openclaw-codex-bundled-marketplace-"), ); cleanupPaths.push(bundledMarketplacePath); + fs.mkdirSync(path.join(bundledMarketplacePath, "plugins", "computer-use"), { + recursive: true, + }); const request = createBundledMarketplaceComputerUseRequest(bundledMarketplacePath); const status = await ensureCodexComputerUse({ @@ -290,7 +319,7 @@ describe("Codex Computer Use setup", () => { }, }, request, - defaultBundledMarketplacePath: bundledMarketplacePath, + defaultBundledMarketplacePaths: [bundledMarketplacePath], }); expectStatusFields(status, { @@ -308,6 +337,90 @@ describe("Codex Computer Use setup", () => { }); }); + it.each([ + { + label: "prefers ChatGPT.app when both desktop marketplaces exist", + marketplaceIndexes: [0, 1], + pluginIndexes: [0, 1], + expectedIndex: 0, + }, + { + label: "uses ChatGPT.app when it is the only desktop marketplace", + marketplaceIndexes: [0], + pluginIndexes: [0], + expectedIndex: 0, + }, + { + label: "falls back to legacy Codex.app when it is the only desktop marketplace", + marketplaceIndexes: [1], + pluginIndexes: [1], + expectedIndex: 1, + }, + { + label: "skips a ChatGPT.app marketplace that does not contain Computer Use", + marketplaceIndexes: [0, 1], + pluginIndexes: [1], + expectedIndex: 1, + }, + { + label: "does not add a marketplace when neither desktop bundle contains Computer Use", + marketplaceIndexes: [0, 1], + pluginIndexes: [], + expectedIndex: undefined, + }, + ])("$label", async ({ marketplaceIndexes, pluginIndexes, expectedIndex }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-desktop-bundles-")); + cleanupPaths.push(root); + const bundledMarketplacePaths = [ + path.join(root, "ChatGPT.app", "Contents", "Resources", "plugins", "openai-bundled"), + path.join(root, "Codex.app", "Contents", "Resources", "plugins", "openai-bundled"), + ]; + for (const index of marketplaceIndexes) { + fs.mkdirSync(bundledMarketplacePaths[index], { recursive: true }); + } + for (const index of pluginIndexes) { + fs.mkdirSync(path.join(bundledMarketplacePaths[index], "plugins", "computer-use"), { + recursive: true, + }); + } + + const expectedPath = + expectedIndex === undefined ? undefined : bundledMarketplacePaths[expectedIndex]; + const request = expectedPath + ? createBundledMarketplaceComputerUseRequest(expectedPath) + : createEmptyMarketplaceComputerUseRequest(); + const setup = ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + marketplaceDiscoveryTimeoutMs: 1, + }, + }, + request, + defaultBundledMarketplacePaths: bundledMarketplacePaths, + }); + + if (!expectedPath) { + await expectSetupErrorStatus(setup, { + ready: false, + reason: "marketplace_missing", + }); + expectRequestMethodNotCalled(request, "marketplace/add"); + return; + } + + const status = await setup; + expectStatusFields(status, { + ready: true, + reason: "ready", + marketplaceName: "openai-bundled", + }); + expect(request).toHaveBeenCalledWith("marketplace/add", { + source: expectedPath, + }); + }); + it("allows auto-install from a configured local marketplace path", async () => { const request = createComputerUseRequest({ installed: false }); @@ -601,6 +714,9 @@ function createAmbiguousComputerUseRequest(): CodexComputerUseRequest { function createEmptyMarketplaceComputerUseRequest(): CodexComputerUseRequest { return vi.fn(async (method: string) => { + if (method === "experimentalFeature/enablement/set") { + return { enablement: { plugins: true } }; + } if (method === "plugin/list") { return { marketplaces: [], diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts index 788330801080..3fc18e8fa276 100644 --- a/extensions/codex/src/app-server/computer-use.ts +++ b/extensions/codex/src/app-server/computer-use.ts @@ -3,6 +3,7 @@ * app-server sessions. */ import { existsSync } from "node:fs"; +import path from "node:path"; import { describeControlFailure } from "./capabilities.js"; import type { CodexAppServerClient } from "./client.js"; import { @@ -74,7 +75,7 @@ export type CodexComputerUseSetupParams = { timeoutMs?: number; signal?: AbortSignal; forceEnable?: boolean; - defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePaths?: readonly string[]; }; type MarketplaceRef = @@ -106,8 +107,11 @@ type PluginInspection = const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000; const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"]; -const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH = - "/Applications/Codex.app/Contents/Resources/plugins/openai-bundled"; +// ChatGPT.app is the current desktop owner; keep Codex.app as the legacy fallback. +const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATHS = [ + "/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled", + "/Applications/Codex.app/Contents/Resources/plugins/openai-bundled", +] as const; /** Reads Computer Use readiness without installing or mutating app-server state. */ export async function readCodexComputerUseStatus( @@ -200,7 +204,7 @@ async function inspectCodexComputerUse(params: { signal?: AbortSignal; config: ResolvedCodexComputerUseConfig; installPlugin: boolean; - defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePaths?: readonly string[]; }): Promise { const request = createComputerUseRequest(params); if (params.installPlugin) { @@ -214,7 +218,7 @@ async function inspectCodexComputerUse(params: { config: params.config, allowAdd: params.installPlugin, signal: params.signal, - defaultBundledMarketplacePath: params.defaultBundledMarketplacePath, + defaultBundledMarketplacePaths: params.defaultBundledMarketplacePaths, }); if (!marketplace.marketplace) { return unavailableStatus( @@ -340,7 +344,7 @@ async function resolveMarketplaceRef(params: { config: ResolvedCodexComputerUseConfig; allowAdd: boolean; signal?: AbortSignal; - defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePaths?: readonly string[]; }): Promise { let preferredMarketplaceName = params.config.marketplaceName; if (params.config.marketplaceSource && params.allowAdd) { @@ -358,14 +362,27 @@ async function resolveMarketplaceRef(params: { } let candidates = await listComputerUseMarketplaceCandidates(params.request, params.config); - if (candidates.length === 0 && shouldAddBundledComputerUseMarketplace(params)) { - const bundledMarketplacePath = - params.defaultBundledMarketplacePath ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH; - const added = await params.request<{ marketplaceName?: string }>("marketplace/add", { - source: bundledMarketplacePath, - } satisfies CodexRequestObject); - preferredMarketplaceName ??= added.marketplaceName; - candidates = await listComputerUseMarketplaceCandidates(params.request, params.config); + if ( + candidates.length === 0 && + params.allowAdd && + usesDefaultMarketplaceDiscovery(params.config) + ) { + // Most turns already have a registered marketplace. Probe app bundles only + // on the empty auto-install path to keep ordinary startup free of filesystem I/O. + const bundledMarketplacePath = ( + params.defaultBundledMarketplacePaths ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATHS + ).find((candidatePath) => + // The signed desktop bundles publish plugins under this fixed marketplace layout. + // Check the requested plugin before registering a source that would shadow the fallback. + existsSync(path.join(candidatePath, "plugins", params.config.pluginName)), + ); + if (bundledMarketplacePath) { + const added = await params.request<{ marketplaceName?: string }>("marketplace/add", { + source: bundledMarketplacePath, + } satisfies CodexRequestObject); + preferredMarketplaceName ??= added.marketplaceName; + candidates = await listComputerUseMarketplaceCandidates(params.request, params.config); + } } const waitUntil = marketplaceDiscoveryWaitUntil(params); @@ -405,7 +422,10 @@ async function resolveMarketplaceRef(params: { }; } const marketplace = candidates[0]; - return marketplace ? { marketplace } : {}; + if (marketplace) { + return { marketplace }; + } + return {}; } async function listComputerUseMarketplaceCandidates( @@ -431,20 +451,8 @@ function blockUnsafeAutoInstallStatus( ); } -function shouldAddBundledComputerUseMarketplace(params: { - config: ResolvedCodexComputerUseConfig; - allowAdd: boolean; - defaultBundledMarketplacePath?: string; -}): boolean { - const bundledMarketplacePath = - params.defaultBundledMarketplacePath ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH; - return ( - params.allowAdd && - !params.config.marketplaceSource && - !params.config.marketplacePath && - !params.config.marketplaceName && - existsSync(bundledMarketplacePath) - ); +function usesDefaultMarketplaceDiscovery(config: ResolvedCodexComputerUseConfig): boolean { + return !config.marketplaceSource && !config.marketplacePath && !config.marketplaceName; } function findComputerUseMarketplaces( @@ -484,12 +492,7 @@ function marketplaceDiscoveryWaitUntil(params: { config: ResolvedCodexComputerUseConfig; allowAdd: boolean; }): number { - if ( - params.allowAdd && - !params.config.marketplaceSource && - !params.config.marketplacePath && - !params.config.marketplaceName - ) { + if (params.allowAdd && usesDefaultMarketplaceDiscovery(params.config)) { return Date.now() + params.config.marketplaceDiscoveryTimeoutMs; } return 0; diff --git a/extensions/codex/src/app-server/managed-binary.test.ts b/extensions/codex/src/app-server/managed-binary.test.ts index 7b1faa3dbd1f..3114585fe6f4 100644 --- a/extensions/codex/src/app-server/managed-binary.test.ts +++ b/extensions/codex/src/app-server/managed-binary.test.ts @@ -28,6 +28,8 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string { } const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex"; +const MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND = + "/Applications/ChatGPT.app/Contents/Resources/codex"; describe("managed Codex app-server binary", () => { it("leaves explicit command overrides unchanged", async () => { @@ -43,10 +45,58 @@ describe("managed Codex app-server binary", () => { expect(pathExists).not.toHaveBeenCalled(); }); - it("prefers the macOS desktop app bundle when it exists", async () => { + it("prefers ChatGPT.app when both macOS desktop bundles exist", async () => { const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot }); const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); + const pathExists = vi.fn( + async (filePath: string) => + filePath === MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND || + filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || + filePath === pluginLocalCommand, + ); + + await expect( + resolveManagedCodexAppServerStartOptions(startOptions("managed"), { + platform: "darwin", + pluginRoot, + pathExists, + }), + ).resolves.toEqual({ + ...startOptions("managed"), + command: MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND, + commandSource: "resolved-managed", + managedFallbackCommandPaths: [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, pluginLocalCommand], + }); + expect(paths.commandPath).toBe(MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND); + expect(paths.candidateCommandPaths).toContain(pluginLocalCommand); + }); + + it("prefers the ChatGPT.app desktop bundle when Codex.app is absent", async () => { + const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); + const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); + const pathExists = vi.fn( + async (filePath: string) => + filePath === MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND || filePath === pluginLocalCommand, + ); + + await expect( + resolveManagedCodexAppServerStartOptions(startOptions("managed"), { + platform: "darwin", + pluginRoot, + pathExists, + }), + ).resolves.toEqual({ + ...startOptions("managed"), + command: MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND, + commandSource: "resolved-managed", + managedFallbackCommandPaths: [pluginLocalCommand], + }); + }); + + it("falls back to the legacy Codex.app desktop bundle when ChatGPT.app is absent", async () => { + const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); + const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); const pathExists = vi.fn( async (filePath: string) => filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand, @@ -64,11 +114,9 @@ describe("managed Codex app-server binary", () => { commandSource: "resolved-managed", managedFallbackCommandPaths: [pluginLocalCommand], }); - expect(paths.commandPath).toBe(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND); - expect(paths.candidateCommandPaths).toContain(pluginLocalCommand); }); - it("falls back to the plugin-local bundled Codex binary on macOS", async () => { + it("falls back to the plugin-local binary when neither desktop bundle exists", async () => { const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand); @@ -84,6 +132,7 @@ describe("managed Codex app-server binary", () => { command: pluginLocalCommand, commandSource: "resolved-managed", }); + expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND, "darwin"); expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin"); }); diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index e173dcfa2296..080952d0ebb0 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -12,7 +12,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js"; const CODEX_APP_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); const CODEX_PLUGIN_ROOT = resolveDefaultCodexPluginRoot(CODEX_APP_SERVER_MODULE_DIR); -const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex"; +// ChatGPT.app is the current desktop owner; keep Codex.app as the legacy fallback. +const MACOS_DESKTOP_CODEX_APP_SERVER_COMMANDS = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/Codex.app/Contents/Resources/codex", +] as const; type ManagedCodexAppServerPaths = { commandPath: string; @@ -89,7 +93,7 @@ function resolveManagedCodexAppServerCommandCandidates( } function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] { - return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : []; + return platform === "darwin" ? [...MACOS_DESKTOP_CODEX_APP_SERVER_COMMANDS] : []; } function resolveDefaultCodexPluginRoot(moduleDir: string): string {