diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 3234323472a..209b5713d48 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -590,7 +590,10 @@ 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 +Codex app-server, use `marketplaceName` instead. `marketplaceName` can also +select a remote-only Codex catalog entry for status checks, but Codex app-server +does not yet support remote `plugin/install`; installs and re-enables still need +`marketplaceSource` or `marketplacePath`. 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 diff --git a/extensions/codex/src/app-server/computer-use.test.ts b/extensions/codex/src/app-server/computer-use.test.ts index 13f9e872e12..98be0d2d03f 100644 --- a/extensions/codex/src/app-server/computer-use.test.ts +++ b/extensions/codex/src/app-server/computer-use.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - CodexComputerUseSetupError, ensureCodexComputerUse, installCodexComputerUse, readCodexComputerUseStatus, @@ -19,6 +18,7 @@ describe("Codex Computer Use setup", () => { expect.objectContaining({ enabled: false, ready: false, + reason: "disabled", message: "Computer Use is disabled.", }), ); @@ -36,6 +36,7 @@ describe("Codex Computer Use setup", () => { expect.objectContaining({ enabled: true, ready: true, + reason: "ready", installed: true, pluginEnabled: true, mcpServerAvailable: true, @@ -63,6 +64,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: false, + reason: "plugin_disabled", installed: true, pluginEnabled: false, mcpServerAvailable: false, @@ -89,6 +91,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", message: "Computer Use is ready.", }), ); @@ -110,6 +113,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: false, + reason: "marketplace_missing", message: "Multiple Codex marketplaces contain computer-use. Configure computerUse.marketplaceName or computerUse.marketplacePath to choose one.", }), @@ -132,6 +136,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", installed: true, pluginEnabled: true, tools: ["list_apps"], @@ -161,6 +166,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", installed: true, pluginEnabled: true, message: "Computer Use is ready.", @@ -180,7 +186,11 @@ describe("Codex Computer Use setup", () => { pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } }, request, }), - ).rejects.toThrow(CodexComputerUseSetupError); + ).rejects.toMatchObject({ + status: expect.objectContaining({ + reason: "plugin_not_installed", + }), + }); expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything()); }); @@ -201,6 +211,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", message: "Computer Use is ready.", }), ); @@ -228,6 +239,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", message: "Computer Use is ready.", }), ); @@ -255,7 +267,11 @@ describe("Codex Computer Use setup", () => { }, request, }), - ).rejects.toThrow(CodexComputerUseSetupError); + ).rejects.toMatchObject({ + status: expect.objectContaining({ + reason: "auto_install_blocked", + }), + }); expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything()); expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything()); }); @@ -276,6 +292,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: false, + reason: "marketplace_missing", 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.", }), @@ -294,6 +311,7 @@ describe("Codex Computer Use setup", () => { ).rejects.toMatchObject({ status: expect.objectContaining({ ready: false, + reason: "remote_install_unsupported", installed: false, pluginEnabled: false, marketplaceName: "openai-curated", @@ -320,6 +338,7 @@ describe("Codex Computer Use setup", () => { await expect(installed).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", message: "Computer Use is ready.", }), ); @@ -343,6 +362,7 @@ describe("Codex Computer Use setup", () => { ).resolves.toEqual( expect.objectContaining({ ready: true, + reason: "ready", marketplaceName: "openai-curated", }), ); diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts index 786547d226c..b4ff01df8fd 100644 --- a/extensions/codex/src/app-server/computer-use.ts +++ b/extensions/codex/src/app-server/computer-use.ts @@ -15,9 +15,21 @@ export type CodexComputerUseRequest = ( params?: unknown, ) => Promise; +export type CodexComputerUseStatusReason = + | "disabled" + | "marketplace_missing" + | "plugin_not_installed" + | "plugin_disabled" + | "remote_install_unsupported" + | "mcp_missing" + | "ready" + | "check_failed" + | "auto_install_blocked"; + export type CodexComputerUseStatus = { enabled: boolean; ready: boolean; + reason: CodexComputerUseStatusReason; installed: boolean; pluginEnabled: boolean; mcpServerAvailable: boolean; @@ -49,17 +61,33 @@ export type CodexComputerUseSetupParams = { forceEnable?: boolean; }; -type MarketplaceRef = { - name?: string; - path?: string; - remoteMarketplaceName?: string; -}; +type MarketplaceRef = + | { + kind: "local"; + name?: string; + path: string; + } + | { + kind: "remote"; + name: string; + remoteMarketplaceName: string; + }; type MarketplaceResolution = { marketplace?: MarketplaceRef; message?: string; }; +type PluginInspection = + | { + ok: true; + plugin: v2.PluginDetail; + } + | { + ok: false; + status: CodexComputerUseStatus; + }; + const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000; const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"]; @@ -77,7 +105,11 @@ export async function readCodexComputerUseStatus( installPlugin: false, }); } catch (error) { - return unavailableStatus(config, `Computer Use check failed: ${describeControlFailure(error)}`); + return unavailableStatus( + config, + "check_failed", + `Computer Use check failed: ${describeControlFailure(error)}`, + ); } } @@ -164,74 +196,121 @@ async function inspectCodexComputerUse(params: { if (!marketplace.marketplace) { return unavailableStatus( params.config, + "marketplace_missing", 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( + const pluginInspection = await ensureComputerUsePlugin({ request, - marketplace.marketplace, + config: params.config, + marketplace: marketplace.marketplace, + installPlugin: params.installPlugin, + }); + if (!pluginInspection.ok) { + return pluginInspection.status; + } + + return await readComputerUseTools({ + request, + config: params.config, + plugin: pluginInspection.plugin, + installPlugin: params.installPlugin, + }); +} + +async function ensureComputerUsePlugin(params: { + request: CodexComputerUseRequest; + config: ResolvedCodexComputerUseConfig; + marketplace: MarketplaceRef; + installPlugin: boolean; +}): Promise { + let plugin = await readComputerUsePlugin( + params.request, + params.marketplace, params.config.pluginName, ); if (!plugin.summary.installed || !plugin.summary.enabled) { if (!params.installPlugin) { - return statusFromPlugin({ - config: params.config, - plugin, - tools: [], - message: pluginSetupMessage(params.config, plugin, marketplace.marketplace), - }); + return { + ok: false, + status: statusFromPlugin({ + config: params.config, + plugin, + tools: [], + reason: pluginSetupReason(plugin, params.marketplace), + message: pluginSetupMessage(params.config, plugin, params.marketplace), + }), + }; } - if (!marketplace.marketplace.path) { - return statusFromPlugin({ - config: params.config, - plugin, - tools: [], - message: remoteInstallUnsupportedMessage(plugin, marketplace.marketplace), - }); + if (params.marketplace.kind === "remote") { + return { + ok: false, + status: statusFromPlugin({ + config: params.config, + plugin, + tools: [], + reason: "remote_install_unsupported", + message: remoteInstallUnsupportedMessage(plugin, params.marketplace), + }), + }; } - await request( + await params.request( "plugin/install", pluginRequestParams( - marketplace.marketplace, + params.marketplace, params.config.pluginName, ) satisfies v2.PluginInstallParams, ); - await reloadMcpServers(request); + await reloadMcpServers(params.request); plugin = await readComputerUsePlugin( - request, - marketplace.marketplace, + params.request, + params.marketplace, params.config.pluginName, ); } if (!plugin.summary.installed || !plugin.summary.enabled) { - return statusFromPlugin({ - config: params.config, - plugin, - tools: [], - message: pluginSetupMessage(params.config, plugin, marketplace.marketplace), - }); + return { + ok: false, + status: statusFromPlugin({ + config: params.config, + plugin, + tools: [], + reason: pluginSetupReason(plugin, params.marketplace), + message: pluginSetupMessage(params.config, plugin, params.marketplace), + }), + }; } + return { ok: true, plugin }; +} - let server = await readMcpServerStatus(request, params.config.mcpServerName); +async function readComputerUseTools(params: { + request: CodexComputerUseRequest; + config: ResolvedCodexComputerUseConfig; + plugin: v2.PluginDetail; + installPlugin: boolean; +}): Promise { + let server = await readMcpServerStatus(params.request, params.config.mcpServerName); if (!server && params.installPlugin) { - await reloadMcpServers(request); - server = await readMcpServerStatus(request, params.config.mcpServerName); + await reloadMcpServers(params.request); + server = await readMcpServerStatus(params.request, params.config.mcpServerName); } if (!server) { return statusFromPlugin({ config: params.config, - plugin, + plugin: params.plugin, tools: [], + reason: "mcp_missing", message: `Computer Use is installed, but the ${params.config.mcpServerName} MCP server is not available.`, }); } return statusFromPlugin({ config: params.config, - plugin, + plugin: params.plugin, tools: Object.keys(server.tools).toSorted(), + reason: "ready", message: "Computer Use is ready.", }); } @@ -252,8 +331,8 @@ async function resolveMarketplaceRef(params: { if (params.config.marketplacePath) { const marketplace: MarketplaceRef = preferredMarketplaceName - ? { name: preferredMarketplaceName, path: params.config.marketplacePath } - : { path: params.config.marketplacePath }; + ? { kind: "local", name: preferredMarketplaceName, path: params.config.marketplacePath } + : { kind: "local", path: params.config.marketplacePath }; return { marketplace }; } @@ -312,6 +391,7 @@ function blockUnsafeAutoInstallStatus( } return unavailableStatus( config, + "auto_install_blocked", "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.", ); } @@ -331,9 +411,9 @@ function findComputerUseMarketplaces( ) .map((marketplace) => { if (marketplace.path) { - return { name: marketplace.name, path: marketplace.path }; + return { kind: "local", name: marketplace.name, path: marketplace.path }; } - return { name: marketplace.name, remoteMarketplaceName: marketplace.name }; + return { kind: "remote", name: marketplace.name, remoteMarketplaceName: marketplace.name }; }); } @@ -426,20 +506,30 @@ async function reloadMcpServers(request: CodexComputerUseRequest): Promise function pluginRequestParams(marketplace: MarketplaceRef, pluginName: string) { return { - ...(marketplace.path ? { marketplacePath: marketplace.path } : {}), - ...(!marketplace.path && marketplace.remoteMarketplaceName + ...(marketplace.kind === "local" ? { marketplacePath: marketplace.path } : {}), + ...(marketplace.kind === "remote" ? { remoteMarketplaceName: marketplace.remoteMarketplaceName } : {}), pluginName, }; } +function pluginSetupReason( + plugin: v2.PluginDetail, + marketplace: MarketplaceRef, +): CodexComputerUseStatusReason { + if (marketplace.kind === "remote") { + return "remote_install_unsupported"; + } + return plugin.summary.installed ? "plugin_disabled" : "plugin_not_installed"; +} + function pluginSetupMessage( config: ResolvedCodexComputerUseConfig, plugin: v2.PluginDetail, marketplace: MarketplaceRef, ): string { - if (!marketplace.path) { + if (marketplace.kind === "remote") { return remoteInstallUnsupportedMessage(plugin, marketplace); } if (!plugin.summary.installed) { @@ -461,12 +551,14 @@ function statusFromPlugin(params: { config: ResolvedCodexComputerUseConfig; plugin: v2.PluginDetail; tools: string[]; + reason: CodexComputerUseStatusReason; message: string; }): CodexComputerUseStatus { return { enabled: true, ready: params.plugin.summary.installed && params.plugin.summary.enabled && params.tools.length > 0, + reason: params.reason, installed: params.plugin.summary.installed, pluginEnabled: params.plugin.summary.enabled, mcpServerAvailable: params.tools.length > 0, @@ -483,6 +575,7 @@ function disabledStatus(config: ResolvedCodexComputerUseConfig): CodexComputerUs return { enabled: false, ready: false, + reason: "disabled", installed: false, pluginEnabled: false, mcpServerAvailable: false, @@ -495,11 +588,13 @@ function disabledStatus(config: ResolvedCodexComputerUseConfig): CodexComputerUs function unavailableStatus( config: ResolvedCodexComputerUseConfig, + reason: CodexComputerUseStatusReason, message: string, ): CodexComputerUseStatus { return { enabled: true, ready: false, + reason, installed: false, pluginEnabled: false, mcpServerAvailable: false, diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 6346c0e83fe..033d18af243 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -94,9 +94,7 @@ 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(`Plugin: ${status.pluginName} (${computerUsePluginState(status)})`); lines.push( `MCP server: ${status.mcpServerName}${ status.mcpServerAvailable ? ` (${status.tools.length} tools)` : " (unavailable)" @@ -112,6 +110,13 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string return lines.join("\n"); } +function computerUsePluginState(status: CodexComputerUseStatus): string { + if (!status.installed) { + return "not installed"; + } + return status.pluginEnabled ? "installed" : "installed, disabled"; +} + export function formatList(response: JsonValue | undefined, label: string): string { const entries = extractArray(response); if (entries.length === 0) { diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 93bd6b6c360..591fdcc5edd 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -265,6 +265,27 @@ describe("codex command", () => { }); }); + it("formats disabled installed Codex Computer Use plugins", async () => { + const readCodexComputerUseStatus = vi.fn(async () => ({ + ...computerUseReadyStatus(), + ready: false, + reason: "plugin_disabled" as const, + pluginEnabled: false, + mcpServerAvailable: false, + tools: [], + message: + "Computer Use is installed, but the computer-use plugin is disabled. Run /codex computer-use install or enable computerUse.autoInstall to re-enable it.", + })); + + await expect( + handleCodexCommand(createContext("computer-use status"), { + deps: createDeps({ readCodexComputerUseStatus }), + }), + ).resolves.toEqual({ + text: expect.stringContaining("Plugin: computer-use (installed, disabled)"), + }); + }); + it("installs Codex Computer Use from command overrides", async () => { const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); @@ -667,6 +688,7 @@ function computerUseReadyStatus(): CodexComputerUseStatus { return { enabled: true, ready: true, + reason: "ready", installed: true, pluginEnabled: true, mcpServerAvailable: true,