From 67ffa3df8b07addd0cbd14644233cc87e78e9ec8 Mon Sep 17 00:00:00 2001 From: pash-openai Date: Sun, 26 Apr 2026 13:21:56 -0700 Subject: [PATCH] Add Codex Computer Use setup for Codex mode (#71842) * Add Codex Computer Use setup * Tighten Codex Computer Use setup checks * Handle fresh Codex Computer Use marketplace setup * Fix channel setup manifest fixture * Match Codex Computer Use marketplace loading * Harden plugin manifest test fixtures * Isolate auth choice legacy manifest test * Update aggregate shard test expectation * Improve Codex Computer Use first-run setup * Harden Codex Computer Use auto-install * Fix plugin auto-enable test fixture roots --- docs/plugins/codex-harness.md | 68 +++ extensions/codex/openclaw.plugin.json | 81 +++ .../codex/src/app-server/computer-use.test.ts | 502 +++++++++++++++++ .../codex/src/app-server/computer-use.ts | 511 ++++++++++++++++++ .../codex/src/app-server/config.test.ts | 52 ++ extensions/codex/src/app-server/config.ts | 131 +++++ .../codex/src/app-server/run-attempt.ts | 7 + extensions/codex/src/command-formatters.ts | 24 + extensions/codex/src/command-handlers.ts | 154 ++++++ extensions/codex/src/commands.test.ts | 77 +++ src/commands/auth-choice-legacy.test.ts | 23 +- .../channel-setup/plugin-install.test.ts | 58 +- .../workspace-shadow-bypass.test.ts | 22 +- src/config/plugin-auto-enable.test-helpers.ts | 2 + .../web-fetch-providers.runtime.test.ts | 6 + .../web-search-providers.runtime.test.ts | 6 + src/scripts/test-projects.test.ts | 2 + test/vitest/vitest.unit-fast-paths.mjs | 2 +- 18 files changed, 1691 insertions(+), 37 deletions(-) create mode 100644 extensions/codex/src/app-server/computer-use.test.ts create mode 100644 extensions/codex/src/app-server/computer-use.ts diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index cb209f2ea8c..0919961c78e 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -542,6 +542,72 @@ Environment overrides remain available for local testing: preferred for repeatable deployments because it keeps the plugin behavior in the same reviewed file as the rest of the Codex harness setup. +## Computer Use + +Computer Use is a Codex-native MCP plugin. OpenClaw does not vendor the desktop +control app or execute desktop actions itself; it enables Codex app-server +plugins, installs the configured Codex marketplace plugin when requested, checks +that the `computer-use` MCP server is available, and then lets Codex handle the +native MCP tool calls during Codex-mode turns. + +Set `plugins.entries.codex.config.computerUse` when you want Codex-mode turns to +require Computer Use: + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + computerUse: { + autoInstall: true, + }, + }, + }, + }, + }, + agents: { + defaults: { + model: "openai/gpt-5.5", + embeddedHarness: { + runtime: "codex", + }, + }, + }, +} +``` + +With no marketplace fields, OpenClaw asks Codex app-server to use its discovered +marketplaces. On a fresh Codex home, app-server seeds the official curated +marketplace and OpenClaw follows the same loading shape as Codex: it polls +`plugin/list` during install before treating Computer Use as unavailable. The +default discovery wait is 60 seconds and can be tuned with +`marketplaceDiscoveryTimeoutMs`. If multiple known Codex marketplaces contain +Computer Use, OpenClaw uses the Codex marketplace preference order before +failing closed for unknown ambiguous matches. + +Use `marketplaceSource` for a non-default Codex marketplace source that +app-server can add, or `marketplacePath` for a local marketplace file that +already exists on the machine. If the marketplace is already registered with +Codex app-server, use `marketplaceName` instead. The defaults are +`pluginName: "computer-use"` and `mcpServerName: "computer-use"`. +For safety, turn-start auto-install only uses marketplaces app-server has +already discovered. Use `/codex computer-use install` for explicit installs from +a configured `marketplaceSource` or `marketplacePath`. + +The same setup can be checked or installed from the command surface: + +- `/codex computer-use status` +- `/codex computer-use install` +- `/codex computer-use install --source ` +- `/codex computer-use install --marketplace-path ` + +Computer Use is macOS-specific and may require local OS permissions before the +Codex MCP server can control apps. If `computerUse.enabled` is true and the MCP +server is unavailable, Codex-mode turns fail before the thread starts instead of +silently running without the native Computer Use tools. + ## Common recipes Local Codex with default stdio transport: @@ -644,6 +710,8 @@ Common forms: - `/codex resume ` attaches the current OpenClaw session to an existing Codex thread. - `/codex compact` asks Codex app-server to compact the attached thread. - `/codex review` starts Codex native review for the attached thread. +- `/codex computer-use status` checks the configured Computer Use plugin and MCP server. +- `/codex computer-use install` installs the configured Computer Use plugin and reloads MCP servers. - `/codex account` shows account and rate-limit status. - `/codex mcp` lists Codex app-server MCP server status. - `/codex skills` lists Codex app-server skills. diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index c69bba5ef3f..a0da7789df6 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -43,6 +43,42 @@ } } }, + "computerUse": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "autoInstall": { + "type": "boolean", + "default": false + }, + "marketplaceDiscoveryTimeoutMs": { + "type": "number", + "minimum": 1, + "default": 60000 + }, + "marketplaceSource": { + "type": "string" + }, + "marketplacePath": { + "type": "string" + }, + "marketplaceName": { + "type": "string" + }, + "pluginName": { + "type": "string", + "default": "computer-use" + }, + "mcpServerName": { + "type": "string", + "default": "computer-use" + } + } + }, "appServer": { "type": "object", "additionalProperties": false, @@ -112,6 +148,51 @@ "help": "Maximum time to wait for Codex app-server model discovery before falling back to the bundled model list.", "advanced": true }, + "computerUse": { + "label": "Computer Use", + "help": "Controls Codex app-server setup for the Computer Use plugin.", + "advanced": true + }, + "computerUse.enabled": { + "label": "Enable Computer Use", + "help": "When true, Codex-mode turns require the configured Computer Use MCP server to be available.", + "advanced": true + }, + "computerUse.autoInstall": { + "label": "Auto Install", + "help": "Install the configured Computer Use plugin when Codex-mode turns start.", + "advanced": true + }, + "computerUse.marketplaceDiscoveryTimeoutMs": { + "label": "Marketplace Discovery Timeout", + "help": "Maximum time to wait for Codex app-server to finish loading marketplaces during Computer Use install.", + "advanced": true + }, + "computerUse.marketplaceSource": { + "label": "Marketplace Source", + "help": "Optional Codex marketplace source to add before installing Computer Use.", + "advanced": true + }, + "computerUse.marketplacePath": { + "label": "Marketplace Path", + "help": "Optional local Codex marketplace file path containing the Computer Use plugin.", + "advanced": true + }, + "computerUse.marketplaceName": { + "label": "Marketplace Name", + "help": "Optional registered Codex marketplace name containing the Computer Use plugin.", + "advanced": true + }, + "computerUse.pluginName": { + "label": "Plugin Name", + "help": "Codex marketplace plugin name for Computer Use.", + "advanced": true + }, + "computerUse.mcpServerName": { + "label": "MCP Server Name", + "help": "MCP server name exposed by the Computer Use plugin.", + "advanced": true + }, "appServer": { "label": "App Server", "help": "Runtime controls for connecting to Codex app-server.", diff --git a/extensions/codex/src/app-server/computer-use.test.ts b/extensions/codex/src/app-server/computer-use.test.ts new file mode 100644 index 00000000000..39d9c4651da --- /dev/null +++ b/extensions/codex/src/app-server/computer-use.test.ts @@ -0,0 +1,502 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CodexComputerUseSetupError, + ensureCodexComputerUse, + installCodexComputerUse, + readCodexComputerUseStatus, + type CodexComputerUseRequest, +} from "./computer-use.js"; + +describe("Codex Computer Use setup", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("stays disabled until configured", async () => { + await expect( + readCodexComputerUseStatus({ pluginConfig: {}, request: vi.fn() }), + ).resolves.toEqual( + expect.objectContaining({ + enabled: false, + ready: false, + message: "Computer Use is disabled.", + }), + ); + }); + + it("reports an installed Computer Use MCP server from a registered marketplace", async () => { + const request = createComputerUseRequest({ installed: true }); + + await expect( + readCodexComputerUseStatus({ + pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + enabled: true, + ready: true, + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + marketplaceName: "desktop-tools", + tools: ["list_apps"], + message: "Computer Use is ready.", + }), + ); + expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything()); + expect(request).not.toHaveBeenCalledWith( + "experimentalFeature/enablement/set", + expect.anything(), + ); + expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything()); + }); + + it("does not register marketplace sources during status checks", async () => { + const request = createComputerUseRequest({ installed: true }); + + await expect( + readCodexComputerUseStatus({ + pluginConfig: { + computerUse: { + enabled: true, + marketplaceSource: "github:example/desktop-tools", + }, + }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: true, + message: "Computer Use is ready.", + }), + ); + expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything()); + expect(request).not.toHaveBeenCalledWith( + "experimentalFeature/enablement/set", + expect.anything(), + ); + }); + + it("fails closed when multiple marketplaces contain Computer Use", async () => { + const request = createAmbiguousComputerUseRequest(); + + await expect( + readCodexComputerUseStatus({ + pluginConfig: { computerUse: { enabled: true } }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: false, + message: + "Multiple Codex marketplaces contain computer-use. Configure computerUse.marketplaceName or computerUse.marketplacePath to choose one.", + }), + ); + expect(request).not.toHaveBeenCalledWith("plugin/read", expect.anything()); + }); + + it("installs Computer Use from a configured marketplace source", async () => { + const request = createComputerUseRequest({ installed: false }); + + await expect( + installCodexComputerUse({ + pluginConfig: { + computerUse: { + marketplaceSource: "github:example/desktop-tools", + }, + }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: true, + installed: true, + pluginEnabled: true, + tools: ["list_apps"], + }), + ); + expect(request).toHaveBeenCalledWith("experimentalFeature/enablement/set", { + enablement: { plugins: true }, + }); + expect(request).toHaveBeenCalledWith("marketplace/add", { + source: "github:example/desktop-tools", + }); + expect(request).toHaveBeenCalledWith("plugin/install", { + marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }); + expect(request).toHaveBeenCalledWith("config/mcpServer/reload", undefined); + }); + + it("fails closed when Computer Use is required but not installed", async () => { + const request = createComputerUseRequest({ installed: false }); + + await expect( + ensureCodexComputerUse({ + pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } }, + request, + }), + ).rejects.toThrow(CodexComputerUseSetupError); + expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything()); + }); + + it("skips setup writes when auto-install is already ready", async () => { + const request = createComputerUseRequest({ installed: true }); + + await expect( + ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + marketplaceName: "desktop-tools", + }, + }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: true, + message: "Computer Use is ready.", + }), + ); + expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything()); + expect(request).not.toHaveBeenCalledWith( + "experimentalFeature/enablement/set", + expect.anything(), + ); + expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything()); + }); + + it("uses setup writes when auto-install needs to install", async () => { + const request = createComputerUseRequest({ installed: false }); + + await expect( + ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + }, + }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: true, + message: "Computer Use is ready.", + }), + ); + expect(request).toHaveBeenCalledWith("experimentalFeature/enablement/set", { + enablement: { plugins: true }, + }); + expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything()); + expect(request).toHaveBeenCalledWith("plugin/install", { + marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }); + }); + + it("requires an explicit install command for configured marketplace sources", async () => { + const request = createComputerUseRequest({ installed: false }); + + await expect( + ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + marketplaceSource: "github:example/desktop-tools", + }, + }, + request, + }), + ).rejects.toThrow(CodexComputerUseSetupError); + expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything()); + expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything()); + }); + + it("fails closed when a configured marketplace name is not discovered", async () => { + const request = createEmptyMarketplaceComputerUseRequest(); + + await expect( + readCodexComputerUseStatus({ + pluginConfig: { + computerUse: { + enabled: true, + marketplaceName: "missing-marketplace", + }, + }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: false, + message: + "Configured Codex marketplace missing-marketplace was not found or does not contain computer-use. Run /codex computer-use install with a source or path to install from a new marketplace.", + }), + ); + expect(request).not.toHaveBeenCalledWith("plugin/read", expect.anything()); + }); + + it("waits for the default Codex marketplace during install", async () => { + vi.useFakeTimers(); + const request = createComputerUseRequest({ + installed: false, + marketplaceAvailableAfterListCalls: 3, + }); + const installed = installCodexComputerUse({ + pluginConfig: { computerUse: {} }, + request, + }); + + await vi.advanceTimersByTimeAsync(4_000); + + await expect(installed).resolves.toEqual( + expect.objectContaining({ + ready: true, + message: "Computer Use is ready.", + }), + ); + expect(request).toHaveBeenCalledWith("plugin/install", { + marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }); + expect( + vi.mocked(request).mock.calls.filter(([method]) => method === "plugin/list"), + ).toHaveLength(3); + }); + + it("prefers the official Computer Use marketplace when multiple matches are present", async () => { + const request = createMultiMarketplaceComputerUseRequest(); + + await expect( + installCodexComputerUse({ + pluginConfig: { computerUse: {} }, + request, + }), + ).resolves.toEqual( + expect.objectContaining({ + ready: true, + marketplaceName: "openai-curated", + }), + ); + expect(request).toHaveBeenCalledWith("plugin/install", { + marketplacePath: "/marketplaces/openai-curated/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }); + }); +}); + +function createComputerUseRequest(params: { + installed: boolean; + marketplaceAvailableAfterListCalls?: number; +}): CodexComputerUseRequest { + let installed = params.installed; + let pluginListCalls = 0; + return vi.fn(async (method: string, requestParams?: unknown) => { + if (method === "experimentalFeature/enablement/set") { + return { enablement: { plugins: true } }; + } + if (method === "marketplace/add") { + return { + marketplaceName: "desktop-tools", + installedRoot: "/marketplaces/desktop-tools", + alreadyAdded: false, + }; + } + if (method === "plugin/list") { + pluginListCalls += 1; + const marketplaceAvailable = + pluginListCalls >= (params.marketplaceAvailableAfterListCalls ?? 1); + return { + marketplaces: marketplaceAvailable + ? [ + { + name: "desktop-tools", + path: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + interface: null, + plugins: [pluginSummary(installed)], + }, + ] + : [], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + if (method === "plugin/read") { + expect(requestParams).toEqual( + expect.objectContaining({ + pluginName: "computer-use", + }), + ); + return { + plugin: { + marketplaceName: "desktop-tools", + marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + summary: pluginSummary(installed), + description: "Control desktop apps.", + skills: [], + apps: [], + mcpServers: ["computer-use"], + }, + }; + } + if (method === "plugin/install") { + installed = true; + return { authPolicy: "ON_INSTALL", appsNeedingAuth: [] }; + } + if (method === "config/mcpServer/reload") { + return undefined; + } + if (method === "mcpServerStatus/list") { + return { + data: installed + ? [ + { + name: "computer-use", + tools: { + list_apps: { + name: "list_apps", + inputSchema: { type: "object" }, + }, + }, + resources: [], + resourceTemplates: [], + authStatus: "unsupported", + }, + ] + : [], + nextCursor: null, + }; + } + throw new Error(`unexpected request ${method}`); + }) as CodexComputerUseRequest; +} + +function createAmbiguousComputerUseRequest(): CodexComputerUseRequest { + return vi.fn(async (method: string) => { + if (method === "plugin/list") { + return { + marketplaces: [ + { + name: "desktop-tools", + path: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + interface: null, + plugins: [pluginSummary(true, "desktop-tools")], + }, + { + name: "other-tools", + path: "/marketplaces/other-tools/.agents/plugins/marketplace.json", + interface: null, + plugins: [pluginSummary(true, "other-tools")], + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + throw new Error(`unexpected request ${method}`); + }) as CodexComputerUseRequest; +} + +function createEmptyMarketplaceComputerUseRequest(): CodexComputerUseRequest { + return vi.fn(async (method: string) => { + if (method === "plugin/list") { + return { + marketplaces: [], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + throw new Error(`unexpected request ${method}`); + }) as CodexComputerUseRequest; +} + +function createMultiMarketplaceComputerUseRequest(): CodexComputerUseRequest { + let installed = false; + return vi.fn(async (method: string, requestParams?: unknown) => { + if (method === "experimentalFeature/enablement/set") { + return { enablement: { plugins: true } }; + } + if (method === "plugin/list") { + return { + marketplaces: [ + marketplaceEntry("workspace-tools", false), + marketplaceEntry("openai-curated", installed), + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + if (method === "plugin/read") { + return { + plugin: { + marketplaceName: "openai-curated", + marketplacePath: "/marketplaces/openai-curated/.agents/plugins/marketplace.json", + summary: pluginSummary(installed, "openai-curated"), + description: "Control desktop apps.", + skills: [], + apps: [], + mcpServers: ["computer-use"], + }, + }; + } + if (method === "plugin/install") { + expect(requestParams).toEqual({ + marketplacePath: "/marketplaces/openai-curated/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }); + installed = true; + return { authPolicy: "ON_INSTALL", appsNeedingAuth: [] }; + } + if (method === "config/mcpServer/reload") { + return undefined; + } + if (method === "mcpServerStatus/list") { + return { + data: installed + ? [ + { + name: "computer-use", + tools: { + list_apps: { + name: "list_apps", + inputSchema: { type: "object" }, + }, + }, + resources: [], + resourceTemplates: [], + authStatus: "unsupported", + }, + ] + : [], + nextCursor: null, + }; + } + throw new Error(`unexpected request ${method}`); + }) as CodexComputerUseRequest; +} + +function marketplaceEntry(marketplaceName: string, installed: boolean) { + return { + name: marketplaceName, + path: `/marketplaces/${marketplaceName}/.agents/plugins/marketplace.json`, + interface: null, + plugins: [pluginSummary(installed, marketplaceName)], + }; +} + +function pluginSummary(installed: boolean, marketplaceName = "desktop-tools") { + return { + id: `computer-use@${marketplaceName}`, + name: "computer-use", + source: { type: "local", path: `/marketplaces/${marketplaceName}/plugins/computer-use` }, + installed, + enabled: installed, + installPolicy: "AVAILABLE", + authPolicy: "ON_INSTALL", + interface: null, + }; +} diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts new file mode 100644 index 00000000000..204111dfc82 --- /dev/null +++ b/extensions/codex/src/app-server/computer-use.ts @@ -0,0 +1,511 @@ +import { describeControlFailure } from "./capabilities.js"; +import type { CodexAppServerClient } from "./client.js"; +import { + resolveCodexAppServerRuntimeOptions, + resolveCodexComputerUseConfig, + type CodexComputerUseConfig, + type ResolvedCodexComputerUseConfig, +} from "./config.js"; +import type { JsonValue } from "./protocol.js"; +import { requestCodexAppServerJson } from "./request.js"; +import type { v2 } from "./protocol-generated/typescript/index.js"; + +export type CodexComputerUseRequest = ( + method: string, + params?: unknown, +) => Promise; + +export type CodexComputerUseStatus = { + enabled: boolean; + ready: boolean; + installed: boolean; + pluginEnabled: boolean; + mcpServerAvailable: boolean; + pluginName: string; + mcpServerName: string; + marketplaceName?: string; + marketplacePath?: string; + tools: string[]; + message: string; +}; + +export class CodexComputerUseSetupError extends Error { + readonly status: CodexComputerUseStatus; + + constructor(status: CodexComputerUseStatus) { + super(status.message); + this.name = "CodexComputerUseSetupError"; + this.status = status; + } +} + +export type CodexComputerUseSetupParams = { + pluginConfig?: unknown; + overrides?: Partial; + request?: CodexComputerUseRequest; + client?: CodexAppServerClient; + timeoutMs?: number; + signal?: AbortSignal; + forceEnable?: boolean; +}; + +type MarketplaceRef = { + name?: string; + path?: string; + remoteMarketplaceName?: string; +}; + +type MarketplaceResolution = { + marketplace?: MarketplaceRef; + message?: string; +}; + +const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000; +const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"]; + +export async function readCodexComputerUseStatus( + params: CodexComputerUseSetupParams = {}, +): Promise { + const config = resolveComputerUseConfig(params); + if (!config.enabled) { + return disabledStatus(config); + } + try { + return await inspectCodexComputerUse({ + ...params, + config, + installPlugin: false, + }); + } catch (error) { + return unavailableStatus(config, `Computer Use check failed: ${describeControlFailure(error)}`); + } +} + +export async function ensureCodexComputerUse( + params: CodexComputerUseSetupParams = {}, +): Promise { + const config = resolveComputerUseConfig(params); + if (!config.enabled) { + return disabledStatus(config); + } + const status = await inspectCodexComputerUse({ + ...params, + config, + installPlugin: false, + }); + if (status.ready) { + return status; + } + if (config.autoInstall) { + const blockedAutoInstallStatus = blockUnsafeAutoInstallStatus(config); + if (blockedAutoInstallStatus) { + throw new CodexComputerUseSetupError(blockedAutoInstallStatus); + } + const installedStatus = await inspectCodexComputerUse({ + ...params, + config, + installPlugin: true, + }); + if (!installedStatus.ready) { + throw new CodexComputerUseSetupError(installedStatus); + } + return installedStatus; + } + if (!status.ready) { + throw new CodexComputerUseSetupError(status); + } + return status; +} + +export async function installCodexComputerUse( + params: CodexComputerUseSetupParams = {}, +): Promise { + const config = resolveComputerUseConfig({ + ...params, + forceEnable: true, + overrides: { ...params.overrides, enabled: true, autoInstall: true }, + }); + const status = await inspectCodexComputerUse({ + ...params, + config, + installPlugin: true, + }); + if (!status.ready) { + throw new CodexComputerUseSetupError(status); + } + return status; +} + +async function inspectCodexComputerUse(params: { + pluginConfig?: unknown; + request?: CodexComputerUseRequest; + client?: CodexAppServerClient; + timeoutMs?: number; + signal?: AbortSignal; + config: ResolvedCodexComputerUseConfig; + installPlugin: boolean; +}): Promise { + const request = createComputerUseRequest(params); + if (params.installPlugin) { + await request( + "experimentalFeature/enablement/set", + { + enablement: { plugins: true }, + } satisfies v2.ExperimentalFeatureEnablementSetParams, + ); + } + + const marketplace = await resolveMarketplaceRef({ + request, + config: params.config, + allowAdd: params.installPlugin, + signal: params.signal, + }); + if (!marketplace.marketplace) { + return unavailableStatus( + params.config, + marketplace.message ?? + `No Codex marketplace containing ${params.config.pluginName} is registered. Configure computerUse.marketplaceSource or computerUse.marketplacePath, then run /codex computer-use install.`, + ); + } + + let plugin = await readComputerUsePlugin( + request, + marketplace.marketplace, + params.config.pluginName, + ); + if (!plugin.summary.installed || !plugin.summary.enabled) { + if (!params.installPlugin) { + return statusFromPlugin({ + config: params.config, + plugin, + tools: [], + message: `Computer Use is available but not installed. Run /codex computer-use install or enable computerUse.autoInstall.`, + }); + } + await request( + "plugin/install", + pluginRequestParams( + marketplace.marketplace, + params.config.pluginName, + ) satisfies v2.PluginInstallParams, + ); + await reloadMcpServers(request); + plugin = await readComputerUsePlugin( + request, + marketplace.marketplace, + params.config.pluginName, + ); + } + + let server = await readMcpServerStatus(request, params.config.mcpServerName); + if (!server && params.installPlugin) { + await reloadMcpServers(request); + server = await readMcpServerStatus(request, params.config.mcpServerName); + } + if (!server) { + return statusFromPlugin({ + config: params.config, + plugin, + tools: [], + message: `Computer Use is installed, but the ${params.config.mcpServerName} MCP server is not available.`, + }); + } + + return statusFromPlugin({ + config: params.config, + plugin, + tools: Object.keys(server.tools).toSorted(), + message: "Computer Use is ready.", + }); +} + +async function resolveMarketplaceRef(params: { + request: CodexComputerUseRequest; + config: ResolvedCodexComputerUseConfig; + allowAdd: boolean; + signal?: AbortSignal; +}): Promise { + let preferredMarketplaceName = params.config.marketplaceName; + if (params.config.marketplaceSource && params.allowAdd) { + const added = await params.request("marketplace/add", { + source: params.config.marketplaceSource, + } satisfies v2.MarketplaceAddParams); + preferredMarketplaceName ??= added.marketplaceName; + } + + if (params.config.marketplacePath) { + const marketplace: MarketplaceRef = preferredMarketplaceName + ? { name: preferredMarketplaceName, path: params.config.marketplacePath } + : { path: params.config.marketplacePath }; + return { marketplace }; + } + + let candidates: MarketplaceRef[] = []; + const waitUntil = marketplaceDiscoveryWaitUntil(params); + while (candidates.length === 0) { + const listed = await params.request("plugin/list", { + cwds: [], + } satisfies v2.PluginListParams); + candidates = findComputerUseMarketplaces(listed, params.config.pluginName); + if (candidates.length > 0) { + break; + } + if (Date.now() >= waitUntil) { + break; + } + await delay( + Math.min(CURATED_MARKETPLACE_POLL_INTERVAL_MS, waitUntil - Date.now()), + params.signal, + ); + } + + if (preferredMarketplaceName) { + const preferred = candidates.find((candidate) => candidate.name === preferredMarketplaceName); + if (preferred) { + return { marketplace: preferred }; + } + return { + message: `Configured Codex marketplace ${preferredMarketplaceName} was not found or does not contain ${params.config.pluginName}. Run /codex computer-use install with a source or path to install from a new marketplace.`, + }; + } + if (candidates.length > 1) { + const preferred = chooseKnownComputerUseMarketplace(candidates); + if (preferred) { + return { marketplace: preferred }; + } + return { + message: `Multiple Codex marketplaces contain ${params.config.pluginName}. Configure computerUse.marketplaceName or computerUse.marketplacePath to choose one.`, + }; + } + if (params.config.marketplaceSource && !params.allowAdd && candidates.length === 0) { + return { + message: + "Computer Use marketplace source is configured but has not been registered. Run /codex computer-use install to register it.", + }; + } + const marketplace = candidates[0]; + return marketplace ? { marketplace } : {}; +} + +function blockUnsafeAutoInstallStatus( + config: ResolvedCodexComputerUseConfig, +): CodexComputerUseStatus | undefined { + if (!config.marketplaceSource && !config.marketplacePath) { + return undefined; + } + return unavailableStatus( + config, + "Computer Use auto-install only uses marketplaces Codex app-server has already discovered. Run /codex computer-use install to install from a configured marketplace source or path.", + ); +} + +function findComputerUseMarketplaces( + listed: v2.PluginListResponse, + pluginName: string, +): MarketplaceRef[] { + return listed.marketplaces + .filter((marketplace) => + marketplace.plugins.some( + (plugin) => + plugin.name === pluginName || + plugin.id === pluginName || + plugin.id === `${pluginName}@${marketplace.name}`, + ), + ) + .map((marketplace) => { + if (marketplace.path) { + return { name: marketplace.name, path: marketplace.path }; + } + return { name: marketplace.name, remoteMarketplaceName: marketplace.name }; + }); +} + +function chooseKnownComputerUseMarketplace( + candidates: MarketplaceRef[], +): MarketplaceRef | undefined { + for (const marketplaceName of COMPUTER_USE_MARKETPLACE_NAME_PRIORITY) { + const candidate = candidates.find((marketplace) => marketplace.name === marketplaceName); + if (candidate) { + return candidate; + } + } + return undefined; +} + +function marketplaceDiscoveryWaitUntil(params: { + config: ResolvedCodexComputerUseConfig; + allowAdd: boolean; +}): number { + if ( + params.allowAdd && + !params.config.marketplaceSource && + !params.config.marketplacePath && + !params.config.marketplaceName + ) { + return Date.now() + params.config.marketplaceDiscoveryTimeoutMs; + } + return 0; +} + +async function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw abortError(signal); + } + await new Promise((resolve, reject) => { + let timer: ReturnType; + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(abortError(signal)); + }; + timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function abortError(signal?: AbortSignal): Error { + const reason = signal?.reason; + return reason instanceof Error ? reason : new Error("Computer Use setup was aborted."); +} + +async function readComputerUsePlugin( + request: CodexComputerUseRequest, + marketplace: MarketplaceRef, + pluginName: string, +): Promise { + const response = await request( + "plugin/read", + pluginRequestParams(marketplace, pluginName) satisfies v2.PluginReadParams, + ); + return response.plugin; +} + +async function readMcpServerStatus( + request: CodexComputerUseRequest, + serverName: string, +): Promise { + let cursor: string | null | undefined; + do { + const response = await request("mcpServerStatus/list", { + cursor, + limit: 100, + detail: "toolsAndAuthOnly", + } satisfies v2.ListMcpServerStatusParams); + const found = response.data.find((server) => server.name === serverName); + if (found) { + return found; + } + cursor = response.nextCursor; + } while (cursor); + return undefined; +} + +async function reloadMcpServers(request: CodexComputerUseRequest): Promise { + await request("config/mcpServer/reload", undefined); +} + +function pluginRequestParams(marketplace: MarketplaceRef, pluginName: string) { + return { + ...(marketplace.path ? { marketplacePath: marketplace.path } : {}), + ...(!marketplace.path && marketplace.remoteMarketplaceName + ? { remoteMarketplaceName: marketplace.remoteMarketplaceName } + : {}), + pluginName, + }; +} + +function statusFromPlugin(params: { + config: ResolvedCodexComputerUseConfig; + plugin: v2.PluginDetail; + tools: string[]; + message: string; +}): CodexComputerUseStatus { + return { + enabled: true, + ready: + params.plugin.summary.installed && params.plugin.summary.enabled && params.tools.length > 0, + installed: params.plugin.summary.installed, + pluginEnabled: params.plugin.summary.enabled, + mcpServerAvailable: params.tools.length > 0, + pluginName: params.config.pluginName, + mcpServerName: params.config.mcpServerName, + marketplaceName: params.plugin.marketplaceName, + ...(params.plugin.marketplacePath ? { marketplacePath: params.plugin.marketplacePath } : {}), + tools: params.tools, + message: params.message, + }; +} + +function disabledStatus(config: ResolvedCodexComputerUseConfig): CodexComputerUseStatus { + return { + enabled: false, + ready: false, + installed: false, + pluginEnabled: false, + mcpServerAvailable: false, + pluginName: config.pluginName, + mcpServerName: config.mcpServerName, + tools: [], + message: "Computer Use is disabled.", + }; +} + +function unavailableStatus( + config: ResolvedCodexComputerUseConfig, + message: string, +): CodexComputerUseStatus { + return { + enabled: true, + ready: false, + installed: false, + pluginEnabled: false, + mcpServerAvailable: false, + pluginName: config.pluginName, + mcpServerName: config.mcpServerName, + ...(config.marketplaceName ? { marketplaceName: config.marketplaceName } : {}), + ...(config.marketplacePath ? { marketplacePath: config.marketplacePath } : {}), + tools: [], + message, + }; +} + +function createComputerUseRequest(params: { + pluginConfig?: unknown; + request?: CodexComputerUseRequest; + client?: CodexAppServerClient; + timeoutMs?: number; + signal?: AbortSignal; +}): CodexComputerUseRequest { + if (params.request) { + return params.request; + } + if (params.client) { + return async (method: string, requestParams?: unknown) => + await params.client!.request(method, requestParams, { + timeoutMs: params.timeoutMs, + signal: params.signal, + }); + } + const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig }); + return async (method: string, requestParams?: unknown) => + await requestCodexAppServerJson({ + method, + requestParams, + timeoutMs: params.timeoutMs ?? runtime.requestTimeoutMs, + startOptions: runtime.start, + }); +} + +function resolveComputerUseConfig( + params: Pick, +): ResolvedCodexComputerUseConfig { + const overrides = params.forceEnable ? { ...params.overrides, enabled: true } : params.overrides; + return resolveCodexComputerUseConfig({ + pluginConfig: params.pluginConfig, + overrides, + }); +} diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 19a06f16fd3..79bf770f823 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -2,9 +2,11 @@ import fs from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { CODEX_APP_SERVER_CONFIG_KEYS, + CODEX_COMPUTER_USE_CONFIG_KEYS, codexAppServerStartOptionsKey, readCodexPluginConfig, resolveCodexAppServerRuntimeOptions, + resolveCodexComputerUseConfig, } from "./config.js"; describe("Codex app-server config", () => { @@ -130,6 +132,48 @@ describe("Codex app-server config", () => { ); }); + it("resolves Computer Use setup from plugin config and environment fallbacks", () => { + expect( + resolveCodexComputerUseConfig({ + pluginConfig: { + computerUse: { + autoInstall: true, + marketplaceName: "desktop-tools", + }, + }, + env: { + OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_NAME: "env-fallback-plugin", + }, + }), + ).toEqual({ + enabled: true, + autoInstall: true, + marketplaceDiscoveryTimeoutMs: 60_000, + pluginName: "env-fallback-plugin", + mcpServerName: "computer-use", + marketplaceName: "desktop-tools", + }); + + expect( + resolveCodexComputerUseConfig({ + pluginConfig: {}, + env: { + OPENCLAW_CODEX_COMPUTER_USE: "1", + OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_SOURCE: "github:example/plugins", + OPENCLAW_CODEX_COMPUTER_USE_AUTO_INSTALL: "true", + OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS: "30000", + }, + }), + ).toEqual( + expect.objectContaining({ + enabled: true, + autoInstall: true, + marketplaceDiscoveryTimeoutMs: 30_000, + marketplaceSource: "github:example/plugins", + }), + ); + }); + it("allows plugin config to opt in to guardian-reviewed local execution", () => { const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: { @@ -246,6 +290,7 @@ describe("Codex app-server config", () => { configSchema: { properties: { appServer: { properties: Record }; + computerUse: { properties: Record }; }; }; uiHints: Record; @@ -258,6 +303,13 @@ describe("Codex app-server config", () => { for (const key of CODEX_APP_SERVER_CONFIG_KEYS) { expect(manifest.uiHints[`appServer.${key}`]).toBeTruthy(); } + const computerUseManifestKeys = Object.keys( + manifest.configSchema.properties.computerUse.properties, + ).toSorted(); + expect(computerUseManifestKeys).toEqual([...CODEX_COMPUTER_USE_CONFIG_KEYS].toSorted()); + for (const key of CODEX_COMPUTER_USE_CONFIG_KEYS) { + expect(manifest.uiHints[`computerUse.${key}`]).toBeTruthy(); + } }); it("does not schema-default mode-derived policy fields", async () => { diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 8cff47f7149..a1fc3184dc1 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -9,6 +9,28 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env"; +export type CodexComputerUseConfig = { + enabled?: boolean; + autoInstall?: boolean; + marketplaceDiscoveryTimeoutMs?: number; + marketplaceSource?: string; + marketplacePath?: string; + marketplaceName?: string; + pluginName?: string; + mcpServerName?: string; +}; + +export type ResolvedCodexComputerUseConfig = { + enabled: boolean; + autoInstall: boolean; + marketplaceDiscoveryTimeoutMs: number; + pluginName: string; + mcpServerName: string; + marketplaceSource?: string; + marketplacePath?: string; + marketplaceName?: string; +}; + export type CodexAppServerStartOptions = { transport: CodexAppServerTransportMode; command: string; @@ -35,6 +57,7 @@ export type CodexPluginConfig = { enabled?: boolean; timeoutMs?: number; }; + computerUse?: CodexComputerUseConfig; appServer?: { mode?: CodexAppServerPolicyMode; transport?: CodexAppServerTransportMode; @@ -68,6 +91,21 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [ "defaultWorkspaceDir", ] as const; +export const CODEX_COMPUTER_USE_CONFIG_KEYS = [ + "enabled", + "autoInstall", + "marketplaceDiscoveryTimeoutMs", + "marketplaceSource", + "marketplacePath", + "marketplaceName", + "pluginName", + "mcpServerName", +] as const; + +export const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use"; +export const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use"; +export const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000; + const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]); const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]); const codexAppServerApprovalPolicySchema = z.enum([ @@ -92,6 +130,19 @@ const codexPluginConfigSchema = z }) .strict() .optional(), + computerUse: z + .object({ + enabled: z.boolean().optional(), + autoInstall: z.boolean().optional(), + marketplaceDiscoveryTimeoutMs: z.number().positive().optional(), + marketplaceSource: z.string().optional(), + marketplacePath: z.string().optional(), + marketplaceName: z.string().optional(), + pluginName: z.string().optional(), + mcpServerName: z.string().optional(), + }) + .strict() + .optional(), appServer: z .object({ mode: codexAppServerPolicyModeSchema.optional(), @@ -176,6 +227,64 @@ export function resolveCodexAppServerRuntimeOptions( }; } +export function resolveCodexComputerUseConfig( + params: { + pluginConfig?: unknown; + env?: NodeJS.ProcessEnv; + overrides?: Partial; + } = {}, +): ResolvedCodexComputerUseConfig { + const env = params.env ?? process.env; + const config = readCodexPluginConfig(params.pluginConfig).computerUse ?? {}; + const marketplaceSource = + readNonEmptyString(params.overrides?.marketplaceSource) ?? + readNonEmptyString(config.marketplaceSource) ?? + readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_SOURCE); + const marketplacePath = + readNonEmptyString(params.overrides?.marketplacePath) ?? + readNonEmptyString(config.marketplacePath) ?? + readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_PATH); + const marketplaceName = + readNonEmptyString(params.overrides?.marketplaceName) ?? + readNonEmptyString(config.marketplaceName) ?? + readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_NAME); + const autoInstall = + params.overrides?.autoInstall ?? + config.autoInstall ?? + readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE_AUTO_INSTALL) ?? + false; + const marketplaceDiscoveryTimeoutMs = normalizePositiveNumber( + params.overrides?.marketplaceDiscoveryTimeoutMs ?? + config.marketplaceDiscoveryTimeoutMs ?? + readNumberEnv(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS), + DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS, + ); + const enabled = + params.overrides?.enabled ?? + config.enabled ?? + readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE) ?? + Boolean(autoInstall || marketplaceSource || marketplacePath || marketplaceName); + + return { + enabled, + autoInstall, + marketplaceDiscoveryTimeoutMs, + pluginName: + readNonEmptyString(params.overrides?.pluginName) ?? + readNonEmptyString(config.pluginName) ?? + readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_NAME) ?? + DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME, + mcpServerName: + readNonEmptyString(params.overrides?.mcpServerName) ?? + readNonEmptyString(config.mcpServerName) ?? + readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MCP_SERVER_NAME) ?? + DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME, + ...(marketplaceSource ? { marketplaceSource } : {}), + ...(marketplacePath ? { marketplacePath } : {}), + ...(marketplaceName ? { marketplaceName } : {}), + }; +} + export function codexAppServerStartOptionsKey( options: CodexAppServerStartOptions, params: { authProfileId?: string } = {}, @@ -264,6 +373,28 @@ function normalizeHeaders(value: unknown): Record { ); } +function readBooleanEnv(value: string | undefined): boolean | undefined { + if (value === undefined) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + return undefined; +} + +function readNumberEnv(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + function resolveArgs(configArgs: unknown, envArgs: string | undefined): string[] { if (Array.isArray(configArgs)) { return configArgs diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index c24aa13d769..b6cd07225f4 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -41,6 +41,7 @@ import { defaultCodexAppServerClientFactory, } from "./client-factory.js"; import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js"; +import { ensureCodexComputerUse } from "./computer-use.js"; import { resolveCodexAppServerRuntimeOptions } from "./config.js"; import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; @@ -311,6 +312,12 @@ export async function runCodexAppServerAttempt( signal: runAbortController.signal, operation: async () => { const startupClient = await clientFactory(appServer.start, startupAuthProfileId); + await ensureCodexComputerUse({ + client: startupClient, + pluginConfig: options.pluginConfig, + timeoutMs: appServer.requestTimeoutMs, + signal: runAbortController.signal, + }); const startupThread = await startOrResumeThread({ client: startupClient, params, diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index d84c3d52336..7ad43c15604 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -1,4 +1,5 @@ import type { CodexAppServerModelListResult } from "./app-server/models.js"; +import type { CodexComputerUseStatus } from "./app-server/computer-use.js"; import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js"; import type { SafeValue } from "./command-rpc.js"; @@ -89,6 +90,28 @@ export function formatAccount( ].join("\n"); } +export function formatComputerUseStatus(status: CodexComputerUseStatus): string { + const lines = [ + `Computer Use: ${status.ready ? "ready" : status.enabled ? "not ready" : "disabled"}`, + ]; + lines.push( + `Plugin: ${status.pluginName}${status.installed ? " (installed)" : " (not installed)"}`, + ); + lines.push( + `MCP server: ${status.mcpServerName}${ + status.mcpServerAvailable ? ` (${status.tools.length} tools)` : " (unavailable)" + }`, + ); + if (status.marketplaceName) { + lines.push(`Marketplace: ${status.marketplaceName}`); + } + if (status.tools.length > 0) { + lines.push(`Tools: ${status.tools.slice(0, 8).join(", ")}`); + } + lines.push(status.message); + return lines.join("\n"); +} + export function formatList(response: JsonValue | undefined, label: string): string { const entries = extractArray(response); if (entries.length === 0) { @@ -120,6 +143,7 @@ export function buildHelp(): string { "- /codex detach", "- /codex compact", "- /codex review", + "- /codex computer-use [status|install]", "- /codex account", "- /codex mcp", "- /codex skills", diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 59278456826..38a5e1b1ba2 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -1,5 +1,11 @@ import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; +import { + installCodexComputerUse, + readCodexComputerUseStatus, + type CodexComputerUseSetupParams, +} from "./app-server/computer-use.js"; +import type { CodexComputerUseConfig } from "./app-server/config.js"; import { listAllCodexAppServerModels } from "./app-server/models.js"; import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; import { @@ -10,6 +16,7 @@ import { import { buildHelp, formatAccount, + formatComputerUseStatus, formatCodexStatus, formatList, formatModels, @@ -49,6 +56,8 @@ export type CodexCommandDeps = { safeCodexControlRequest: SafeCodexControlRequestFn; writeCodexAppServerBinding: typeof writeCodexAppServerBinding; clearCodexAppServerBinding: typeof clearCodexAppServerBinding; + readCodexComputerUseStatus: typeof readCodexComputerUseStatus; + installCodexComputerUse: typeof installCodexComputerUse; resolveCodexDefaultWorkspaceDir: typeof resolveCodexDefaultWorkspaceDir; startCodexConversationThread: typeof startCodexConversationThread; readCodexConversationActiveTurn: typeof readCodexConversationActiveTurn; @@ -80,6 +89,8 @@ const defaultCodexCommandDeps: CodexCommandDeps = { safeCodexControlRequest, writeCodexAppServerBinding, clearCodexAppServerBinding, + readCodexComputerUseStatus, + installCodexComputerUse, resolveCodexDefaultWorkspaceDir, startCodexConversationThread, readCodexConversationActiveTurn, @@ -98,6 +109,13 @@ type ParsedBindArgs = { help?: boolean; }; +type ParsedComputerUseArgs = { + action: "status" | "install"; + overrides: Partial; + hasOverrides: boolean; + help?: boolean; +}; + export async function handleCodexSubcommand( ctx: PluginCommandContext, options: { pluginConfig?: unknown; deps?: Partial }, @@ -170,6 +188,11 @@ export async function handleCodexSubcommand( ), }; } + if (normalized === "computer-use" || normalized === "computeruse") { + return { + text: await handleComputerUseCommand(deps, options.pluginConfig, rest), + }; + } if (normalized === "mcp") { return { text: formatList( @@ -204,6 +227,29 @@ export async function handleCodexSubcommand( return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` }; } +async function handleComputerUseCommand( + deps: CodexCommandDeps, + pluginConfig: unknown, + args: string[], +): Promise { + const parsed = parseComputerUseArgs(args); + if (parsed.help) { + return [ + "Usage: /codex computer-use [status|install] [--source ] [--marketplace-path ] [--marketplace ]", + "Checks or installs the configured Codex Computer Use plugin through app-server.", + ].join("\n"); + } + const params: CodexComputerUseSetupParams = { + pluginConfig, + forceEnable: parsed.action === "install" || parsed.hasOverrides, + ...(Object.keys(parsed.overrides).length > 0 ? { overrides: parsed.overrides } : {}), + }; + if (parsed.action === "install") { + return formatComputerUseStatus(await deps.installCodexComputerUse(params)); + } + return formatComputerUseStatus(await deps.readCodexComputerUseStatus(params)); +} + async function bindConversation( deps: CodexCommandDeps, ctx: PluginCommandContext, @@ -504,6 +550,114 @@ function parseBindArgs(args: string[]): ParsedBindArgs { return parsed; } +function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { + const parsed: ParsedComputerUseArgs = { + action: "status", + overrides: {}, + hasOverrides: false, + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + parsed.help = true; + continue; + } + if (arg === "status" || arg === "install") { + parsed.action = arg; + continue; + } + if (arg === "--source" || arg === "--marketplace-source") { + const value = readRequiredOptionValue(args, index); + if (!value) { + parsed.help = true; + continue; + } + parsed.overrides.marketplaceSource = value; + index += 1; + continue; + } + if (arg === "--marketplace-path" || arg === "--path") { + const value = readRequiredOptionValue(args, index); + if (!value) { + parsed.help = true; + continue; + } + parsed.overrides.marketplacePath = value; + index += 1; + continue; + } + if (arg === "--marketplace") { + const value = readRequiredOptionValue(args, index); + if (!value) { + parsed.help = true; + continue; + } + parsed.overrides.marketplaceName = value; + index += 1; + continue; + } + if (arg === "--plugin") { + const value = readRequiredOptionValue(args, index); + if (!value) { + parsed.help = true; + continue; + } + parsed.overrides.pluginName = value; + index += 1; + continue; + } + if (arg === "--server" || arg === "--mcp-server") { + const value = readRequiredOptionValue(args, index); + if (!value) { + parsed.help = true; + continue; + } + parsed.overrides.mcpServerName = value; + index += 1; + continue; + } + parsed.help = true; + } + parsed.overrides = normalizeComputerUseStringOverrides(parsed.overrides); + parsed.hasOverrides = Object.values(parsed.overrides).some(Boolean); + return parsed; +} + +function readRequiredOptionValue(args: string[], index: number): string | undefined { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return undefined; + } + return value; +} + +function normalizeComputerUseStringOverrides( + overrides: Partial, +): Partial { + const normalized: Partial = {}; + const marketplaceSource = normalizeOptionalString(overrides.marketplaceSource); + if (marketplaceSource) { + normalized.marketplaceSource = marketplaceSource; + } + const marketplacePath = normalizeOptionalString(overrides.marketplacePath); + if (marketplacePath) { + normalized.marketplacePath = marketplacePath; + } + const marketplaceName = normalizeOptionalString(overrides.marketplaceName); + if (marketplaceName) { + normalized.marketplaceName = marketplaceName; + } + const pluginName = normalizeOptionalString(overrides.pluginName); + if (pluginName) { + normalized.pluginName = pluginName; + } + const mcpServerName = normalizeOptionalString(overrides.mcpServerName); + if (mcpServerName) { + normalized.mcpServerName = mcpServerName; + } + return normalized; +} + function normalizeOptionalString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed || undefined; diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index f8ec23bd407..93bd6b6c360 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; +import type { CodexComputerUseStatus } from "./app-server/computer-use.js"; import type { CodexAppServerStartOptions } from "./app-server/config.js"; import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js"; import type { CodexCommandDeps } from "./command-handlers.js"; @@ -241,6 +242,67 @@ describe("codex command", () => { }); }); + it("checks Codex Computer Use setup", async () => { + const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus()); + + await expect( + handleCodexCommand(createContext("computer-use status"), { + deps: createDeps({ readCodexComputerUseStatus }), + }), + ).resolves.toEqual({ + text: [ + "Computer Use: ready", + "Plugin: computer-use (installed)", + "MCP server: computer-use (1 tools)", + "Marketplace: desktop-tools", + "Tools: list_apps", + "Computer Use is ready.", + ].join("\n"), + }); + expect(readCodexComputerUseStatus).toHaveBeenCalledWith({ + pluginConfig: undefined, + forceEnable: false, + }); + }); + + it("installs Codex Computer Use from command overrides", async () => { + const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); + + await expect( + handleCodexCommand( + createContext( + "computer-use install --source github:example/desktop-tools --marketplace desktop-tools", + ), + { + deps: createDeps({ installCodexComputerUse }), + }, + ), + ).resolves.toEqual({ + text: expect.stringContaining("Computer Use: ready"), + }); + expect(installCodexComputerUse).toHaveBeenCalledWith({ + pluginConfig: undefined, + forceEnable: true, + overrides: { + marketplaceSource: "github:example/desktop-tools", + marketplaceName: "desktop-tools", + }, + }); + }); + + it("shows help when Computer Use option values are missing", async () => { + const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); + + await expect( + handleCodexCommand(createContext("computer-use install --source"), { + deps: createDeps({ installCodexComputerUse }), + }), + ).resolves.toEqual({ + text: expect.stringContaining("Usage: /codex computer-use"), + }); + expect(installCodexComputerUse).not.toHaveBeenCalled(); + }); + it("explains compaction when no Codex thread is attached", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); @@ -600,3 +662,18 @@ describe("codex command", () => { }); }); }); + +function computerUseReadyStatus(): CodexComputerUseStatus { + return { + enabled: true, + ready: true, + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + pluginName: "computer-use", + mcpServerName: "computer-use", + marketplaceName: "desktop-tools", + tools: ["list_apps"], + message: "Computer Use is ready.", + }; +} diff --git a/src/commands/auth-choice-legacy.test.ts b/src/commands/auth-choice-legacy.test.ts index e31f40ba441..e4df5332326 100644 --- a/src/commands/auth-choice-legacy.test.ts +++ b/src/commands/auth-choice-legacy.test.ts @@ -32,19 +32,34 @@ import { resolveDeprecatedAuthChoiceReplacement, } from "./auth-choice-legacy.js"; +function authChoiceManifestEnv(): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: "extensions", + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "0", + OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + VITEST: "1", + } as NodeJS.ProcessEnv; +} + describe("auth choice legacy aliases", () => { it("maps claude-cli to the new anthropic cli choice", () => { - expect(normalizeLegacyOnboardAuthChoice("claude-cli")).toBe("anthropic-cli"); - expect(resolveDeprecatedAuthChoiceReplacement("claude-cli")).toEqual({ + const env = authChoiceManifestEnv(); + expect(normalizeLegacyOnboardAuthChoice("claude-cli", { env })).toBe("anthropic-cli"); + expect(resolveDeprecatedAuthChoiceReplacement("claude-cli", { env })).toEqual({ normalized: "anthropic-cli", message: 'Auth choice "claude-cli" is deprecated; using Anthropic Claude CLI setup instead.', }); - expect(formatDeprecatedNonInteractiveAuthChoiceError("claude-cli")).toBe( + expect(formatDeprecatedNonInteractiveAuthChoiceError("claude-cli", { env })).toBe( 'Auth choice "claude-cli" is deprecated.\nUse "--auth-choice anthropic-cli".', ); }); it("sources deprecated cli aliases from plugin manifests", () => { - expect(resolveLegacyAuthChoiceAliasesForCli()).toEqual(["claude-cli", "codex-cli"]); + expect(resolveLegacyAuthChoiceAliasesForCli({ env: authChoiceManifestEnv() })).toEqual([ + "claude-cli", + "codex-cli", + ]); }); }); diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 711486b7253..3984a980089 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -99,6 +99,7 @@ import fs from "node:fs"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; import { pinActivePluginChannelRegistry, @@ -159,37 +160,15 @@ function makeSkipInstallPrompter() { return { prompter, select }; } -function makeManifestRecord(plugin: { - id: string; - channels?: string[]; - origin?: "bundled" | "global" | "workspace"; - activation?: { onChannels?: string[] }; -}) { - const rootDir = `/tmp/openclaw-plugins/${plugin.id}`; - return { - id: plugin.id, - origin: plugin.origin ?? "bundled", - channels: plugin.channels ?? [], - providers: [], - cliBackends: [], - hooks: [], - skills: [], - rootDir, - source: path.join(rootDir, "index.js"), - manifestPath: path.join(rootDir, "openclaw.plugin.json"), - ...(plugin.activation ? { activation: plugin.activation } : {}), - }; -} - function mockActivationOnlyPlugin(plugin: { id: string; origin?: "bundled" | "global" | "workspace"; }) { loadPluginManifestRegistry.mockReturnValue({ plugins: [ - makeManifestRecord({ + createManifestRecord({ id: plugin.id, - origin: plugin.origin, + ...(plugin.origin === undefined ? {} : { origin: plugin.origin }), activation: { onChannels: ["external-chat"], }, @@ -199,6 +178,27 @@ function mockActivationOnlyPlugin(plugin: { }); } +function createManifestRecord( + overrides: Partial & Pick, +): PluginManifestRecord { + const { id, ...rest } = overrides; + return { + id, + channels: [], + providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], + skills: [], + hooks: [], + origin: "bundled", + rootDir: `/tmp/openclaw-test/${id}`, + source: `/tmp/openclaw-test/${id}/index.ts`, + manifestPath: `/tmp/openclaw-test/${id}/openclaw.plugin.json`, + ...rest, + }; +} + function expectSetupSnapshotDoesNotScopeToPlugin(params: { cfg: OpenClawConfig; runtime: ReturnType; @@ -216,10 +216,10 @@ function expectSetupSnapshotDoesNotScopeToPlugin(params: { onlyPluginIds: [params.pluginId], }), ); - expect( - (vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] }) - .onlyPluginIds, - ).toBeUndefined(); + const firstLoadCall = vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as + | { onlyPluginIds?: string[] } + | undefined; + expect(firstLoadCall?.onlyPluginIds).toBeUndefined(); } beforeEach(() => { @@ -789,7 +789,7 @@ describe("ensureChannelSetupPluginInstalled", () => { const cfg: OpenClawConfig = {}; loadPluginManifestRegistry.mockReturnValue({ plugins: [ - makeManifestRecord({ + createManifestRecord({ id: "custom-external-chat-plugin", channels: ["external-chat"], }), diff --git a/src/commands/channel-setup/workspace-shadow-bypass.test.ts b/src/commands/channel-setup/workspace-shadow-bypass.test.ts index 69c78073ecc..78cb990846a 100644 --- a/src/commands/channel-setup/workspace-shadow-bypass.test.ts +++ b/src/commands/channel-setup/workspace-shadow-bypass.test.ts @@ -8,6 +8,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; // --------------------------------------------------------------------------- // Mocks (hoisted to module top level) @@ -92,6 +93,21 @@ function createWorkspaceCatalogEntry(id: string, label: string) { }; } +function createManifestChannelPlugin(id: string, channels: string[]): PluginManifestRecord { + return { + id, + channels, + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "workspace", + rootDir: `/tmp/openclaw-test/${id}`, + source: `/tmp/openclaw-test/${id}/index.ts`, + manifestPath: `/tmp/openclaw-test/${id}/openclaw.plugin.json`, + }; +} + function mockWorkspaceOnlyCatalogEntry(entry: ReturnType) { listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace ? [] : [entry], @@ -190,7 +206,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x- }; listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]); loadPluginManifestRegistry.mockReturnValue({ - plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }], + plugins: [createManifestChannelPlugin("trusted-telegram-shadow", ["telegram"])], diagnostics: [], }); listPluginContributionIds.mockReturnValue(["telegram"]); @@ -241,7 +257,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x- }, })); loadPluginManifestRegistry.mockReturnValue({ - plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }], + plugins: [createManifestChannelPlugin("trusted-telegram-shadow", ["telegram"])], diagnostics: [], }); listPluginContributionIds.mockReturnValue(["telegram"]); @@ -286,7 +302,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x- autoEnabledReasons: {}, })); loadPluginManifestRegistry.mockReturnValue({ - plugins: [{ id: "my-cool-plugin", channels: ["my-cool-plugin"] }], + plugins: [createManifestChannelPlugin("my-cool-plugin", ["my-cool-plugin"])], diagnostics: [], }); listPluginContributionIds.mockReturnValue(["my-cool-plugin"]); diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts index 5f2ba70781e..a758be3bdf6 100644 --- a/src/config/plugin-auto-enable.test-helpers.ts +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -29,6 +29,8 @@ export function makeIsolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.Proce const rootDir = makeTempDir(); return { OPENCLAW_STATE_DIR: path.join(rootDir, "state"), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"), + VITEST: "true", ...overrides, }; } diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index de5630f1e7f..eaaea2ba07d 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -43,6 +43,9 @@ function createManifestRegistryFixture() { manifestPath: "/tmp/firecrawl/openclaw.plugin.json", channels: [], providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], skills: [], hooks: [], configUiHints: { "webFetch.apiKey": { label: "key" } }, @@ -55,6 +58,9 @@ function createManifestRegistryFixture() { manifestPath: "/tmp/noise/openclaw.plugin.json", channels: [], providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], skills: [], hooks: [], configUiHints: { unrelated: { label: "nope" } }, diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 228d60c1769..8f8cb050836 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -147,6 +147,9 @@ function createManifestRegistryFixture() { manifestPath: "/tmp/brave/openclaw.plugin.json", channels: [], providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], skills: [], hooks: [], configUiHints: { "webSearch.apiKey": { label: "key" } }, @@ -159,6 +162,9 @@ function createManifestRegistryFixture() { manifestPath: "/tmp/noise/openclaw.plugin.json", channels: [], providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], skills: [], hooks: [], configUiHints: { unrelated: { label: "nope" } }, diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index a716dcb4420..c72805a8d99 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -474,6 +474,8 @@ describe("test-projects args", () => { const configs = buildFullSuiteVitestRunPlans([]).map((plan) => plan.config); expect(configs).toContain("test/vitest/vitest.full-core-unit-fast.config.ts"); + expect(configs).toContain("test/vitest/vitest.full-core-support-boundary.config.ts"); + expect(configs).not.toContain("test/vitest/vitest.boundary.config.ts"); expect(configs).toContain("test/vitest/vitest.full-agentic.config.ts"); expect(configs).not.toContain("test/vitest/vitest.agents.config.ts"); expect(configs).not.toContain("test/vitest/vitest.plugins.config.ts"); diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index a28b460ab80..cd1438e9d70 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -84,7 +84,7 @@ const disqualifyingPatterns = [ }, { code: "module-mocking-helper", - pattern: /runtime-module-mocks/u, + pattern: /(?:runtime-module-mocks|plugins-cli-test-helpers)/u, }, { code: "vitest-mock-api",