diff --git a/docs/docs_map.md b/docs/docs_map.md index f69b081dd340..cb894461238c 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5404,6 +5404,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Commands - H2: Marketplace choices - H2: Bundled macOS marketplace + - H3: Shared plugin cache - H2: Remote catalog limit - H2: Configuration reference - H2: What OpenClaw checks diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index 3805970386b7..ff0a08c53873 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -208,6 +208,17 @@ If you use a nonstandard Codex app path, run `/codex computer-use install local marketplace file path. Use `--marketplace-path` only when you have the marketplace JSON file path, not the bundled marketplace root. +### Shared plugin cache + +The default `pluginCacheMode: "independent"` leaves each Codex home and its +plugin cache unmanaged. Set `pluginCacheMode: "shared"` to copy the bundled +Computer Use plugin into the active Codex home's discoverable plugin cache +before app-server startup. Shared mode preserves older cached versions because +running Codex clients can still reference their versioned plugin directories; a +failed replacement copy also preserves the active cache. Explicit +`marketplaceName` or `marketplacePath` configuration disables this +reconciliation so OpenClaw does not override that selection. + ## Remote catalog limit Codex app-server can list and read remote-only catalog entries, but it does @@ -231,6 +242,13 @@ remote install is unsupported, run install with a local source or path: | `enabled` | inferred | Require Computer Use. Defaults to true when another Computer Use field is set. | | `autoInstall` | false | Install or re-enable from already discovered marketplaces at turn start. | | `marketplaceDiscoveryTimeoutMs` | 60000 | How long install waits for Codex app-server marketplace discovery. | +| `liveTestTimeoutMs` | 60000 | Timeout for the temporary readiness thread and its cleanup requests. | +| `toolCallTimeoutMs` | 60000 | Timeout for the Computer Use `list_apps` readiness tool call. | +| `healthCheckEnabled` | false | Run periodic readiness probes while the owning app-server client is active. | +| `healthCheckIntervalMinutes` | 60 | Probe cadence; accepted values are 30, 60, 120, or 240 minutes. | +| `pluginCacheMode` | `independent` | Use `shared` to refresh the Codex-home cache from the bundled desktop plugin. | +| `strictReadiness` | false | Stop startup on a failed live probe instead of continuing with a warning. | +| `autoRepair` | false | Kill stale scoped Computer Use MCP children and retry a failed probe once. | | `marketplaceSource` | unset | Source string passed to Codex app-server `marketplace/add`. | | `marketplacePath` | unset | Local Codex marketplace file path containing the plugin. | | `marketplaceName` | unset | Registered Codex marketplace name to select. | @@ -252,6 +270,13 @@ matching config key is unset: | `enabled` | `OPENCLAW_CODEX_COMPUTER_USE` | | `autoInstall` | `OPENCLAW_CODEX_COMPUTER_USE_AUTO_INSTALL` | | `marketplaceDiscoveryTimeoutMs` | `OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS` | +| `liveTestTimeoutMs` | `OPENCLAW_CODEX_COMPUTER_USE_LIVE_TEST_TIMEOUT_MS` | +| `toolCallTimeoutMs` | `OPENCLAW_CODEX_COMPUTER_USE_TOOL_CALL_TIMEOUT_MS` | +| `healthCheckEnabled` | `OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_ENABLED` | +| `healthCheckIntervalMinutes` | `OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_INTERVAL_MINUTES` | +| `pluginCacheMode` | `OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_CACHE_MODE` | +| `strictReadiness` | `OPENCLAW_CODEX_COMPUTER_USE_STRICT_READINESS` | +| `autoRepair` | `OPENCLAW_CODEX_COMPUTER_USE_AUTO_REPAIR` | | `marketplaceSource` | `OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_SOURCE` | | `marketplacePath` | `OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_PATH` | | `marketplaceName` | `OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_NAME` | diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 3cf0148b3b91..c33f9bb1b498 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -83,6 +83,38 @@ "minimum": 1, "default": 60000 }, + "liveTestTimeoutMs": { + "type": "number", + "minimum": 1, + "default": 60000 + }, + "toolCallTimeoutMs": { + "type": "number", + "minimum": 1, + "default": 60000 + }, + "healthCheckEnabled": { + "type": "boolean", + "default": false + }, + "healthCheckIntervalMinutes": { + "type": "number", + "enum": [30, 60, 120, 240], + "default": 60 + }, + "pluginCacheMode": { + "type": "string", + "enum": ["shared", "independent"], + "default": "independent" + }, + "strictReadiness": { + "type": "boolean", + "default": false + }, + "autoRepair": { + "type": "boolean", + "default": false + }, "marketplaceSource": { "type": "string" }, @@ -365,6 +397,41 @@ "help": "Maximum time to wait for Codex app-server to finish loading marketplaces during Computer Use install.", "advanced": true }, + "computerUse.liveTestTimeoutMs": { + "label": "Live Test Timeout", + "help": "Maximum time for the Computer Use list_apps readiness probe before status and startup treat the live test as failed.", + "advanced": true + }, + "computerUse.toolCallTimeoutMs": { + "label": "Tool Call Timeout", + "help": "Maximum expected time for real Computer Use tool calls such as list_apps before OpenClaw treats the child runtime as stale.", + "advanced": true + }, + "computerUse.healthCheckEnabled": { + "label": "Periodic Health Checks", + "help": "When true, run periodic Computer Use live probes on the configured cadence.", + "advanced": true + }, + "computerUse.healthCheckIntervalMinutes": { + "label": "Health Check Interval", + "help": "Cadence for periodic Computer Use health checks when health checks are enabled.", + "advanced": true + }, + "computerUse.pluginCacheMode": { + "label": "Plugin Cache Mode", + "help": "The default independent mode leaves each Codex home unmanaged. Choose shared to opt in to a refreshed Codex-discoverable cache copy.", + "advanced": true + }, + "computerUse.strictReadiness": { + "label": "Strict Readiness", + "help": "When true, a failed Computer Use live probe stops Codex-mode startup. The default false value preserves existing enabled setups by continuing with a warning.", + "advanced": true + }, + "computerUse.autoRepair": { + "label": "Auto Repair", + "help": "When true, failed Computer Use live tests repair stale scoped Computer Use MCP children before retrying once.", + "advanced": true + }, "computerUse.marketplaceSource": { "label": "Marketplace Source", "help": "Optional Codex marketplace source to add before installing Computer Use.", diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 0e1df549952c..dacb984a60b2 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -10,7 +10,11 @@ import type { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startCodexAttemptThread } from "./attempt-startup.js"; import { CodexAppServerClient } from "./client.js"; -import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js"; +import { + type CodexPluginConfig, + resolveCodexAppServerRuntimeOptions, + resolveCodexComputerUseConfig, +} from "./config.js"; import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js"; import { clearSharedCodexAppServerClient, @@ -107,7 +111,7 @@ function startThreadWithHarness( overrides?.attemptClientFactory?.(harness) ?? getLeasedSharedCodexAppServerClient, appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }), pluginConfig: effectivePluginConfig, - computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false }, + computerUseConfig: resolveCodexComputerUseConfig({ pluginConfig: effectivePluginConfig }), startupAuthProfileId: undefined, startupAuthAccountCacheKey: undefined, startupEnvApiKeyCacheKey: undefined, diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index a68e35b67469..c3d437c8a769 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -21,13 +21,14 @@ import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagno import { withCodexStartupTimeout } from "./attempt-timeouts.js"; import { ensureCodexAppServerClientRuntime } from "./client-runtime.js"; import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js"; +import { startCodexComputerUseHealthMonitor } from "./computer-use-health.js"; import { ensureCodexComputerUse } from "./computer-use.js"; import { resolveCodexPluginsPolicy, withMcpElicitationsApprovalPolicy, type CodexAppServerRuntimeOptions, type CodexPluginConfig, - type CodexComputerUseConfig, + type ResolvedCodexComputerUseConfig, } from "./config.js"; import { disableCodexPluginThreadConfig, @@ -104,7 +105,7 @@ export async function startCodexAttemptThread(params: { bindingStore: CodexAppServerBindingStore; appServer: CodexAppServerRuntimeOptions; pluginConfig: CodexPluginConfig; - computerUseConfig: CodexComputerUseConfig; + computerUseConfig: ResolvedCodexComputerUseConfig; startupAuthProfileId: string | null | undefined; startupAuthAccountCacheKey: string | undefined; startupEnvApiKeyCacheKey: string | undefined; @@ -434,6 +435,10 @@ export async function startCodexAttemptThread(params: { throw new Error("codex app-server startup did not reserve its thread route"); } startupSandboxEnvironmentAcquired = false; + startCodexComputerUseHealthMonitor({ + client: activeStartupClient, + config: params.computerUseConfig, + }); startupAttemptSucceeded = true; return { client: activeStartupClient, diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index f3d45ec08a57..2cc60395657f 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -21,7 +21,12 @@ import { } from "openclaw/plugin-sdk/agent-runtime"; import { hasUsableOAuthCredential } from "openclaw/plugin-sdk/provider-auth"; import type { CodexAppServerClient } from "./client.js"; -import { resolveCodexAppServerUserHomeDir, type CodexAppServerStartOptions } from "./config.js"; +import { ensureCodexComputerUseSharedPluginCache } from "./computer-use-cache.js"; +import { + resolveCodexAppServerUserHomeDir, + resolveCodexComputerUseConfig, + type CodexAppServerStartOptions, +} from "./config.js"; import type { CodexChatgptAuthTokensRefreshResponse, CodexGetAccountResponse, @@ -61,11 +66,16 @@ export async function bridgeCodexAppServerStartOptions(params: { authProfileId?: string | null; authProfileStore?: AuthProfileStore; config?: AuthProfileOrderConfig; + pluginConfig?: unknown; }): Promise { if (params.startOptions.transport !== "stdio") { return params.startOptions; } - const scopedStartOptions = await withCodexHomeEnvironment(params.startOptions, params.agentDir); + const scopedStartOptions = await withCodexHomeEnvironment( + params.startOptions, + params.agentDir, + params.pluginConfig, + ); if (params.authProfileId === null) { return scopedStartOptions; } @@ -333,6 +343,7 @@ export function resolveCodexAppServerNativeHomeDir(agentDir: string): string { async function withCodexHomeEnvironment( startOptions: CodexAppServerStartOptions, agentDir: string, + pluginConfig?: unknown, ): Promise { const codexHome = startOptions.env?.[CODEX_HOME_ENV_VAR]?.trim() ? startOptions.env[CODEX_HOME_ENV_VAR] @@ -343,6 +354,10 @@ async function withCodexHomeEnvironment( ? startOptions.env[HOME_ENV_VAR] : undefined; await fs.mkdir(codexHome, { recursive: true }); + await ensureCodexComputerUseSharedPluginCache({ + codexHome, + config: resolveCodexComputerUseConfig({ pluginConfig }), + }); if (nativeHome) { await fs.mkdir(nativeHome, { recursive: true }); } diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 1c553c576651..45f77c08ddd2 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -229,6 +229,11 @@ export class CodexAppServerClient { return this.runtimeIdentity ? { ...this.runtimeIdentity } : undefined; } + /** Returns the local transport PID for scoped child-process cleanup, when available. */ + getTransportPid(): number | undefined { + return this.child.pid; + } + request( method: M, params: CodexAppServerRequestParams, diff --git a/extensions/codex/src/app-server/computer-use-cache.test.ts b/extensions/codex/src/app-server/computer-use-cache.test.ts new file mode 100644 index 000000000000..9062c071506a --- /dev/null +++ b/extensions/codex/src/app-server/computer-use-cache.test.ts @@ -0,0 +1,357 @@ +// Codex tests cover Computer Use shared plugin cache reconciliation. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureCodexComputerUseSharedPluginCache } from "./computer-use-cache.js"; +import type { ResolvedCodexComputerUseConfig } from "./config.js"; +import { useAutoCleanupTempDirTracker } from "./test-support.js"; + +describe("Codex Computer Use shared plugin cache", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + it("prefers the current ChatGPT.app bundled marketplace when both desktop app candidates exist", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const chatGptMarketplacePath = path.join( + root, + "Applications", + "ChatGPT.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + const legacyCodexMarketplacePath = path.join( + root, + "Applications", + "Codex.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + await writeBundledComputerUsePlugin(chatGptMarketplacePath, "2.0.0"); + await writeBundledComputerUsePlugin(legacyCodexMarketplacePath, "1.0.0"); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome: path.join(root, "agent", "codex-home"), + bundledMarketplacePathCandidates: [chatGptMarketplacePath, legacyCodexMarketplacePath], + config: computerUseConfig(), + }); + + expect(result).toMatchObject({ + status: "shared", + version: "2.0.0", + targetPath: path.join(chatGptMarketplacePath, "plugins", "computer-use"), + }); + }); + + it("falls back to the legacy Codex.app bundled marketplace when ChatGPT.app is absent", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const chatGptMarketplacePath = path.join( + root, + "Applications", + "ChatGPT.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + const legacyCodexMarketplacePath = path.join( + root, + "Applications", + "Codex.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + await writeBundledComputerUsePlugin(legacyCodexMarketplacePath, "1.0.0"); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome: path.join(root, "agent", "codex-home"), + bundledMarketplacePathCandidates: [chatGptMarketplacePath, legacyCodexMarketplacePath], + config: computerUseConfig(), + }); + + expect(result).toMatchObject({ + status: "shared", + version: "1.0.0", + targetPath: path.join(legacyCodexMarketplacePath, "plugins", "computer-use"), + }); + }); + + it("copies the bundled plugin without removing versions used by live clients", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const bundledMarketplacePath = path.join(root, "Codex.app", "plugins", "openai-bundled"); + const bundledPluginRoot = path.join(bundledMarketplacePath, "plugins", "computer-use"); + await fs.mkdir(path.join(bundledPluginRoot, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(bundledPluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version: "1.0.857" }), + ); + const codexHome = path.join(root, "agent", "codex-home"); + const activeCachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "1.0.857", + ); + const priorCachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "1.0.799", + ); + await fs.mkdir(priorCachePath, { recursive: true }); + await fs.writeFile(path.join(priorCachePath, "live-client-marker"), "in use"); + await fs.symlink(bundledPluginRoot, activeCachePath, "dir"); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath, + config: computerUseConfig(), + }); + + expect(result).toMatchObject({ + status: "shared", + changed: true, + version: "1.0.857", + removedStaleVersions: [], + }); + await expect( + fs.readFile(path.join(priorCachePath, "live-client-marker"), "utf8"), + ).resolves.toBe("in use"); + const cacheEntries = await fs.readdir(path.dirname(activeCachePath), { + withFileTypes: true, + }); + const activeCacheEntry = cacheEntries.find((entry) => entry.name === "1.0.857"); + expect(activeCacheEntry?.isDirectory()).toBe(true); + expect(activeCacheEntry?.isSymbolicLink()).toBe(false); + expect((await fs.lstat(activeCachePath)).isDirectory()).toBe(true); + expect((await fs.lstat(activeCachePath)).isSymbolicLink()).toBe(false); + await expect( + fs.access(path.join(activeCachePath, ".codex-plugin", "plugin.json")), + ).resolves.toBe(undefined); + await expect(fs.access(priorCachePath)).resolves.toBe(undefined); + }); + + it("leaves an up-to-date copied cache entry unchanged", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const bundledMarketplacePath = path.join(root, "Codex.app", "plugins", "openai-bundled"); + const bundledPluginRoot = path.join(bundledMarketplacePath, "plugins", "computer-use"); + await fs.mkdir(path.join(bundledPluginRoot, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(bundledPluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version: "1.0.857" }), + ); + const codexHome = path.join(root, "agent", "codex-home"); + const activeCachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "1.0.857", + ); + await fs.mkdir(path.dirname(activeCachePath), { recursive: true }); + await fs.cp(bundledPluginRoot, activeCachePath, { recursive: true }); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath, + config: computerUseConfig(), + }); + + expect(result).toMatchObject({ + status: "shared", + changed: false, + version: "1.0.857", + removedStaleVersions: [], + }); + expect((await fs.lstat(activeCachePath)).isDirectory()).toBe(true); + expect((await fs.lstat(activeCachePath)).isSymbolicLink()).toBe(false); + await expect( + fs.access(path.join(activeCachePath, ".codex-plugin", "plugin.json")), + ).resolves.toBe(undefined); + }); + + it("refreshes a stale copied cache entry with the bundled version", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const bundledMarketplacePath = path.join(root, "Codex.app", "plugins", "openai-bundled"); + const bundledPluginRoot = path.join(bundledMarketplacePath, "plugins", "computer-use"); + await fs.mkdir(path.join(bundledPluginRoot, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(bundledPluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version: "1.0.857" }), + ); + const codexHome = path.join(root, "agent", "codex-home"); + const activeCachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "1.0.857", + ); + await fs.mkdir(path.join(activeCachePath, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(activeCachePath, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version: "1.0.799" }), + ); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath, + config: computerUseConfig(), + }); + + expect(result).toMatchObject({ + status: "shared", + changed: true, + version: "1.0.857", + removedStaleVersions: [], + }); + await expect( + fs.readFile(path.join(activeCachePath, ".codex-plugin", "plugin.json"), "utf8"), + ).resolves.toContain('"version":"1.0.857"'); + }); + + it("leaves cache entries alone in independent mode", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome: path.join(root, "codex-home"), + bundledMarketplacePath: path.join(root, "missing"), + config: computerUseConfig({ pluginCacheMode: "independent" }), + }); + + expect(result).toMatchObject({ + status: "independent", + changed: false, + removedStaleVersions: [], + }); + }); + + it("preserves an explicitly named marketplace cache", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const codexHome = path.join(root, "agent", "codex-home"); + const cacheRoot = path.join(codexHome, "plugins", "cache", "desktop-tools", "computer-use"); + await fs.mkdir(path.join(cacheRoot, "1.0.101"), { recursive: true }); + await fs.mkdir(path.join(cacheRoot, "1.0.102"), { recursive: true }); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath: path.join(root, "missing-bundled-marketplace"), + config: computerUseConfig({ marketplaceName: "desktop-tools" }), + }); + + expect(result).toMatchObject({ + status: "explicit_marketplace", + changed: false, + removedStaleVersions: [], + }); + await expect(fs.access(path.join(cacheRoot, "1.0.101"))).resolves.toBe(undefined); + await expect(fs.access(path.join(cacheRoot, "1.0.102"))).resolves.toBe(undefined); + }); + + it("preserves the default namespace when marketplacePath is explicit", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const codexHome = path.join(root, "agent", "codex-home"); + const cacheRoot = path.join(codexHome, "plugins", "cache", "openai-bundled", "computer-use"); + await fs.mkdir(path.join(cacheRoot, "1.0.101"), { recursive: true }); + await fs.mkdir(path.join(cacheRoot, "1.0.102"), { recursive: true }); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath: path.join(root, "missing-bundled-marketplace"), + config: computerUseConfig({ + marketplacePath: path.join( + root, + "custom-marketplace", + ".agents", + "plugins", + "marketplace.json", + ), + }), + }); + + expect(result).toMatchObject({ + status: "explicit_marketplace", + changed: false, + removedStaleVersions: [], + }); + await expect(fs.access(path.join(cacheRoot, "1.0.101"))).resolves.toBe(undefined); + await expect(fs.access(path.join(cacheRoot, "1.0.102"))).resolves.toBe(undefined); + }); + + it("preserves the active cache when replacement copying fails", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-"); + const codexHome = path.join(root, "agent", "codex-home"); + const bundledMarketplacePath = path.join(root, "bundled-marketplace"); + const cachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "2.0.0", + ); + const manifestPath = path.join(cachePath, ".codex-plugin", "plugin.json"); + const olderCachePath = path.join(path.dirname(cachePath), "1.0.0"); + const olderManifestPath = path.join(olderCachePath, ".codex-plugin", "plugin.json"); + await writeBundledComputerUsePlugin(bundledMarketplacePath, "2.0.0"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, JSON.stringify({ name: "computer-use", version: "old" })); + await fs.mkdir(path.dirname(olderManifestPath), { recursive: true }); + await fs.writeFile( + olderManifestPath, + JSON.stringify({ name: "computer-use", version: "1.0.0" }), + ); + vi.spyOn(fs, "cp").mockRejectedValueOnce(new Error("copy failed")); + + await expect( + ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath, + config: computerUseConfig(), + }), + ).rejects.toThrow("copy failed"); + await expect(fs.readFile(manifestPath, "utf8")).resolves.toContain('"version":"old"'); + await expect(fs.readFile(olderManifestPath, "utf8")).resolves.toContain('"version":"1.0.0"'); + }); +}); + +async function writeBundledComputerUsePlugin( + bundledMarketplacePath: string, + version: string, +): Promise { + const bundledPluginRoot = path.join(bundledMarketplacePath, "plugins", "computer-use"); + await fs.mkdir(path.join(bundledPluginRoot, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(bundledPluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version }), + ); +} + +function computerUseConfig( + overrides: Partial = {}, +): ResolvedCodexComputerUseConfig { + return { + enabled: true, + autoInstall: true, + marketplaceDiscoveryTimeoutMs: 60_000, + liveTestTimeoutMs: 60_000, + toolCallTimeoutMs: 60_000, + healthCheckEnabled: false, + healthCheckIntervalMinutes: 60, + pluginCacheMode: "shared", + strictReadiness: false, + autoRepair: false, + pluginName: "computer-use", + mcpServerName: "computer-use", + ...overrides, + }; +} diff --git a/extensions/codex/src/app-server/computer-use-cache.ts b/extensions/codex/src/app-server/computer-use-cache.ts new file mode 100644 index 000000000000..edfbb1148e8e --- /dev/null +++ b/extensions/codex/src/app-server/computer-use-cache.ts @@ -0,0 +1,184 @@ +/** Shared Computer Use plugin cache reconciliation for isolated Codex homes. */ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ResolvedCodexComputerUseConfig } from "./config.js"; +import { + resolveFirstExistingMacOSDesktopCodexBundledMarketplacePath, + resolveMacOSDesktopCodexBundledMarketplaceCandidates, +} from "./desktop-app-paths.js"; + +export type CodexComputerUsePluginCacheRepairResult = + | { + status: "disabled" | "explicit_marketplace" | "independent" | "source_missing" | "shared"; + changed: boolean; + message: string; + cachePath?: string; + targetPath?: string; + version?: string; + removedStaleVersions: string[]; + warnings: string[]; + } + | { + status: "failed"; + changed: false; + message: string; + removedStaleVersions: string[]; + warnings: string[]; + }; + +export const DEFAULT_CODEX_COMPUTER_USE_BUNDLED_MARKETPLACE_PATH = + resolveMacOSDesktopCodexBundledMarketplaceCandidates("darwin")[0] ?? ""; + +const DEFAULT_BUNDLED_MARKETPLACE_NAME = "openai-bundled"; + +export async function ensureCodexComputerUseSharedPluginCache(params: { + codexHome: string; + config: ResolvedCodexComputerUseConfig; + bundledMarketplacePath?: string; + bundledMarketplacePathCandidates?: readonly string[]; +}): Promise { + if (!params.config.enabled) { + return skippedCacheResult( + "disabled", + "Computer Use cache sharing skipped because it is disabled.", + ); + } + if (params.config.pluginCacheMode === "independent") { + return skippedCacheResult( + "independent", + "Computer Use cache sharing skipped because pluginCacheMode is independent.", + ); + } + if (params.config.marketplaceName || params.config.marketplacePath) { + return skippedCacheResult( + "explicit_marketplace", + "Computer Use cache sharing skipped because an explicit marketplace is configured.", + ); + } + + const bundledMarketplacePath = resolveComputerUseBundledMarketplacePath(params); + const sourcePluginRoot = path.join(bundledMarketplacePath, "plugins", params.config.pluginName); + const version = await readBundledPluginVersion(sourcePluginRoot); + if (!version) { + return skippedCacheResult( + "source_missing", + `Computer Use bundled plugin source was not found at ${sourcePluginRoot}.`, + ); + } + + const marketplaceName = params.config.marketplaceName ?? DEFAULT_BUNDLED_MARKETPLACE_NAME; + const cacheRoot = path.join( + params.codexHome, + "plugins", + "cache", + marketplaceName, + params.config.pluginName, + ); + const cachePath = path.join(cacheRoot, version); + const changed = await ensureRealDirectoryCopy(cachePath, sourcePluginRoot, version); + return { + status: "shared", + changed, + cachePath, + targetPath: sourcePluginRoot, + version, + removedStaleVersions: [], + warnings: [], + message: `Computer Use plugin cache ${cachePath} contains bundled plugin ${sourcePluginRoot}.`, + }; +} + +function resolveComputerUseBundledMarketplacePath(params: { + bundledMarketplacePath?: string; + bundledMarketplacePathCandidates?: readonly string[]; +}): string { + return ( + params.bundledMarketplacePath ?? + resolveFirstExistingMacOSDesktopCodexBundledMarketplacePath({ + candidates: params.bundledMarketplacePathCandidates, + }) ?? + params.bundledMarketplacePathCandidates?.[0] ?? + DEFAULT_CODEX_COMPUTER_USE_BUNDLED_MARKETPLACE_PATH + ); +} + +async function readBundledPluginVersion(sourcePluginRoot: string): Promise { + const pluginJsonPath = path.join(sourcePluginRoot, ".codex-plugin", "plugin.json"); + let raw: string; + try { + raw = await fs.readFile(pluginJsonPath, "utf8"); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" && parsed.version.trim() + ? parsed.version.trim() + : undefined; + } catch { + return undefined; + } +} + +async function ensureRealDirectoryCopy( + cachePath: string, + sourcePluginRoot: string, + version: string, +): Promise { + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + const stat = await fs.lstat(cachePath).catch(() => undefined); + if (stat?.isDirectory() && !stat.isSymbolicLink()) { + const cachedVersion = await readBundledPluginVersion(cachePath); + if (cachedVersion === version) { + return false; + } + } + const cacheRoot = path.dirname(cachePath); + const cacheName = path.basename(cachePath); + const stagingRoot = await fs.mkdtemp(path.join(cacheRoot, `.${cacheName}.staging-`)); + const stagedPath = path.join(stagingRoot, cacheName); + const backupPath = path.join(cacheRoot, `.${cacheName}.backup-${process.pid}-${Date.now()}`); + let backupCreated = false; + try { + await fs.cp(sourcePluginRoot, stagedPath, { recursive: true }); + if (stat) { + await fs.rename(cachePath, backupPath); + backupCreated = true; + } + try { + await fs.rename(stagedPath, cachePath); + } catch (error) { + if (backupCreated) { + try { + await fs.rename(backupPath, cachePath); + backupCreated = false; + } catch (restoreError) { + throw new Error( + `Failed to install Computer Use cache ${cachePath} and restore its prior copy: ${String(error)}`, + { cause: restoreError }, + ); + } + } + throw error; + } + if (backupCreated) { + await fs.rm(backupPath, { recursive: true, force: true }); + } + return true; + } finally { + await fs.rm(stagingRoot, { recursive: true, force: true }); + } +} + +function skippedCacheResult( + status: "disabled" | "explicit_marketplace" | "independent" | "source_missing", + message: string, +): CodexComputerUsePluginCacheRepairResult { + return { + status, + changed: false, + message, + removedStaleVersions: [], + warnings: status === "source_missing" ? [message] : [], + }; +} diff --git a/extensions/codex/src/app-server/computer-use-health.test.ts b/extensions/codex/src/app-server/computer-use-health.test.ts new file mode 100644 index 000000000000..b1247df2fdde --- /dev/null +++ b/extensions/codex/src/app-server/computer-use-health.test.ts @@ -0,0 +1,235 @@ +// Codex tests cover periodic Computer Use health monitoring. +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CodexAppServerClient } from "./client.js"; +import { startCodexComputerUseHealthMonitor } from "./computer-use-health.js"; +import type { ResolvedCodexComputerUseConfig } from "./config.js"; + +describe("Codex Computer Use periodic health", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("runs the live list_apps probe on the configured cadence and clears on client close", async () => { + vi.useFakeTimers(); + const client = createClient(); + + const result = startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ healthCheckEnabled: true, healthCheckIntervalMinutes: 30 }), + }); + + expect(result).toEqual({ started: true, intervalMs: 30 * 60_000 }); + expect(client.request).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(30 * 60_000); + + expect(client.request).toHaveBeenCalledWith( + "mcpServer/tool/call", + { + threadId: "health-probe-thread-1", + server: "computer-use", + tool: "list_apps", + arguments: {}, + }, + { timeoutMs: 60_000 }, + ); + + client.close(); + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect( + client.request.mock.calls.filter(([method]) => method === "mcpServer/tool/call"), + ).toHaveLength(1); + }); + + it("repairs stale CUA children and retries once after a failed probe", async () => { + vi.useFakeTimers(); + const client = createClient({ liveTestFailures: 1 }); + const repairComputerUseMcpChildren = vi.fn(async () => ({ + attempted: true, + killedPids: [1234], + warnings: [], + message: "Terminated 1 stale Computer Use MCP child process.", + })); + + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ + autoRepair: true, + healthCheckEnabled: true, + healthCheckIntervalMinutes: 30, + }), + repairComputerUseMcpChildren, + }); + + await vi.advanceTimersByTimeAsync(30 * 60_000); + + expect( + client.request.mock.calls.filter(([method]) => method === "mcpServer/tool/call"), + ).toHaveLength(2); + expect(repairComputerUseMcpChildren).toHaveBeenCalledTimes(1); + }); + + it("stops an existing monitor when health checks are disabled", async () => { + vi.useFakeTimers(); + const client = createClient(); + + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ healthCheckEnabled: true, healthCheckIntervalMinutes: 30 }), + }); + expect( + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ healthCheckEnabled: false }), + }), + ).toEqual({ started: false, reason: "health_disabled" }); + + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(client.request).not.toHaveBeenCalled(); + }); + + it("stops an existing monitor when Computer Use is disabled", async () => { + vi.useFakeTimers(); + const client = createClient(); + + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ healthCheckEnabled: true, healthCheckIntervalMinutes: 30 }), + }); + expect( + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ enabled: false, healthCheckEnabled: true }), + }), + ).toEqual({ started: false, reason: "disabled" }); + + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(client.request).not.toHaveBeenCalled(); + }); + + it("replaces a same-interval monitor when probe configuration changes", async () => { + vi.useFakeTimers(); + const client = createClient(); + + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ healthCheckEnabled: true, healthCheckIntervalMinutes: 30 }), + }); + expect( + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ + healthCheckEnabled: true, + healthCheckIntervalMinutes: 30, + mcpServerName: "replacement-computer-use", + toolCallTimeoutMs: 45_000, + }), + }), + ).toEqual({ started: true, intervalMs: 30 * 60_000 }); + + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(client.request).toHaveBeenCalledWith( + "mcpServer/tool/call", + { + threadId: "health-probe-thread-1", + server: "replacement-computer-use", + tool: "list_apps", + arguments: {}, + }, + { timeoutMs: 45_000 }, + ); + expect( + client.request.mock.calls.filter(([method]) => method === "mcpServer/tool/call"), + ).toHaveLength(1); + }); + + it("does not start when Computer Use is disabled", () => { + const client = createClient(); + + expect( + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig({ enabled: false }), + }), + ).toEqual({ started: false, reason: "disabled" }); + expect(client.addCloseHandler).not.toHaveBeenCalled(); + }); + + it("does not start periodic health checks unless explicitly enabled", () => { + const client = createClient(); + + expect( + startCodexComputerUseHealthMonitor({ + client: client.client, + config: computerUseConfig(), + }), + ).toEqual({ started: false, reason: "health_disabled" }); + expect(client.addCloseHandler).not.toHaveBeenCalled(); + }); +}); + +function createClient(options: { liveTestFailures?: number } = {}) { + const closeHandlers = new Set<(client: CodexAppServerClient) => void>(); + let threadStarts = 0; + let liveTestFailures = options.liveTestFailures ?? 0; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "thread/start") { + threadStarts += 1; + return { + thread: { id: `health-probe-thread-${threadStarts}` }, + model: "gpt-5.1", + modelProvider: "openai", + }; + } + if (method === "mcpServer/tool/call") { + if (liveTestFailures > 0) { + liveTestFailures -= 1; + throw new Error("hung"); + } + return { content: [{ type: "text", text: "[]" }] }; + } + if (method === "thread/unsubscribe" || method === "thread/archive") { + expect(params).toEqual({ threadId: `health-probe-thread-${threadStarts}` }); + return undefined; + } + throw new Error(`unexpected request ${method}`); + }); + const addCloseHandler = vi.fn((handler: (client: CodexAppServerClient) => void) => { + closeHandlers.add(handler); + return () => closeHandlers.delete(handler); + }); + const client = { + request, + addCloseHandler, + } as unknown as CodexAppServerClient; + return { + client, + request, + addCloseHandler, + close: () => { + for (const handler of closeHandlers) { + handler(client); + } + }, + }; +} + +function computerUseConfig( + overrides: Partial = {}, +): ResolvedCodexComputerUseConfig { + return { + enabled: true, + autoInstall: true, + marketplaceDiscoveryTimeoutMs: 60_000, + liveTestTimeoutMs: 60_000, + toolCallTimeoutMs: 60_000, + healthCheckEnabled: false, + healthCheckIntervalMinutes: 60, + pluginCacheMode: "shared", + strictReadiness: false, + autoRepair: false, + pluginName: "computer-use", + mcpServerName: "computer-use", + ...overrides, + }; +} diff --git a/extensions/codex/src/app-server/computer-use-health.ts b/extensions/codex/src/app-server/computer-use-health.ts new file mode 100644 index 000000000000..003b454b864d --- /dev/null +++ b/extensions/codex/src/app-server/computer-use-health.ts @@ -0,0 +1,162 @@ +// Codex plugin module implements periodic Computer Use health probes. +import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { CodexAppServerClient } from "./client.js"; +import { + killStaleComputerUseMcpChildren, + runCodexComputerUseLiveTest, + type CodexComputerUseRepairStatus, +} from "./computer-use.js"; +import type { ResolvedCodexComputerUseConfig } from "./config.js"; + +type ComputerUseHealthMonitor = { + fingerprint: string; + intervalMs: number; + repairComputerUseMcpChildren?: () => Promise; + timer: ReturnType; + disposeCloseHandler: () => void; + running: boolean; +}; + +type ComputerUseHealthMonitorState = { + monitors: WeakMap; +}; + +const COMPUTER_USE_HEALTH_MONITOR_STATE = Symbol.for("openclaw.codexComputerUseHealthMonitorState"); + +function getComputerUseHealthMonitorState(): ComputerUseHealthMonitorState { + const globalState = globalThis as typeof globalThis & { + [COMPUTER_USE_HEALTH_MONITOR_STATE]?: ComputerUseHealthMonitorState; + }; + globalState[COMPUTER_USE_HEALTH_MONITOR_STATE] ??= { + monitors: new WeakMap(), + }; + return globalState[COMPUTER_USE_HEALTH_MONITOR_STATE]; +} + +export function startCodexComputerUseHealthMonitor(params: { + client: CodexAppServerClient; + config: ResolvedCodexComputerUseConfig; + repairComputerUseMcpChildren?: () => Promise; +}): { started: boolean; intervalMs?: number; reason?: string } { + const state = getComputerUseHealthMonitorState(); + const existing = state.monitors.get(params.client); + if (!params.config.enabled || !params.config.healthCheckEnabled) { + if (existing) { + clearComputerUseHealthMonitor(params.client, existing); + } + return { + started: false, + reason: params.config.enabled ? "health_disabled" : "disabled", + }; + } + const fingerprint = buildComputerUseHealthMonitorFingerprint(params.config); + const intervalMs = params.config.healthCheckIntervalMinutes * 60_000; + if ( + existing?.fingerprint === fingerprint && + existing.repairComputerUseMcpChildren === params.repairComputerUseMcpChildren + ) { + return { started: false, intervalMs, reason: "already_started" }; + } + if (existing) { + clearComputerUseHealthMonitor(params.client, existing); + } + const repairComputerUseMcpChildren = + params.repairComputerUseMcpChildren ?? + (() => killStaleComputerUseMcpChildren({ ancestorPid: params.client.getTransportPid() })); + const monitor: ComputerUseHealthMonitor = { + fingerprint, + intervalMs, + repairComputerUseMcpChildren: params.repairComputerUseMcpChildren, + timer: setInterval(() => { + void runCodexComputerUseHealthProbe(params.client, params.config, monitor, { + repairComputerUseMcpChildren, + }); + }, intervalMs), + disposeCloseHandler: () => undefined, + running: false, + }; + monitor.timer.unref?.(); + monitor.disposeCloseHandler = params.client.addCloseHandler((client) => { + const active = state.monitors.get(client); + if (active) { + clearComputerUseHealthMonitor(client, active); + } + }); + state.monitors.set(params.client, monitor); + return { started: true, intervalMs }; +} + +function buildComputerUseHealthMonitorFingerprint(config: ResolvedCodexComputerUseConfig): string { + return JSON.stringify({ + autoRepair: config.autoRepair, + healthCheckIntervalMinutes: config.healthCheckIntervalMinutes, + liveTestTimeoutMs: config.liveTestTimeoutMs, + mcpServerName: config.mcpServerName, + toolCallTimeoutMs: config.toolCallTimeoutMs, + }); +} + +async function runCodexComputerUseHealthProbe( + client: CodexAppServerClient, + config: ResolvedCodexComputerUseConfig, + monitor: ComputerUseHealthMonitor, + options: { + repairComputerUseMcpChildren?: () => Promise; + }, +): Promise { + if (monitor.running) { + return; + } + monitor.running = true; + try { + const { liveTest, repair } = await runCodexComputerUseLiveTest({ + config, + repairComputerUseMcpChildren: options.repairComputerUseMcpChildren, + request: async ( + method: string, + requestParams?: unknown, + requestOptions?: { timeoutMs?: number }, + ) => + await client.request(method, requestParams, { + timeoutMs: requestOptions?.timeoutMs ?? config.liveTestTimeoutMs, + }), + }); + if (!liveTest.ok) { + embeddedAgentLog.warn("codex computer-use periodic health failed", { + mcpServerName: config.mcpServerName, + attempts: liveTest.attempts, + timeoutMs: liveTest.timeoutMs, + error: liveTest.error, + repair, + }); + return; + } + if (repair?.killedPids.length) { + embeddedAgentLog.info("codex computer-use periodic health repaired stale children", { + mcpServerName: config.mcpServerName, + killedPids: repair.killedPids, + }); + } + } catch (error) { + embeddedAgentLog.warn("codex computer-use periodic health probe crashed", { + mcpServerName: config.mcpServerName, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + monitor.running = false; + } +} + +function clearComputerUseHealthMonitor( + client: CodexAppServerClient, + monitor: ComputerUseHealthMonitor, +): void { + clearInterval(monitor.timer); + monitor.disposeCloseHandler(); + getComputerUseHealthMonitorState().monitors.delete(client); +} + +export const testing = { + clearComputerUseHealthMonitor, + getComputerUseHealthMonitorState, +}; diff --git a/extensions/codex/src/app-server/computer-use.test.ts b/extensions/codex/src/app-server/computer-use.test.ts index 0de2529fadd8..9e300281d6fc 100644 --- a/extensions/codex/src/app-server/computer-use.test.ts +++ b/extensions/codex/src/app-server/computer-use.test.ts @@ -1,15 +1,16 @@ // Codex tests cover computer use plugin behavior. import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureCodexComputerUse, installCodexComputerUse, readCodexComputerUseStatus, + testing, type CodexComputerUseStatus, type CodexComputerUseRequest, } from "./computer-use.js"; +import { useAutoCleanupTempDirTracker } from "./test-support.js"; function expectStatusFields( status: CodexComputerUseStatus, @@ -44,8 +45,8 @@ function requireRecord(value: unknown, label: string): Record { function requestCalls( request: CodexComputerUseRequest, -): ReadonlyArray { - return vi.mocked(request).mock.calls as ReadonlyArray; +): ReadonlyArray { + return vi.mocked(request).mock.calls; } function expectRequestMethodNotCalled(request: CodexComputerUseRequest, method: string): void { @@ -53,13 +54,10 @@ function expectRequestMethodNotCalled(request: CodexComputerUseRequest, method: } describe("Codex Computer Use setup", () => { - const cleanupPaths: string[] = []; + const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => { vi.useRealTimers(); - for (const cleanupPath of cleanupPaths.splice(0)) { - fs.rmSync(cleanupPath, { recursive: true, force: true }); - } }); it("stays disabled until configured", async () => { @@ -91,11 +89,293 @@ describe("Codex Computer Use setup", () => { tools: ["list_apps"], message: "Computer Use is ready.", }); + expect(status.installation).toMatchObject({ + status: "installed", + ok: true, + }); + expect(status.exposure).toMatchObject({ + status: "available", + ok: true, + }); + expect(status.liveTest).toMatchObject({ + status: "passed", + ok: true, + attempted: true, + attempts: 1, + timeoutMs: 60_000, + retried: false, + repaired: false, + }); + expect(request).toHaveBeenCalledWith( + "thread/start", + { + input: [], + developerInstructions: "OpenClaw Computer Use readiness probe", + sandbox: "danger-full-access", + approvalPolicy: "never", + ephemeral: true, + }, + { timeoutMs: 60_000 }, + ); + expect(request).toHaveBeenCalledWith( + "mcpServer/tool/call", + { + threadId: "computer-use-probe-thread-1", + server: "computer-use", + tool: "list_apps", + arguments: {}, + }, + { + timeoutMs: 60_000, + }, + ); + expect(request).toHaveBeenCalledWith( + "thread/unsubscribe", + { threadId: "computer-use-probe-thread-1" }, + { timeoutMs: 60_000 }, + ); + expect(request).toHaveBeenCalledWith( + "thread/archive", + { threadId: "computer-use-probe-thread-1" }, + { timeoutMs: 60_000 }, + ); expectRequestMethodNotCalled(request, "marketplace/add"); expectRequestMethodNotCalled(request, "experimentalFeature/enablement/set"); expectRequestMethodNotCalled(request, "plugin/install"); }); + it("repairs stale Computer Use MCP children and retries the live test once", async () => { + const request = createComputerUseRequest({ installed: true, liveTestFailures: 1 }); + const repairComputerUseMcpChildren = vi.fn(async () => ({ + attempted: true, + killedPids: [1234], + warnings: [], + message: "Terminated 1 stale Computer Use MCP child process.", + })); + + const status = await readCodexComputerUseStatus({ + pluginConfig: { + computerUse: { enabled: true, marketplaceName: "desktop-tools", autoRepair: true }, + }, + request, + repairComputerUseMcpChildren, + }); + + expectStatusFields(status, { + ready: true, + reason: "ready", + message: "Computer Use is ready.", + }); + expect(status.liveTest).toMatchObject({ + status: "passed", + attempts: 2, + retried: true, + repaired: true, + }); + expect(status.repair).toMatchObject({ + attempted: true, + killedPids: [1234], + }); + expect(repairComputerUseMcpChildren).toHaveBeenCalledTimes(1); + expect( + requestCalls(request).filter(([method]) => method === "mcpServer/tool/call"), + ).toHaveLength(2); + }); + + it("does not repair stale Computer Use MCP children unless autoRepair is enabled", async () => { + const request = createComputerUseRequest({ installed: true, liveTestFailures: 2 }); + const repairComputerUseMcpChildren = vi.fn(async () => ({ + attempted: true, + killedPids: [], + warnings: [], + message: "No stale Computer Use MCP children were found.", + })); + + const status = await readCodexComputerUseStatus({ + pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } }, + request, + repairComputerUseMcpChildren, + }); + + expect(status.liveTest).toMatchObject({ + status: "failed", + ok: false, + attempts: 2, + retried: true, + repaired: false, + }); + expectStatusFields(status, { + ready: false, + reason: "live_test_failed", + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + }); + expect(status.warnings).toContain( + "Computer Use live test failed, but compatibility startup remains enabled; set computerUse.strictReadiness to true to fail closed.", + ); + expect(status.message).toContain( + "Startup is allowed because computerUse.strictReadiness is false.", + ); + expect(status.repair).toBeUndefined(); + expect(repairComputerUseMcpChildren).not.toHaveBeenCalled(); + }); + + it("surfaces install, exposure, and live-test layers separately when the live test fails", async () => { + const request = createComputerUseRequest({ installed: true, liveTestFailures: 2 }); + const repairComputerUseMcpChildren = vi.fn(async () => ({ + attempted: true, + killedPids: [], + warnings: [], + message: "No stale Computer Use MCP children were found.", + })); + + const status = await readCodexComputerUseStatus({ + pluginConfig: { + computerUse: { + enabled: true, + marketplaceName: "desktop-tools", + autoRepair: true, + strictReadiness: true, + }, + }, + request, + repairComputerUseMcpChildren, + }); + + expectStatusFields(status, { + ready: false, + reason: "live_test_failed", + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + }); + expect(status.installation).toMatchObject({ status: "installed", ok: true }); + expect(status.exposure).toMatchObject({ status: "available", ok: true }); + expect(status.liveTest).toMatchObject({ + status: "failed", + ok: false, + attempted: true, + attempts: 2, + timeoutMs: 60_000, + retried: true, + repaired: true, + error: "list_apps timed out", + }); + expect(status.message).toContain("Computer Use live test failed after 2 attempts"); + expect(repairComputerUseMcpChildren).toHaveBeenCalledTimes(1); + }); + + it("keeps startup compatible by default when the live test fails", async () => { + const request = createComputerUseRequest({ installed: true, liveTestFailures: 2 }); + + const status = await ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + marketplaceName: "desktop-tools", + }, + }, + request, + repairComputerUseMcpChildren: vi.fn(async () => ({ + attempted: true, + killedPids: [], + warnings: [], + message: "No stale Computer Use MCP children were found.", + })), + }); + + expectStatusFields(status, { + ready: false, + reason: "live_test_failed", + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + }); + expect(status.liveTest).toMatchObject({ status: "failed", ok: false }); + expect(status.warnings).toContain( + "Computer Use live test failed, but compatibility startup remains enabled; set computerUse.strictReadiness to true to fail closed.", + ); + expect(status.message).toContain( + "Startup is allowed because computerUse.strictReadiness is false.", + ); + }); + + it("keeps auto-install startup compatible when installation succeeds but the live test fails", async () => { + const request = createComputerUseRequest({ installed: false, liveTestFailures: 2 }); + + const status = await ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + marketplaceName: "desktop-tools", + }, + }, + request, + }); + + expectStatusFields(status, { + ready: false, + reason: "live_test_failed", + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + }); + expect(status.warnings).toContain( + "Computer Use live test failed, but compatibility startup remains enabled; set computerUse.strictReadiness to true to fail closed.", + ); + expect(status.message).toContain( + "Startup is allowed because computerUse.strictReadiness is false.", + ); + expect(request).toHaveBeenCalledWith("plugin/install", { + marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }); + }); + + it("fails startup closed when strictReadiness is enabled", async () => { + const request = createComputerUseRequest({ installed: true, liveTestFailures: 2 }); + + await expectSetupErrorStatus( + ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + marketplaceName: "desktop-tools", + strictReadiness: true, + }, + }, + request, + }), + { + ready: false, + reason: "live_test_failed", + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + }, + ); + }); + + it("parses process trees so repair can stay scoped to the app-server child tree", () => { + const processes = testing.parsePsOutput(` + 100 1 /Applications/Codex.app/Contents/MacOS/Codex app-server + 101 100 /Applications/Codex.app/Contents/Frameworks/SkyComputerUseClient mcp + 102 1 /Applications/Codex.app/Contents/Frameworks/SkyComputerUseClient mcp + 103 101 helper + `); + + expect(processes).toContainEqual({ + pid: 101, + ppid: 100, + command: "/Applications/Codex.app/Contents/Frameworks/SkyComputerUseClient mcp", + }); + expect(testing.isDescendantOfPid(101, 100, processes)).toBe(true); + expect(testing.isDescendantOfPid(103, 100, processes)).toBe(true); + expect(testing.isDescendantOfPid(102, 100, processes)).toBe(false); + }); + it("reports an installed but disabled Computer Use plugin separately", async () => { const request = createComputerUseRequest({ installed: true, enabled: false }); @@ -187,6 +467,24 @@ describe("Codex Computer Use setup", () => { expect(request).toHaveBeenCalledWith("config/mcpServer/reload", undefined); }); + it("requires explicit install commands to finish with a passing live test", async () => { + const request = createComputerUseRequest({ installed: true, liveTestFailures: 2 }); + + await expectSetupErrorStatus( + installCodexComputerUse({ + pluginConfig: { computerUse: { marketplaceName: "desktop-tools" } }, + request, + }), + { + ready: false, + reason: "live_test_failed", + installed: true, + pluginEnabled: true, + mcpServerAvailable: true, + }, + ); + }); + it("re-enables an installed but disabled Computer Use plugin during install", async () => { const request = createComputerUseRequest({ installed: true, enabled: false }); @@ -247,32 +545,6 @@ describe("Codex Computer Use setup", () => { expectRequestMethodNotCalled(request, "plugin/install"); }); - it("does not inspect bundled app paths when a registered marketplace is ready", async () => { - const request = createComputerUseRequest({ installed: true }); - const forbiddenCandidates = new Proxy(["/unused/bundled-marketplace"], { - get() { - throw new Error("bundled marketplace candidates must stay lazy"); - }, - }); - - const status = await ensureCodexComputerUse({ - pluginConfig: { - computerUse: { - enabled: true, - autoInstall: true, - }, - }, - request, - defaultBundledMarketplacePaths: forbiddenCandidates, - }); - - expectStatusFields(status, { - ready: true, - reason: "ready", - }); - expectRequestMethodNotCalled(request, "marketplace/add"); - }); - it("uses setup writes when auto-install needs to install", async () => { const request = createComputerUseRequest({ installed: false }); @@ -301,14 +573,99 @@ describe("Codex Computer Use setup", () => { }); }); - it("auto-registers the bundled Codex app marketplace during auto-install", async () => { - const bundledMarketplacePath = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-codex-bundled-marketplace-"), + it("auto-registers the current ChatGPT.app bundled marketplace before legacy Codex.app", async () => { + const root = tempDirs.make("openclaw-codex-bundled-marketplace-"); + const chatGptMarketplacePath = path.join( + root, + "Applications", + "ChatGPT.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", ); - cleanupPaths.push(bundledMarketplacePath); - fs.mkdirSync(path.join(bundledMarketplacePath, "plugins", "computer-use"), { - recursive: true, + const legacyCodexMarketplacePath = path.join( + root, + "Applications", + "Codex.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + fs.mkdirSync(chatGptMarketplacePath, { recursive: true }); + fs.mkdirSync(legacyCodexMarketplacePath, { recursive: true }); + const request = createBundledMarketplaceComputerUseRequest(chatGptMarketplacePath); + + const status = await ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + }, + }, + request, + defaultBundledMarketplacePathCandidates: [chatGptMarketplacePath, legacyCodexMarketplacePath], }); + + expectStatusFields(status, { + ready: true, + reason: "ready", + marketplaceName: "openai-bundled", + message: "Computer Use is ready.", + }); + expect(request).toHaveBeenCalledWith("marketplace/add", { + source: chatGptMarketplacePath, + }); + }); + + it("auto-registers the legacy Codex.app bundled marketplace when ChatGPT.app is absent", async () => { + const root = tempDirs.make("openclaw-codex-bundled-marketplace-"); + const chatGptMarketplacePath = path.join( + root, + "Applications", + "ChatGPT.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + const legacyCodexMarketplacePath = path.join( + root, + "Applications", + "Codex.app", + "Contents", + "Resources", + "plugins", + "openai-bundled", + ); + fs.mkdirSync(legacyCodexMarketplacePath, { recursive: true }); + const request = createBundledMarketplaceComputerUseRequest(legacyCodexMarketplacePath); + + const status = await ensureCodexComputerUse({ + pluginConfig: { + computerUse: { + enabled: true, + autoInstall: true, + }, + }, + request, + defaultBundledMarketplacePathCandidates: [chatGptMarketplacePath, legacyCodexMarketplacePath], + }); + + expectStatusFields(status, { + ready: true, + reason: "ready", + marketplaceName: "openai-bundled", + message: "Computer Use is ready.", + }); + expect(request).toHaveBeenCalledWith("marketplace/add", { + source: legacyCodexMarketplacePath, + }); + }); + + it("keeps explicit bundled marketplace test overrides authoritative during auto-install", async () => { + const bundledMarketplacePath = tempDirs.make("openclaw-codex-bundled-marketplace-"); const request = createBundledMarketplaceComputerUseRequest(bundledMarketplacePath); const status = await ensureCodexComputerUse({ @@ -319,7 +676,7 @@ describe("Codex Computer Use setup", () => { }, }, request, - defaultBundledMarketplacePaths: [bundledMarketplacePath], + defaultBundledMarketplacePath: bundledMarketplacePath, }); expectStatusFields(status, { @@ -337,90 +694,6 @@ describe("Codex Computer Use setup", () => { }); }); - it.each([ - { - label: "prefers ChatGPT.app when both desktop marketplaces exist", - marketplaceIndexes: [0, 1], - pluginIndexes: [0, 1], - expectedIndex: 0, - }, - { - label: "uses ChatGPT.app when it is the only desktop marketplace", - marketplaceIndexes: [0], - pluginIndexes: [0], - expectedIndex: 0, - }, - { - label: "falls back to legacy Codex.app when it is the only desktop marketplace", - marketplaceIndexes: [1], - pluginIndexes: [1], - expectedIndex: 1, - }, - { - label: "skips a ChatGPT.app marketplace that does not contain Computer Use", - marketplaceIndexes: [0, 1], - pluginIndexes: [1], - expectedIndex: 1, - }, - { - label: "does not add a marketplace when neither desktop bundle contains Computer Use", - marketplaceIndexes: [0, 1], - pluginIndexes: [], - expectedIndex: undefined, - }, - ])("$label", async ({ marketplaceIndexes, pluginIndexes, expectedIndex }) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-desktop-bundles-")); - cleanupPaths.push(root); - const bundledMarketplacePaths = [ - path.join(root, "ChatGPT.app", "Contents", "Resources", "plugins", "openai-bundled"), - path.join(root, "Codex.app", "Contents", "Resources", "plugins", "openai-bundled"), - ]; - for (const index of marketplaceIndexes) { - fs.mkdirSync(bundledMarketplacePaths[index], { recursive: true }); - } - for (const index of pluginIndexes) { - fs.mkdirSync(path.join(bundledMarketplacePaths[index], "plugins", "computer-use"), { - recursive: true, - }); - } - - const expectedPath = - expectedIndex === undefined ? undefined : bundledMarketplacePaths[expectedIndex]; - const request = expectedPath - ? createBundledMarketplaceComputerUseRequest(expectedPath) - : createEmptyMarketplaceComputerUseRequest(); - const setup = ensureCodexComputerUse({ - pluginConfig: { - computerUse: { - enabled: true, - autoInstall: true, - marketplaceDiscoveryTimeoutMs: 1, - }, - }, - request, - defaultBundledMarketplacePaths: bundledMarketplacePaths, - }); - - if (!expectedPath) { - await expectSetupErrorStatus(setup, { - ready: false, - reason: "marketplace_missing", - }); - expectRequestMethodNotCalled(request, "marketplace/add"); - return; - } - - const status = await setup; - expectStatusFields(status, { - ready: true, - reason: "ready", - marketplaceName: "openai-bundled", - }); - expect(request).toHaveBeenCalledWith("marketplace/add", { - source: expectedPath, - }); - }); - it("allows auto-install from a configured local marketplace path", async () => { const request = createComputerUseRequest({ installed: false }); @@ -564,10 +837,13 @@ function createComputerUseRequest(params: { installed: boolean; enabled?: boolean; marketplaceAvailableAfterListCalls?: number; + liveTestFailures?: number; }): CodexComputerUseRequest { let installed = params.installed; let enabled = params.enabled ?? installed; let pluginListCalls = 0; + let liveTestFailures = params.liveTestFailures ?? 0; + let threadStartCalls = 0; return vi.fn(async (method: string, requestParams?: unknown) => { if (method === "experimentalFeature/enablement/set") { return { enablement: { plugins: true } }; @@ -642,6 +918,33 @@ function createComputerUseRequest(params: { nextCursor: null, }; } + if (method === "thread/start") { + threadStartCalls += 1; + return { + thread: { + id: `computer-use-probe-thread-${threadStartCalls}`, + }, + model: "gpt-5.1", + modelProvider: "openai", + }; + } + if (method === "mcpServer/tool/call") { + expect(requestParams).toEqual({ + threadId: `computer-use-probe-thread-${threadStartCalls}`, + server: "computer-use", + tool: "list_apps", + arguments: {}, + }); + if (liveTestFailures > 0) { + liveTestFailures -= 1; + throw new Error("list_apps timed out"); + } + return { content: [{ type: "text", text: "[]" }] }; + } + if (method === "thread/unsubscribe" || method === "thread/archive") { + expect(requestParams).toEqual({ threadId: `computer-use-probe-thread-${threadStartCalls}` }); + return undefined; + } throw new Error(`unexpected request ${method}`); }) as CodexComputerUseRequest; } @@ -714,9 +1017,6 @@ function createAmbiguousComputerUseRequest(): CodexComputerUseRequest { function createEmptyMarketplaceComputerUseRequest(): CodexComputerUseRequest { return vi.fn(async (method: string) => { - if (method === "experimentalFeature/enablement/set") { - return { enablement: { plugins: true } }; - } if (method === "plugin/list") { return { marketplaces: [], @@ -730,6 +1030,7 @@ function createEmptyMarketplaceComputerUseRequest(): CodexComputerUseRequest { function createMultiMarketplaceComputerUseRequest(): CodexComputerUseRequest { let installed = false; + let threadStartCalls = 0; return vi.fn(async (method: string, requestParams?: unknown) => { if (method === "experimentalFeature/enablement/set") { return { enablement: { plugins: true } }; @@ -789,6 +1090,20 @@ function createMultiMarketplaceComputerUseRequest(): CodexComputerUseRequest { nextCursor: null, }; } + if (method === "thread/start") { + threadStartCalls += 1; + return { + thread: { id: `multi-marketplace-probe-thread-${threadStartCalls}` }, + model: "gpt-5.1", + modelProvider: "openai", + }; + } + if (method === "mcpServer/tool/call") { + return { content: [{ type: "text", text: "[]" }] }; + } + if (method === "thread/unsubscribe" || method === "thread/archive") { + return undefined; + } throw new Error(`unexpected request ${method}`); }) as CodexComputerUseRequest; } @@ -798,6 +1113,7 @@ function createBundledMarketplaceComputerUseRequest( ): CodexComputerUseRequest { let registered = false; let installed = false; + let threadStartCalls = 0; return vi.fn(async (method: string, requestParams?: unknown) => { if (method === "experimentalFeature/enablement/set") { return { enablement: { plugins: true } }; @@ -870,6 +1186,20 @@ function createBundledMarketplaceComputerUseRequest( nextCursor: null, }; } + if (method === "thread/start") { + threadStartCalls += 1; + return { + thread: { id: `bundled-marketplace-probe-thread-${threadStartCalls}` }, + model: "gpt-5.1", + modelProvider: "openai", + }; + } + if (method === "mcpServer/tool/call") { + return { content: [{ type: "text", text: "[]" }] }; + } + if (method === "thread/unsubscribe" || method === "thread/archive") { + return undefined; + } throw new Error(`unexpected request ${method}`); }) as CodexComputerUseRequest; } diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts index 3fc18e8fa276..5518767283bf 100644 --- a/extensions/codex/src/app-server/computer-use.ts +++ b/extensions/codex/src/app-server/computer-use.ts @@ -2,8 +2,9 @@ * Computer Use plugin/MCP readiness checks and optional install flow for Codex * app-server sessions. */ +import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import path from "node:path"; +import { promisify } from "node:util"; import { describeControlFailure } from "./capabilities.js"; import type { CodexAppServerClient } from "./client.js"; import { @@ -12,6 +13,7 @@ import { type CodexComputerUseConfig, type ResolvedCodexComputerUseConfig, } from "./config.js"; +import { resolveFirstExistingMacOSDesktopCodexBundledMarketplacePath } from "./desktop-app-paths.js"; import type { CodexListMcpServerStatusResponse, CodexMcpServerStatus, @@ -19,6 +21,7 @@ import type { CodexPluginListResponse, CodexPluginReadResponse, CodexRequestObject, + CodexThreadStartResponse, JsonValue, } from "./protocol.js"; import { requestCodexAppServerJson } from "./request.js"; @@ -27,6 +30,7 @@ import { requestCodexAppServerJson } from "./request.js"; export type CodexComputerUseRequest = ( method: string, params?: unknown, + options?: { timeoutMs?: number }, ) => Promise; type CodexComputerUseStatusReason = @@ -36,10 +40,48 @@ type CodexComputerUseStatusReason = | "plugin_disabled" | "remote_install_unsupported" | "mcp_missing" + | "live_test_failed" | "ready" | "check_failed" | "auto_install_blocked"; +type CodexComputerUseInstallationStatus = + | "disabled" + | "marketplace_missing" + | "not_installed" + | "installed_disabled" + | "installed"; + +type CodexComputerUseExposureStatus = "skipped" | "missing" | "available"; + +type CodexComputerUseLiveTestState = "skipped" | "passed" | "failed"; + +export type CodexComputerUseStatusSection = { + status: string; + ok: boolean; + message: string; +}; + +export type CodexComputerUseLiveTestStatus = { + status: CodexComputerUseLiveTestState; + ok: boolean; + attempted: boolean; + attempts: number; + timeoutMs: number; + retried: boolean; + repaired: boolean; + message: string; + error?: string; + durationMs?: number; +}; + +export type CodexComputerUseRepairStatus = { + attempted: boolean; + killedPids: number[]; + message: string; + warnings: string[]; +}; + /** Readiness status for Codex Computer Use plugin and MCP server wiring. */ export type CodexComputerUseStatus = { enabled: boolean; @@ -53,6 +95,15 @@ export type CodexComputerUseStatus = { marketplaceName?: string; marketplacePath?: string; tools: string[]; + installation: CodexComputerUseStatusSection & { + status: CodexComputerUseInstallationStatus; + }; + exposure: CodexComputerUseStatusSection & { + status: CodexComputerUseExposureStatus; + }; + liveTest: CodexComputerUseLiveTestStatus; + repair?: CodexComputerUseRepairStatus; + warnings: string[]; message: string; }; @@ -75,7 +126,9 @@ export type CodexComputerUseSetupParams = { timeoutMs?: number; signal?: AbortSignal; forceEnable?: boolean; - defaultBundledMarketplacePaths?: readonly string[]; + defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePathCandidates?: readonly string[]; + repairComputerUseMcpChildren?: () => Promise; }; type MarketplaceRef = @@ -107,11 +160,9 @@ type PluginInspection = const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000; const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"]; -// ChatGPT.app is the current desktop owner; keep Codex.app as the legacy fallback. -const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATHS = [ - "/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled", - "/Applications/Codex.app/Contents/Resources/plugins/openai-bundled", -] as const; +const COMPUTER_USE_LIVE_TEST_RETRY_COUNT = 1; +const COMPUTER_USE_LIVE_TEST_THREAD_NAME = "OpenClaw Computer Use readiness probe"; +const execFileAsync = promisify(execFile); /** Reads Computer Use readiness without installing or mutating app-server state. */ export async function readCodexComputerUseStatus( @@ -155,6 +206,9 @@ export async function ensureCodexComputerUse( if (status.ready) { return status; } + if (isNonStrictLiveTestStartupAllowed(status, config)) { + return status; + } if (config.autoInstall) { const blockedAutoInstallStatus = blockUnsafeAutoInstallStatus(config); if (blockedAutoInstallStatus) { @@ -165,6 +219,9 @@ export async function ensureCodexComputerUse( config, installPlugin: true, }); + if (isNonStrictLiveTestStartupAllowed(installedStatus, config)) { + return installedStatus; + } if (!installedStatus.ready) { throw new CodexComputerUseSetupError(installedStatus); } @@ -204,9 +261,16 @@ async function inspectCodexComputerUse(params: { signal?: AbortSignal; config: ResolvedCodexComputerUseConfig; installPlugin: boolean; - defaultBundledMarketplacePaths?: readonly string[]; + defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePathCandidates?: readonly string[]; + repairComputerUseMcpChildren?: () => Promise; }): Promise { const request = createComputerUseRequest(params); + const repairComputerUseMcpChildren = + params.repairComputerUseMcpChildren ?? + (params.client + ? () => killStaleComputerUseMcpChildren({ ancestorPid: params.client?.getTransportPid() }) + : undefined); if (params.installPlugin) { await request("experimentalFeature/enablement/set", { enablement: { plugins: true }, @@ -218,7 +282,8 @@ async function inspectCodexComputerUse(params: { config: params.config, allowAdd: params.installPlugin, signal: params.signal, - defaultBundledMarketplacePaths: params.defaultBundledMarketplacePaths, + defaultBundledMarketplacePath: params.defaultBundledMarketplacePath, + defaultBundledMarketplacePathCandidates: params.defaultBundledMarketplacePathCandidates, }); if (!marketplace.marketplace) { return unavailableStatus( @@ -244,6 +309,7 @@ async function inspectCodexComputerUse(params: { config: params.config, plugin: pluginInspection.plugin, installPlugin: params.installPlugin, + repairComputerUseMcpChildren, }); } @@ -314,6 +380,7 @@ async function readComputerUseTools(params: { config: ResolvedCodexComputerUseConfig; plugin: CodexPluginDetail; installPlugin: boolean; + repairComputerUseMcpChildren?: () => Promise; }): Promise { let server = await readMcpServerStatus(params.request, params.config.mcpServerName); if (!server && params.installPlugin) { @@ -330,13 +397,162 @@ async function readComputerUseTools(params: { }); } - return statusFromPlugin({ + const status = statusFromPlugin({ config: params.config, plugin: params.plugin, tools: Object.keys(server.tools).toSorted(), reason: "ready", message: "Computer Use is ready.", }); + const { liveTest, repair } = await runCodexComputerUseLiveTest({ + request: params.request, + config: params.config, + repairComputerUseMcpChildren: params.repairComputerUseMcpChildren, + }); + const compatibilityStartupAllowed = !liveTest.ok && !params.config.strictReadiness; + return { + ...status, + ready: liveTest.ok, + reason: liveTest.ok ? "ready" : "live_test_failed", + liveTest, + ...(repair ? { repair } : {}), + warnings: [ + ...status.warnings, + ...(repair?.warnings ?? []), + ...(compatibilityStartupAllowed + ? [ + "Computer Use live test failed, but compatibility startup remains enabled; set computerUse.strictReadiness to true to fail closed.", + ] + : []), + ], + message: liveTest.ok + ? "Computer Use is ready." + : compatibilityStartupAllowed + ? `${liveTest.message} Startup is allowed because computerUse.strictReadiness is false.` + : liveTest.message, + }; +} + +function isNonStrictLiveTestStartupAllowed( + status: CodexComputerUseStatus, + config: ResolvedCodexComputerUseConfig, +): boolean { + return ( + !config.strictReadiness && + status.reason === "live_test_failed" && + status.installed && + status.pluginEnabled && + status.mcpServerAvailable && + status.installation.ok && + status.exposure.ok + ); +} + +export async function runCodexComputerUseLiveTest(params: { + request: CodexComputerUseRequest; + config: ResolvedCodexComputerUseConfig; + repairComputerUseMcpChildren?: () => Promise; +}): Promise<{ liveTest: CodexComputerUseLiveTestStatus; repair?: CodexComputerUseRepairStatus }> { + const startedAt = Date.now(); + let lastError: unknown; + let repair: CodexComputerUseRepairStatus | undefined; + for (let attempt = 0; attempt <= COMPUTER_USE_LIVE_TEST_RETRY_COUNT; attempt += 1) { + let threadId: string | undefined; + try { + const thread = await params.request( + "thread/start", + { + input: [], + developerInstructions: COMPUTER_USE_LIVE_TEST_THREAD_NAME, + sandbox: "danger-full-access", + approvalPolicy: "never", + ephemeral: true, + }, + { + timeoutMs: params.config.liveTestTimeoutMs, + }, + ); + threadId = thread.thread.id; + await params.request( + "mcpServer/tool/call", + { + threadId, + server: params.config.mcpServerName, + tool: "list_apps", + arguments: {}, + }, + { + timeoutMs: params.config.toolCallTimeoutMs, + }, + ); + return { + liveTest: { + status: "passed", + ok: true, + attempted: true, + attempts: attempt + 1, + timeoutMs: params.config.liveTestTimeoutMs, + retried: attempt > 0, + repaired: Boolean(repair?.attempted), + durationMs: Math.max(0, Date.now() - startedAt), + message: "Computer Use live test passed.", + }, + ...(repair ? { repair } : {}), + }; + } catch (error) { + lastError = error; + if (attempt >= COMPUTER_USE_LIVE_TEST_RETRY_COUNT) { + break; + } + if (params.config.autoRepair) { + repair = params.repairComputerUseMcpChildren + ? await params.repairComputerUseMcpChildren() + : scopedRepairUnavailableStatus(); + } + } finally { + if (threadId) { + await cleanupComputerUseProbeThread(params.request, threadId, params.config); + } + } + } + const errorMessage = describeControlFailure(lastError); + return { + liveTest: { + status: "failed", + ok: false, + attempted: true, + attempts: COMPUTER_USE_LIVE_TEST_RETRY_COUNT + 1, + timeoutMs: params.config.liveTestTimeoutMs, + retried: COMPUTER_USE_LIVE_TEST_RETRY_COUNT > 0, + repaired: Boolean(repair?.attempted), + durationMs: Math.max(0, Date.now() - startedAt), + message: `Computer Use live test failed after ${COMPUTER_USE_LIVE_TEST_RETRY_COUNT + 1} attempts: ${errorMessage}`, + error: errorMessage, + }, + ...(repair ? { repair } : {}), + }; +} + +async function cleanupComputerUseProbeThread( + request: CodexComputerUseRequest, + threadId: string, + config: ResolvedCodexComputerUseConfig, +): Promise { + await Promise.allSettled([ + request("thread/unsubscribe", { threadId }, { timeoutMs: config.liveTestTimeoutMs }), + request("thread/archive", { threadId }, { timeoutMs: config.liveTestTimeoutMs }), + ]); +} + +function scopedRepairUnavailableStatus(): CodexComputerUseRepairStatus { + return { + attempted: false, + killedPids: [], + warnings: [ + "Computer Use auto-repair skipped because no scoped Codex app-server process was available.", + ], + message: "Computer Use stale child repair requires a scoped local app-server PID.", + }; } async function resolveMarketplaceRef(params: { @@ -344,7 +560,8 @@ async function resolveMarketplaceRef(params: { config: ResolvedCodexComputerUseConfig; allowAdd: boolean; signal?: AbortSignal; - defaultBundledMarketplacePaths?: readonly string[]; + defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePathCandidates?: readonly string[]; }): Promise { let preferredMarketplaceName = params.config.marketplaceName; if (params.config.marketplaceSource && params.allowAdd) { @@ -362,27 +579,17 @@ async function resolveMarketplaceRef(params: { } let candidates = await listComputerUseMarketplaceCandidates(params.request, params.config); + const bundledMarketplacePath = resolveBundledComputerUseMarketplacePath(params); if ( candidates.length === 0 && - params.allowAdd && - usesDefaultMarketplaceDiscovery(params.config) + bundledMarketplacePath && + shouldAddBundledComputerUseMarketplace(params) ) { - // Most turns already have a registered marketplace. Probe app bundles only - // on the empty auto-install path to keep ordinary startup free of filesystem I/O. - const bundledMarketplacePath = ( - params.defaultBundledMarketplacePaths ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATHS - ).find((candidatePath) => - // The signed desktop bundles publish plugins under this fixed marketplace layout. - // Check the requested plugin before registering a source that would shadow the fallback. - existsSync(path.join(candidatePath, "plugins", params.config.pluginName)), - ); - if (bundledMarketplacePath) { - const added = await params.request<{ marketplaceName?: string }>("marketplace/add", { - source: bundledMarketplacePath, - } satisfies CodexRequestObject); - preferredMarketplaceName ??= added.marketplaceName; - candidates = await listComputerUseMarketplaceCandidates(params.request, params.config); - } + const added = await params.request<{ marketplaceName?: string }>("marketplace/add", { + source: bundledMarketplacePath, + } satisfies CodexRequestObject); + preferredMarketplaceName ??= added.marketplaceName; + candidates = await listComputerUseMarketplaceCandidates(params.request, params.config); } const waitUntil = marketplaceDiscoveryWaitUntil(params); @@ -422,10 +629,7 @@ async function resolveMarketplaceRef(params: { }; } const marketplace = candidates[0]; - if (marketplace) { - return { marketplace }; - } - return {}; + return marketplace ? { marketplace } : {}; } async function listComputerUseMarketplaceCandidates( @@ -451,8 +655,33 @@ function blockUnsafeAutoInstallStatus( ); } -function usesDefaultMarketplaceDiscovery(config: ResolvedCodexComputerUseConfig): boolean { - return !config.marketplaceSource && !config.marketplacePath && !config.marketplaceName; +function shouldAddBundledComputerUseMarketplace(params: { + config: ResolvedCodexComputerUseConfig; + allowAdd: boolean; + defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePathCandidates?: readonly string[]; +}): boolean { + return ( + params.allowAdd && + !params.config.marketplaceSource && + !params.config.marketplacePath && + !params.config.marketplaceName && + Boolean(resolveBundledComputerUseMarketplacePath(params)) + ); +} + +function resolveBundledComputerUseMarketplacePath(params: { + defaultBundledMarketplacePath?: string; + defaultBundledMarketplacePathCandidates?: readonly string[]; +}): string | undefined { + if (params.defaultBundledMarketplacePath) { + return existsSync(params.defaultBundledMarketplacePath) + ? params.defaultBundledMarketplacePath + : undefined; + } + return resolveFirstExistingMacOSDesktopCodexBundledMarketplacePath({ + candidates: params.defaultBundledMarketplacePathCandidates, + }); } function findComputerUseMarketplaces( @@ -492,7 +721,12 @@ function marketplaceDiscoveryWaitUntil(params: { config: ResolvedCodexComputerUseConfig; allowAdd: boolean; }): number { - if (params.allowAdd && usesDefaultMarketplaceDiscovery(params.config)) { + if ( + params.allowAdd && + !params.config.marketplaceSource && + !params.config.marketplacePath && + !params.config.marketplaceName + ) { return Date.now() + params.config.marketplaceDiscoveryTimeoutMs; } return 0; @@ -620,6 +854,10 @@ function statusFromPlugin(params: { marketplaceName: params.plugin.marketplaceName, ...(params.plugin.marketplacePath ? { marketplacePath: params.plugin.marketplacePath } : {}), tools: params.tools, + installation: installationStatusFromPlugin(params.plugin, params.message), + exposure: exposureStatusFromTools(params.config, params.tools), + liveTest: skippedLiveTestStatus(params.config, "Computer Use live test was not run."), + warnings: pluginWarnings(params.plugin), message: params.message, }; } @@ -635,6 +873,21 @@ function disabledStatus(config: ResolvedCodexComputerUseConfig): CodexComputerUs pluginName: config.pluginName, mcpServerName: config.mcpServerName, tools: [], + installation: { + status: "disabled", + ok: false, + message: "Computer Use is disabled.", + }, + exposure: { + status: "skipped", + ok: false, + message: "MCP exposure was not checked because Computer Use is disabled.", + }, + liveTest: skippedLiveTestStatus( + config, + "Computer Use live test was not run because Computer Use is disabled.", + ), + warnings: [], message: "Computer Use is disabled.", }; } @@ -656,10 +909,206 @@ function unavailableStatus( ...(config.marketplaceName ? { marketplaceName: config.marketplaceName } : {}), ...(config.marketplacePath ? { marketplacePath: config.marketplacePath } : {}), tools: [], + installation: { + status: reason === "marketplace_missing" ? "marketplace_missing" : "not_installed", + ok: false, + message, + }, + exposure: { + status: "skipped", + ok: false, + message: "MCP exposure was not checked because Computer Use installation is not ready.", + }, + liveTest: skippedLiveTestStatus( + config, + "Computer Use live test was not run because installation is not ready.", + ), + warnings: [], message, }; } +function installationStatusFromPlugin( + plugin: CodexPluginDetail, + message: string, +): CodexComputerUseStatus["installation"] { + if (!plugin.summary.installed) { + return { + status: "not_installed", + ok: false, + message, + }; + } + if (!plugin.summary.enabled) { + return { + status: "installed_disabled", + ok: false, + message, + }; + } + return { + status: "installed", + ok: true, + message: "Computer Use plugin is installed and enabled.", + }; +} + +function exposureStatusFromTools( + config: ResolvedCodexComputerUseConfig, + tools: string[], +): CodexComputerUseStatus["exposure"] { + if (tools.length === 0) { + return { + status: "missing", + ok: false, + message: `Computer Use MCP server ${config.mcpServerName} is not exposed.`, + }; + } + return { + status: "available", + ok: true, + message: `Computer Use MCP server ${config.mcpServerName} exposes ${tools.length} tools.`, + }; +} + +function skippedLiveTestStatus( + config: ResolvedCodexComputerUseConfig, + message: string, +): CodexComputerUseLiveTestStatus { + return { + status: "skipped", + ok: false, + attempted: false, + attempts: 0, + timeoutMs: config.liveTestTimeoutMs, + retried: false, + repaired: false, + message, + }; +} + +function pluginWarnings(plugin: CodexPluginDetail): string[] { + const warnings: string[] = []; + const source = plugin.summary.source; + if (source && typeof source === "object" && "type" in source && source.type === "remote") { + warnings.push( + "Computer Use plugin is resolved from a remote marketplace; live local bundles are preferred.", + ); + } + return warnings; +} + +export async function killStaleComputerUseMcpChildren( + options: { ancestorPid?: number } = {}, +): Promise { + if (process.platform !== "darwin") { + return { + attempted: true, + killedPids: [], + warnings: [ + `Computer Use stale child repair is currently macOS-only, not ${process.platform}.`, + ], + message: "Computer Use stale child repair skipped on this platform.", + }; + } + if ( + !options.ancestorPid || + !Number.isSafeInteger(options.ancestorPid) || + options.ancestorPid <= 0 + ) { + return scopedRepairUnavailableStatus(); + } + let stdout: string; + try { + const result = await execFileAsync("/bin/ps", ["-axo", "pid=,ppid=,command="], { + maxBuffer: 5 * 1024 * 1024, + }); + stdout = result.stdout; + } catch (error) { + return { + attempted: true, + killedPids: [], + warnings: [ + `Could not list processes for Computer Use repair: ${describeControlFailure(error)}`, + ], + message: "Computer Use stale child repair could not inspect running processes.", + }; + } + const killedPids: number[] = []; + const warnings: string[] = []; + const processInfos = parsePsOutput(stdout); + for (const processInfo of processInfos) { + if (!isStaleComputerUseMcpChild(processInfo.command)) { + continue; + } + if (!isDescendantOfPid(processInfo.pid, options.ancestorPid, processInfos)) { + continue; + } + try { + process.kill(processInfo.pid, "SIGTERM"); + killedPids.push(processInfo.pid); + } catch (error) { + warnings.push( + `Could not terminate stale Computer Use MCP child pid ${processInfo.pid}: ${describeControlFailure(error)}`, + ); + } + } + return { + attempted: true, + killedPids, + warnings, + message: + killedPids.length === 0 + ? "No stale Computer Use MCP children were found under the scoped Codex app-server process." + : `Terminated ${killedPids.length} stale Computer Use MCP child process${killedPids.length === 1 ? "" : "es"} under the scoped Codex app-server process.`, + }; +} + +function parsePsOutput(stdout: string): Array<{ pid: number; ppid: number; command: string }> { + return stdout + .split(/\r?\n/u) + .flatMap((line) => { + const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/u.exec(line); + if (!match) { + return []; + } + return [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] ?? "" }]; + }) + .filter( + (processInfo) => + Number.isSafeInteger(processInfo.pid) && + processInfo.pid > 0 && + Number.isSafeInteger(processInfo.ppid) && + processInfo.ppid >= 0, + ); +} + +function isStaleComputerUseMcpChild(command: string): boolean { + return command.includes("SkyComputerUseClient") && /(?:^|\s)mcp(?:\s|$)/u.test(command); +} + +function isDescendantOfPid( + pid: number, + ancestorPid: number, + processInfos: Array<{ pid: number; ppid: number }>, +): boolean { + const parents = new Map(processInfos.map((processInfo) => [processInfo.pid, processInfo.ppid])); + const seen = new Set(); + let current = pid; + while (!seen.has(current)) { + seen.add(current); + const parent = parents.get(current); + if (!parent || parent <= 0) { + return false; + } + if (parent === ancestorPid) { + return true; + } + current = parent; + } + return false; +} + function createComputerUseRequest(params: { pluginConfig?: unknown; request?: CodexComputerUseRequest; @@ -671,22 +1120,33 @@ function createComputerUseRequest(params: { return params.request; } if (params.client) { - return async (method: string, requestParams?: unknown) => + return async ( + method: string, + requestParams?: unknown, + options?: { timeoutMs?: number }, + ) => await params.client!.request(method, requestParams, { - timeoutMs: params.timeoutMs, + timeoutMs: options?.timeoutMs ?? params.timeoutMs, signal: params.signal, }); } const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig }); - return async (method: string, requestParams?: unknown) => + return async ( + method: string, + requestParams?: unknown, + options?: { timeoutMs?: number }, + ) => await requestCodexAppServerJson({ method, requestParams, - timeoutMs: params.timeoutMs ?? runtime.requestTimeoutMs, + timeoutMs: options?.timeoutMs ?? params.timeoutMs ?? runtime.requestTimeoutMs, + pluginConfig: params.pluginConfig, startOptions: runtime.start, }); } +export const testing = { isDescendantOfPid, parsePsOutput }; + function resolveComputerUseConfig( params: Pick, ): ResolvedCodexComputerUseConfig { diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index b9063d21d7b9..583a19de0908 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -1604,6 +1604,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] enabled: true, autoInstall: true, marketplaceDiscoveryTimeoutMs: 60_000, + liveTestTimeoutMs: 60_000, + toolCallTimeoutMs: 60_000, + healthCheckEnabled: false, + healthCheckIntervalMinutes: 60, + pluginCacheMode: "independent", + strictReadiness: false, + autoRepair: false, pluginName: "env-fallback-plugin", mcpServerName: "computer-use", marketplaceName: "desktop-tools", @@ -1624,6 +1631,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] enabled: true, autoInstall: true, marketplaceDiscoveryTimeoutMs: 30_000, + liveTestTimeoutMs: 60_000, + toolCallTimeoutMs: 60_000, + healthCheckEnabled: false, + healthCheckIntervalMinutes: 60, + pluginCacheMode: "independent", + strictReadiness: false, + autoRepair: false, marketplaceSource: "github:example/plugins", }, ); @@ -1641,11 +1655,75 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] { enabled: true, marketplaceDiscoveryTimeoutMs: 60_000, + liveTestTimeoutMs: 60_000, + toolCallTimeoutMs: 60_000, + healthCheckEnabled: false, + healthCheckIntervalMinutes: 60, + pluginCacheMode: "independent", + strictReadiness: false, + autoRepair: false, }, ); } }); + it("resolves Computer Use operational policy knobs", () => { + expectFields( + resolveCodexComputerUseConfig({ + pluginConfig: { + computerUse: { + enabled: true, + liveTestTimeoutMs: 45_000, + toolCallTimeoutMs: 55_000, + healthCheckEnabled: true, + healthCheckIntervalMinutes: 120, + pluginCacheMode: "independent", + strictReadiness: true, + autoRepair: true, + }, + }, + env: { + OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_ENABLED: "false", + OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_INTERVAL_MINUTES: "240", + OPENCLAW_CODEX_COMPUTER_USE_STRICT_READINESS: "false", + OPENCLAW_CODEX_COMPUTER_USE_AUTO_REPAIR: "false", + }, + }), + "computer use config", + { + enabled: true, + liveTestTimeoutMs: 45_000, + toolCallTimeoutMs: 55_000, + healthCheckEnabled: true, + healthCheckIntervalMinutes: 120, + pluginCacheMode: "independent", + strictReadiness: true, + autoRepair: true, + }, + ); + + expectFields( + resolveCodexComputerUseConfig({ + pluginConfig: { computerUse: { enabled: true } }, + env: { + OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_ENABLED: "1", + OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_INTERVAL_MINUTES: "90", + OPENCLAW_CODEX_COMPUTER_USE_STRICT_READINESS: "true", + OPENCLAW_CODEX_COMPUTER_USE_AUTO_REPAIR: "true", + OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_CACHE_MODE: "stale-copy", + }, + }), + "computer use config", + { + healthCheckEnabled: true, + healthCheckIntervalMinutes: 60, + pluginCacheMode: "independent", + strictReadiness: true, + autoRepair: true, + }, + ); + }); + it("allows plugin config to opt in to guardian-reviewed local execution", () => { const runtime = resolveRuntimeForTest({ pluginConfig: { diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 12177d7fe8c5..b8a99a955457 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -80,6 +80,13 @@ export type CodexComputerUseConfig = { enabled?: boolean; autoInstall?: boolean; marketplaceDiscoveryTimeoutMs?: number; + liveTestTimeoutMs?: number; + toolCallTimeoutMs?: number; + healthCheckEnabled?: boolean; + healthCheckIntervalMinutes?: number; + pluginCacheMode?: "shared" | "independent"; + strictReadiness?: boolean; + autoRepair?: boolean; marketplaceSource?: string; marketplacePath?: string; marketplaceName?: string; @@ -91,6 +98,13 @@ export type ResolvedCodexComputerUseConfig = { enabled: boolean; autoInstall: boolean; marketplaceDiscoveryTimeoutMs: number; + liveTestTimeoutMs: number; + toolCallTimeoutMs: number; + healthCheckEnabled: boolean; + healthCheckIntervalMinutes: 30 | 60 | 120 | 240; + pluginCacheMode: "shared" | "independent"; + strictReadiness: boolean; + autoRepair: boolean; pluginName: string; mcpServerName: string; marketplaceSource?: string; @@ -304,6 +318,13 @@ export const CODEX_COMPUTER_USE_CONFIG_KEYS = [ "enabled", "autoInstall", "marketplaceDiscoveryTimeoutMs", + "liveTestTimeoutMs", + "toolCallTimeoutMs", + "healthCheckEnabled", + "healthCheckIntervalMinutes", + "pluginCacheMode", + "strictReadiness", + "autoRepair", "marketplaceSource", "marketplacePath", "marketplaceName", @@ -352,6 +373,9 @@ export const CODEX_SUPERVISION_WEBSOCKET_ENDPOINT_CONFIG_KEYS = [ const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use"; const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use"; const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000; +const DEFAULT_CODEX_COMPUTER_USE_LIVE_TEST_TIMEOUT_MS = 60_000; +const DEFAULT_CODEX_COMPUTER_USE_TOOL_CALL_TIMEOUT_MS = 60_000; +const DEFAULT_CODEX_COMPUTER_USE_HEALTH_CHECK_INTERVAL_MINUTES = 60; const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network"; const codexAppServerTransportSchema = z.enum(["stdio", "websocket", "unix"]); @@ -367,6 +391,13 @@ const codexAppServerApprovalPolicySchema = z.preprocess( const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]); const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]); const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]); +const codexComputerUseHealthIntervalSchema = z.union([ + z.literal(30), + z.literal(60), + z.literal(120), + z.literal(240), +]); +const codexComputerUsePluginCacheModeSchema = z.enum(["shared", "independent"]); const codexPluginDestructivePolicySchema = z.union([ z.boolean(), z.literal("auto"), @@ -472,6 +503,13 @@ const codexPluginConfigSchema = z enabled: z.boolean().optional(), autoInstall: z.boolean().optional(), marketplaceDiscoveryTimeoutMs: z.number().positive().optional(), + liveTestTimeoutMs: z.number().positive().optional(), + toolCallTimeoutMs: z.number().positive().optional(), + healthCheckEnabled: z.boolean().optional(), + healthCheckIntervalMinutes: codexComputerUseHealthIntervalSchema.optional(), + pluginCacheMode: codexComputerUsePluginCacheModeSchema.optional(), + strictReadiness: z.boolean().optional(), + autoRepair: z.boolean().optional(), marketplaceSource: z.string().optional(), marketplacePath: z.string().optional(), marketplaceName: z.string().optional(), @@ -955,6 +993,43 @@ export function resolveCodexComputerUseConfig( readNumberEnv(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS), DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS, ); + const liveTestTimeoutMs = normalizePositiveNumber( + params.overrides?.liveTestTimeoutMs ?? + config.liveTestTimeoutMs ?? + readNumberEnv(env.OPENCLAW_CODEX_COMPUTER_USE_LIVE_TEST_TIMEOUT_MS), + DEFAULT_CODEX_COMPUTER_USE_LIVE_TEST_TIMEOUT_MS, + ); + const toolCallTimeoutMs = normalizePositiveNumber( + params.overrides?.toolCallTimeoutMs ?? + config.toolCallTimeoutMs ?? + readNumberEnv(env.OPENCLAW_CODEX_COMPUTER_USE_TOOL_CALL_TIMEOUT_MS), + DEFAULT_CODEX_COMPUTER_USE_TOOL_CALL_TIMEOUT_MS, + ); + const healthCheckIntervalMinutes = normalizeComputerUseHealthCheckIntervalMinutes( + params.overrides?.healthCheckIntervalMinutes ?? + config.healthCheckIntervalMinutes ?? + readNumberEnv(env.OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_INTERVAL_MINUTES), + ); + const healthCheckEnabled = + params.overrides?.healthCheckEnabled ?? + config.healthCheckEnabled ?? + readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE_HEALTH_CHECK_ENABLED) ?? + false; + const pluginCacheMode = + normalizeComputerUsePluginCacheMode(params.overrides?.pluginCacheMode) ?? + normalizeComputerUsePluginCacheMode(config.pluginCacheMode) ?? + normalizeComputerUsePluginCacheMode(env.OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_CACHE_MODE) ?? + "independent"; + const strictReadiness = + params.overrides?.strictReadiness ?? + config.strictReadiness ?? + readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE_STRICT_READINESS) ?? + false; + const autoRepair = + params.overrides?.autoRepair ?? + config.autoRepair ?? + readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE_AUTO_REPAIR) ?? + false; const enabled = params.overrides?.enabled ?? config.enabled ?? @@ -965,6 +1040,13 @@ export function resolveCodexComputerUseConfig( enabled, autoInstall, marketplaceDiscoveryTimeoutMs, + liveTestTimeoutMs, + toolCallTimeoutMs, + healthCheckEnabled, + healthCheckIntervalMinutes, + pluginCacheMode, + strictReadiness, + autoRepair, pluginName: readNonEmptyString(params.overrides?.pluginName) ?? readNonEmptyString(config.pluginName) ?? @@ -981,6 +1063,16 @@ export function resolveCodexComputerUseConfig( }; } +function normalizeComputerUseHealthCheckIntervalMinutes(value: unknown): 30 | 60 | 120 | 240 { + return value === 30 || value === 60 || value === 120 || value === 240 + ? value + : DEFAULT_CODEX_COMPUTER_USE_HEALTH_CHECK_INTERVAL_MINUTES; +} + +function normalizeComputerUsePluginCacheMode(value: unknown): "shared" | "independent" | null { + return value === "shared" || value === "independent" ? value : null; +} + export function codexAppServerStartOptionsKey( options: CodexAppServerStartOptions, params: { diff --git a/extensions/codex/src/app-server/desktop-app-paths.ts b/extensions/codex/src/app-server/desktop-app-paths.ts new file mode 100644 index 000000000000..063af7341181 --- /dev/null +++ b/extensions/codex/src/app-server/desktop-app-paths.ts @@ -0,0 +1,54 @@ +/** Shared path candidates for Codex's macOS desktop app bundle. */ +import { existsSync } from "node:fs"; + +export type MacOSDesktopCodexAppPathCandidate = { + appName: "ChatGPT.app" | "Codex.app"; + appBundlePath: string; + appServerCommandPath: string; + bundledMarketplacePath: string; +}; + +export const MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES: readonly MacOSDesktopCodexAppPathCandidate[] = + [ + { + appName: "ChatGPT.app", + appBundlePath: "/Applications/ChatGPT.app", + appServerCommandPath: "/Applications/ChatGPT.app/Contents/Resources/codex", + bundledMarketplacePath: "/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled", + }, + { + appName: "Codex.app", + appBundlePath: "/Applications/Codex.app", + appServerCommandPath: "/Applications/Codex.app/Contents/Resources/codex", + bundledMarketplacePath: "/Applications/Codex.app/Contents/Resources/plugins/openai-bundled", + }, + ] as const; + +export function resolveMacOSDesktopCodexAppServerCommandCandidates( + platform: NodeJS.Platform = process.platform, +): string[] { + return platform === "darwin" + ? MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES.map((candidate) => candidate.appServerCommandPath) + : []; +} + +export function resolveMacOSDesktopCodexBundledMarketplaceCandidates( + platform: NodeJS.Platform = process.platform, +): string[] { + return platform === "darwin" + ? MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES.map((candidate) => candidate.bundledMarketplacePath) + : []; +} + +export function resolveFirstExistingMacOSDesktopCodexBundledMarketplacePath( + params: { + platform?: NodeJS.Platform; + candidates?: readonly string[]; + pathExists?: (filePath: string) => boolean; + } = {}, +): string | undefined { + const candidates = + params.candidates ?? resolveMacOSDesktopCodexBundledMarketplaceCandidates(params.platform); + const pathExists = params.pathExists ?? existsSync; + return candidates.find((candidate) => pathExists(candidate)); +} diff --git a/extensions/codex/src/app-server/request.ts b/extensions/codex/src/app-server/request.ts index 8861a70ea9bb..3a662e0c967c 100644 --- a/extensions/codex/src/app-server/request.ts +++ b/extensions/codex/src/app-server/request.ts @@ -56,6 +56,7 @@ export async function requestCodexAppServerJson; timeoutMs?: number; + pluginConfig?: unknown; startOptions?: CodexAppServerStartOptions; authProfileId?: string | null; agentDir?: string; @@ -68,6 +69,7 @@ export async function requestCodexAppServerJson(param method: string; requestParams?: unknown; timeoutMs?: number; + pluginConfig?: unknown; startOptions?: CodexAppServerStartOptions; authProfileId?: string | null; agentDir?: string; @@ -80,6 +82,7 @@ export async function requestCodexAppServerJson(param method: string; requestParams?: unknown; timeoutMs?: number; + pluginConfig?: unknown; startOptions?: CodexAppServerStartOptions; authProfileId?: string | null; agentDir?: string; @@ -105,6 +108,7 @@ export async function requestCodexAppServerJson(param params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient )({ startOptions: params.startOptions, + pluginConfig: params.pluginConfig, timeoutMs, authProfileId: params.authProfileId, agentDir: params.agentDir, diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 8d9bb588b911..0e4eb4178f30 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -62,6 +62,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState { export type CodexAppServerClientOptions = { startOptions?: CodexAppServerStartOptions; + pluginConfig?: unknown; timeoutMs?: number; authProfileId?: string | null; agentDir?: string; @@ -120,6 +121,7 @@ async function resolveCodexAppServerClientStartContext( agentDir, authProfileId: usesNativeAuth ? null : authProfileId, config: options?.config, + pluginConfig: options?.pluginConfig, ...(authProfileStore ? { authProfileStore } : {}), }); return { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions }; diff --git a/extensions/codex/src/app-server/test-support.ts b/extensions/codex/src/app-server/test-support.ts index 6a214e5a8b69..a797a5a5f5d5 100644 --- a/extensions/codex/src/app-server/test-support.ts +++ b/extensions/codex/src/app-server/test-support.ts @@ -3,12 +3,34 @@ * transports. */ import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import type { Model } from "openclaw/plugin-sdk/llm"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { vi } from "vitest"; import { CodexAppServerClient } from "./client.js"; import type { CodexAppServerClientFactory, CodexAppServerClientOptions } from "./shared-client.js"; +/** Creates temp directories that are removed by the supplied test cleanup hook. */ +export function useAutoCleanupTempDirTracker(registerCleanup: (cleanup: () => void) => unknown) { + const dirs = new Set(); + registerCleanup(() => { + for (const dir of dirs) { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); + } + dirs.clear(); + }); + return { + dirs, + make(prefix: string): string { + const dir = fs.mkdtempSync(path.join(resolvePreferredOpenClawTmpDir(), prefix)); + dirs.add(dir); + return dir; + }, + }; +} + /** Positional naked-client injection contract confined to tests. */ export type CodexTestAppServerClientFactory = ( startOptions?: CodexAppServerClientOptions["startOptions"], diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 08a53986bb73..aa465329da13 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -170,17 +170,36 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string lines.push( `Plugin: ${formatCodexDisplayText(status.pluginName)} (${computerUsePluginState(status)})`, ); + lines.push( + `Installation: ${formatCodexDisplayText(status.installation.status)} (${status.installation.ok ? "ok" : "not ok"})`, + ); lines.push( `MCP server: ${formatCodexDisplayText(status.mcpServerName)}${ status.mcpServerAvailable ? ` (${status.tools.length} tools)` : " (unavailable)" }`, ); + lines.push( + `Exposure: ${formatCodexDisplayText(status.exposure.status)} (${status.exposure.ok ? "ok" : "not ok"})`, + ); + lines.push( + `Live test: ${formatCodexDisplayText(status.liveTest.status)} (${status.liveTest.attempted ? `${status.liveTest.attempts} attempt${status.liveTest.attempts === 1 ? "" : "s"}, ${status.liveTest.timeoutMs}ms` : "not run"})`, + ); + if (status.liveTest.retried || status.liveTest.repaired) { + lines.push( + `Live test recovery: retried=${status.liveTest.retried ? "yes" : "no"}, repaired=${ + status.liveTest.repaired ? "yes" : "no" + }`, + ); + } if (status.marketplaceName) { lines.push(`Marketplace: ${formatCodexDisplayText(status.marketplaceName)}`); } if (status.tools.length > 0) { lines.push(`Tools: ${status.tools.slice(0, 8).map(formatCodexDisplayText).join(", ")}`); } + for (const warning of status.warnings) { + lines.push(`Warning: ${formatCodexDisplayText(warning)}`); + } lines.push(formatCodexDisplayText(status.message)); return lines.join("\n"); } diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index c1f231e20a77..09d5c0aec601 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -2330,7 +2330,10 @@ describe("codex command", () => { text: [ "Computer Use: ready", "Plugin: computer-use (installed)", + "Installation: installed (ok)", "MCP server: computer-use (1 tools)", + "Exposure: available (ok)", + "Live test: passed (1 attempt, 60000ms)", "Marketplace: desktop-tools", "Tools: list\uff3fapps", "Computer Use is ready.", @@ -2342,6 +2345,38 @@ describe("codex command", () => { }); }); + it("formats failed Codex Computer Use live probes as not ready", async () => { + const readCodexComputerUseStatus = vi.fn(async () => ({ + ...computerUseReadyStatus(), + ready: false, + reason: "live_test_failed" as const, + liveTest: { + status: "failed" as const, + ok: false, + attempted: true, + attempts: 2, + timeoutMs: 60_000, + retried: true, + repaired: false, + message: "Computer Use live test failed after 2 attempts: list_apps timed out", + error: "list_apps timed out", + }, + warnings: [ + "Computer Use live test failed, but compatibility startup remains enabled; set computerUse.strictReadiness to true to fail closed.", + ], + message: + "Computer Use live test failed after 2 attempts: list_apps timed out Startup is allowed because computerUse.strictReadiness is false.", + })); + + const result = await handleCodexCommand(createContext("computer-use status"), { + deps: createDeps({ readCodexComputerUseStatus }), + }); + + expectResultTextContains(result, "Computer Use: not ready"); + expectResultTextContains(result, "Live test: failed (2 attempts, 60000ms)"); + expectResultTextContains(result, "Warning: Computer Use live test failed"); + }); + it("escapes Codex Computer Use status fields before chat display", async () => { const readCodexComputerUseStatus = vi.fn(async () => ({ ...computerUseReadyStatus(), @@ -4964,6 +4999,27 @@ function computerUseReadyStatus(): CodexComputerUseStatus { mcpServerName: "computer-use", marketplaceName: "desktop-tools", tools: ["list_apps"], + installation: { + status: "installed", + ok: true, + message: "Computer Use plugin is installed and enabled.", + }, + exposure: { + status: "available", + ok: true, + message: "Computer Use MCP server computer-use exposes 1 tools.", + }, + liveTest: { + status: "passed", + ok: true, + attempted: true, + attempts: 1, + timeoutMs: 60_000, + retried: false, + repaired: false, + message: "Computer Use live test passed.", + }, + warnings: [], message: "Computer Use is ready.", }; } diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index b91780210881..3decfe4cbd94 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -75,6 +75,65 @@ describe("collectCodexRouteWarnings", () => { ]); }); + it("surfaces enabled Codex Computer Use in doctor warnings", () => { + const warnings = collectCodexRouteWarnings({ + cfg: { + plugins: { + entries: { + codex: { + enabled: true, + config: { + computerUse: { + enabled: true, + healthCheckEnabled: true, + healthCheckIntervalMinutes: 120, + autoRepair: true, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(warnings).toStrictEqual([ + [ + "- Codex Computer Use is enabled.", + "- Doctor config review found Computer Use enabled; run `/codex computer-use status` to inspect installation, exposure, and the live `list_apps` probe.", + "- Periodic Computer Use health checks are enabled with a 120-minute cadence.", + "- Stale Computer Use MCP child repair is enabled and limited to SkyComputerUseClient children.", + ].join("\n"), + ]); + }); + + it("surfaces opt-in defaults for Codex Computer Use health and repair", () => { + const warnings = collectCodexRouteWarnings({ + cfg: { + plugins: { + entries: { + codex: { + enabled: true, + config: { + computerUse: { + enabled: true, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(warnings).toStrictEqual([ + [ + "- Codex Computer Use is enabled.", + "- Doctor config review found Computer Use enabled; run `/codex computer-use status` to inspect installation, exposure, and the live `list_apps` probe.", + "- Periodic Computer Use health checks are disabled by default; set `computerUse.healthCheckEnabled` to true to enable them.", + "- Stale Computer Use MCP child repair is disabled by default; set `computerUse.autoRepair` to true to repair before retrying a failed probe.", + ].join("\n"), + ]); + }); + it("still warns when the native Codex runtime is selected with a legacy model ref", () => { const warnings = collectCodexRouteWarnings({ cfg: { diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 64b1afbcd726..4a199e267359 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -2717,6 +2717,49 @@ function collectCodexAppServerCommandWarnings(cfg: OpenClawConfig): string[] { ]; } +function collectCodexComputerUseWarnings(cfg: OpenClawConfig): string[] { + const plugins = asMutableRecord(cfg.plugins); + const entries = asMutableRecord(plugins?.entries); + const codex = asMutableRecord(entries?.codex); + const config = asMutableRecord(codex?.config); + const computerUse = asMutableRecord(config?.computerUse); + if (!computerUse) { + return []; + } + const enabled = + computerUse.enabled === true || + computerUse.autoInstall === true || + typeof computerUse.marketplaceSource === "string" || + typeof computerUse.marketplacePath === "string" || + typeof computerUse.marketplaceName === "string"; + if (!enabled) { + return []; + } + const cadence = + computerUse.healthCheckIntervalMinutes === 30 || + computerUse.healthCheckIntervalMinutes === 60 || + computerUse.healthCheckIntervalMinutes === 120 || + computerUse.healthCheckIntervalMinutes === 240 + ? computerUse.healthCheckIntervalMinutes + : 60; + const healthCheckLine = + computerUse.healthCheckEnabled === true + ? `- Periodic Computer Use health checks are enabled with a ${cadence}-minute cadence.` + : "- Periodic Computer Use health checks are disabled by default; set `computerUse.healthCheckEnabled` to true to enable them."; + const repairLine = + computerUse.autoRepair === true + ? "- Stale Computer Use MCP child repair is enabled and limited to SkyComputerUseClient children." + : "- Stale Computer Use MCP child repair is disabled by default; set `computerUse.autoRepair` to true to repair before retrying a failed probe."; + return [ + [ + "- Codex Computer Use is enabled.", + "- Doctor config review found Computer Use enabled; run `/codex computer-use status` to inspect installation, exposure, and the live `list_apps` probe.", + healthCheckLine, + repairLine, + ].join("\n"), + ]; +} + /** Collect doctor warnings for legacy Codex model refs, runtime pins, and compaction overrides. */ export function collectCodexRouteWarnings(params: { cfg: OpenClawConfig; @@ -2749,6 +2792,7 @@ export function collectCodexRouteWarnings(params: { }); const warnings: string[] = []; warnings.push(...collectCodexAppServerCommandWarnings(params.cfg)); + warnings.push(...collectCodexComputerUseWarnings(params.cfg)); if (hits.length > 0) { warnings.push( [