diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index 0f25fa03e579..5f2dd7e96d6d 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -115,6 +115,17 @@ 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. +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 installed desktop app +initializes with an unsupported app-server version, OpenClaw closes that child +and retries the next managed binary candidate instead of letting a stale +desktop app shadow the plugin-local fallback. Explicit `appServer.command` +config or `OPENCLAW_CODEX_APP_SERVER_BIN` still overrides this managed +selection. + ## Commands Use the `/codex computer-use` commands from any chat surface where the `codex` @@ -276,7 +287,13 @@ Codex app-server MCP status, or macOS permissions. **Status or a probe times out on `computer-use.list_apps`.** The plugin and 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. +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: + +```text +/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled +``` **A Computer Use tool says `Native hook relay unavailable`.** The Codex-native tool hook could not reach an active OpenClaw relay through the local bridge or diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 016e06d6a3dd..ba37a5f0714f 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -639,6 +639,15 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse) return detectedVersion; } +export function isUnsupportedCodexAppServerVersionError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.startsWith( + `Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`, + ) + ); +} + function buildCodexAppServerRuntimeIdentity( response: CodexInitializeResponse, serverVersion: string, diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 92cf4071d90b..381677189a44 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -167,6 +167,7 @@ export type CodexAppServerStartOptions = { transport: CodexAppServerTransportMode; command: string; commandSource?: CodexAppServerCommandSource; + managedFallbackCommandPaths?: string[]; args: string[]; url?: string; authToken?: string; @@ -332,7 +333,9 @@ const codexAppServerNetworkProxySchema = z baseProfile: z.enum(["read-only", "workspace"]).optional(), mode: z.enum(["limited", "full"]).optional(), domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(), - unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(), + unixSockets: z + .record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema) + .optional(), proxyUrl: z.string().trim().min(1).optional(), socksUrl: z.string().trim().min(1).optional(), enableSocks5: z.boolean().optional(), @@ -874,6 +877,7 @@ export function codexAppServerStartOptionsKey( transport: options.transport, command: options.command, commandSource: options.commandSource ?? null, + managedFallbackCommandPaths: [...(options.managedFallbackCommandPaths ?? [])], args: options.args, url: options.url ?? null, authToken: hashSecretForKey(options.authToken, "authToken"), diff --git a/extensions/codex/src/app-server/managed-binary.test.ts b/extensions/codex/src/app-server/managed-binary.test.ts index 08f0dec69780..7b1faa3dbd1f 100644 --- a/extensions/codex/src/app-server/managed-binary.test.ts +++ b/extensions/codex/src/app-server/managed-binary.test.ts @@ -27,6 +27,8 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string { return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex"); } +const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex"; + describe("managed Codex app-server binary", () => { it("leaves explicit command overrides unchanged", async () => { const explicitOptions = startOptions("config"); @@ -41,10 +43,14 @@ describe("managed Codex app-server binary", () => { expect(pathExists).not.toHaveBeenCalled(); }); - it("resolves the plugin-local bundled Codex binary", async () => { + it("prefers the macOS desktop app bundle when it exists", async () => { const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot }); - const pathExists = vi.fn(async (filePath: string) => filePath === paths.commandPath); + const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); + const pathExists = vi.fn( + async (filePath: string) => + filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand, + ); await expect( resolveManagedCodexAppServerStartOptions(startOptions("managed"), { @@ -54,10 +60,31 @@ describe("managed Codex app-server binary", () => { }), ).resolves.toEqual({ ...startOptions("managed"), - command: paths.commandPath, + command: MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, + 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 () => { + const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); + const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); + const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand); + + await expect( + resolveManagedCodexAppServerStartOptions(startOptions("managed"), { + platform: "darwin", + pluginRoot, + pathExists, + }), + ).resolves.toEqual({ + ...startOptions("managed"), + command: pluginLocalCommand, commandSource: "resolved-managed", }); - expect(paths.commandPath).toBe(managedCommandPath(pluginRoot, "darwin")); + expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin"); }); it("resolves Windows Codex command shims", () => { diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index ab2956d5e6c8..e173dcfa2296 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -12,6 +12,7 @@ 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"; type ManagedCodexAppServerPaths = { commandPath: string; @@ -39,16 +40,19 @@ export async function resolveManagedCodexAppServerStartOptions( pluginRoot: options.pluginRoot, }); const pathExists = options.pathExists ?? commandPathExists; - const commandPath = await findManagedCodexAppServerCommandPath({ + const commandPaths = await findManagedCodexAppServerCommandPaths({ candidateCommandPaths: paths.candidateCommandPaths, pathExists, platform, }); + const commandPath = commandPaths[0]; + const managedFallbackCommandPaths = commandPaths.slice(1); return { ...startOptions, command: commandPath, commandSource: "resolved-managed", + ...(managedFallbackCommandPaths.length > 0 ? { managedFallbackCommandPaths } : {}), }; } @@ -77,12 +81,17 @@ function resolveManagedCodexAppServerCommandCandidates( const roots = resolveManagedCodexAppServerCandidateRoots(pluginRoot, platform); return [ ...new Set([ + ...resolveDesktopCodexAppServerCommandCandidates(platform), ...roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)), ...resolveManagedCodexPackageBinCandidates(roots, platform), ]), ]; } +function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] { + return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : []; +} + function resolveDefaultCodexPluginRoot(moduleDir: string): string { const moduleBaseName = path.basename(moduleDir); if (moduleBaseName === "dist" || moduleBaseName === "dist-runtime") { @@ -195,16 +204,20 @@ function pathForPlatform(platform: NodeJS.Platform): typeof path { return platform === "win32" ? path.win32 : path.posix; } -async function findManagedCodexAppServerCommandPath(params: { +async function findManagedCodexAppServerCommandPaths(params: { candidateCommandPaths: readonly string[]; pathExists: (filePath: string, platform: NodeJS.Platform) => Promise; platform: NodeJS.Platform; -}): Promise { +}): Promise { + const commandPaths: string[] = []; for (const commandPath of params.candidateCommandPaths) { if (await params.pathExists(commandPath, params.platform)) { - return commandPath; + commandPaths.push(commandPath); } } + if (commandPaths.length > 0) { + return commandPaths; + } throw new Error( [ diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index f120ccb37199..d7b30271be1c 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -187,6 +187,41 @@ describe("shared Codex app-server client", () => { startSpy.mockRestore(); }); + it("falls back to the next managed app-server when desktop initialize is unsupported", async () => { + const desktop = createClientHarness(); + const pluginLocal = createClientHarness(); + const startSpy = vi + .spyOn(CodexAppServerClient, "start") + .mockReturnValueOnce(desktop.client) + .mockReturnValueOnce(pluginLocal.client); + mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({ + ...startOptions, + command: "/Applications/Codex.app/Contents/Resources/codex", + commandSource: "resolved-managed", + managedFallbackCommandPaths: ["/cache/openclaw/codex"], + })); + + const listPromise = listCodexAppServerModels({ timeoutMs: 1000 }); + await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)"); + await sendInitializeResult(pluginLocal, "openclaw/0.125.0 (macOS; test)"); + await sendEmptyModelList(pluginLocal); + + await expect(listPromise).resolves.toEqual({ models: [] }); + expect(desktop.process.stdin.destroyed).toBe(true); + expect(pluginLocal.process.stdin.destroyed).toBe(false); + expect(startSpy).toHaveBeenCalledTimes(2); + expect(startSpy.mock.calls[0]?.[0]).toMatchObject({ + command: "/Applications/Codex.app/Contents/Resources/codex", + commandSource: "resolved-managed", + managedFallbackCommandPaths: ["/cache/openclaw/codex"], + }); + expect(startSpy.mock.calls[1]?.[0]).toMatchObject({ + command: "/cache/openclaw/codex", + commandSource: "resolved-managed", + }); + expect(startSpy.mock.calls[1]?.[0]).not.toHaveProperty("managedFallbackCommandPaths"); + }); + it("closes and clears a shared app-server when initialize times out", async () => { const first = createClientHarness(); const second = createClientHarness(); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 0e6fdb7fad58..0fed612321fb 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -11,7 +11,7 @@ import { resolveCodexAppServerAuthProfileStore, resolveCodexAppServerFallbackApiKeyCacheKey, } from "./auth-bridge.js"; -import { CodexAppServerClient } from "./client.js"; +import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js"; import { codexAppServerStartOptionsKey, resolveCodexAppServerRuntimeOptions, @@ -242,27 +242,23 @@ async function acquireSharedCodexAppServerClient( const sharedPromise = entry.promise ?? (entry.promise = (async () => { - const client = CodexAppServerClient.start(startOptions); + const client = await startInitializedCodexAppServerClient({ + startOptions, + agentDir, + authProfileId: usesNativeAuth ? null : authProfileId, + config: options?.config, + onStartedClient: (startedClient) => { + entry.client = startedClient; + startedClient.setActiveSharedLeaseCountProviderForUnscopedNotifications( + () => entry.activeLeases, + ); + options?.onStartedClient?.(startedClient); + }, + }); entry.client = client; - options?.onStartedClient?.(client); client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases); client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient)); - try { - await client.initialize(); - await applyCodexAppServerAuthProfile({ - client, - agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, - startOptions, - config: options?.config, - }); - return client; - } catch (error) { - // Startup failures happen before callers own the shared client, so close - // the child here instead of leaving a rejected daemon attached to stdio. - client.close(); - throw error; - } + return client; })()); try { const client = await withTimeout( @@ -291,39 +287,110 @@ export async function createIsolatedCodexAppServerClient( ): Promise { const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } = await resolveCodexAppServerClientStartContext(options); - const client = CodexAppServerClient.start(startOptions); - if (authProfileId) { - // Profile-backed Codex auth is ephemeral. Keep the host refresh callback - // available whether the profile came from a scoped store or persisted state. - client.addRequestHandler(async (request) => { - if (request.method !== "account/chatgptAuthTokens/refresh") { - return undefined; + return await startInitializedCodexAppServerClient({ + startOptions, + agentDir, + authProfileId: usesNativeAuth ? null : authProfileId, + authProfileStore, + config: options?.config, + timeoutMs: options?.timeoutMs, + onStartedClient: options?.onStartedClient, + }); +} + +async function startInitializedCodexAppServerClient(params: { + startOptions: CodexAppServerStartOptions; + agentDir: string; + authProfileId: string | null | undefined; + authProfileStore?: AuthProfileStore; + config?: CodexAppServerClientOptions["config"]; + timeoutMs?: number; + onStartedClient?: (client: CodexAppServerClient) => void; +}): Promise { + const startOptionsCandidates = resolveManagedFallbackStartOptions(params.startOptions); + for (let index = 0; index < startOptionsCandidates.length; index += 1) { + const startOptions = startOptionsCandidates[index]; + const client = CodexAppServerClient.start(startOptions); + params.onStartedClient?.(client); + const initialize = client.initialize(); + try { + await withTimeout(initialize, params.timeoutMs ?? 0, "codex app-server initialize timed out"); + } catch (error) { + client.close(); + void initialize.catch(() => undefined); + if (shouldTryManagedFallbackStartOption(error, startOptions, index, startOptionsCandidates)) { + continue; } - return await refreshCodexAppServerAuthTokens({ - agentDir, - authProfileId, - ...(authProfileStore ? { authProfileStore } : {}), - config: options?.config, + throw error; + } + + if (params.authProfileId) { + // Profile-backed Codex auth is ephemeral. Keep the host refresh callback + // available whether the profile came from a scoped store or persisted state. + client.addRequestHandler(async (request) => { + if (request.method !== "account/chatgptAuthTokens/refresh") { + return undefined; + } + return await refreshCodexAppServerAuthTokens({ + agentDir: params.agentDir, + authProfileId: params.authProfileId!, + ...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}), + config: params.config, + }); }); - }); + } + + try { + await applyCodexAppServerAuthProfile({ + client, + agentDir: params.agentDir, + authProfileId: params.authProfileId, + startOptions, + config: params.config, + ...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}), + }); + return client; + } catch (error) { + client.close(); + throw error; + } } - const initialize = client.initialize(); - try { - await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out"); - await applyCodexAppServerAuthProfile({ - client, - agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, - startOptions, - config: options?.config, - ...(authProfileStore ? { authProfileStore } : {}), - }); - return client; - } catch (error) { - client.close(); - void initialize.catch(() => undefined); - throw error; + throw new Error("Managed Codex app-server fallback candidates were exhausted."); +} + +function resolveManagedFallbackStartOptions( + startOptions: CodexAppServerStartOptions, +): CodexAppServerStartOptions[] { + const commands = [startOptions.command, ...(startOptions.managedFallbackCommandPaths ?? [])]; + const candidates: CodexAppServerStartOptions[] = []; + for (let index = 0; index < commands.length; index += 1) { + const command = commands[index]; + const managedFallbackCommandPaths = commands.slice(index + 1); + const candidate = { + ...startOptions, + command, + }; + if (managedFallbackCommandPaths.length === 0) { + delete candidate.managedFallbackCommandPaths; + } else { + candidate.managedFallbackCommandPaths = managedFallbackCommandPaths; + } + candidates.push(candidate); } + return candidates; +} + +function shouldTryManagedFallbackStartOption( + error: unknown, + startOptions: CodexAppServerStartOptions, + index: number, + startOptionsCandidates: readonly CodexAppServerStartOptions[], +): boolean { + return ( + startOptions.commandSource === "resolved-managed" && + index < startOptionsCandidates.length - 1 && + isUnsupportedCodexAppServerVersionError(error) + ); } /** Clears and closes all shared clients for deterministic tests. */