mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix (Computer Use) Stabilize Codex Computer Use readiness (#103331)
* fix (Computer Use) Stabilize Codex Computer Use readiness Co-authored-by: Ben Badejo <188106718+bdjben@users.noreply.github.com> * fix (Computer Use) Stabilize Codex Computer Use readiness * fix (Computer Use) Stabilize Codex Computer Use readiness * fix (Computer Use) Stabilize Codex Computer Use readiness * fix(clawsweeper): address review for automerge-openclaw-openclaw-103331 (1) * fix (Computer Use) Stabilize Codex Computer Use readiness --------- Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: Ben Badejo <188106718+bdjben@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CodexAppServerStartOptions> {
|
||||
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<CodexAppServerStartOptions> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<M extends CodexAppServerRequestMethod>(
|
||||
method: M,
|
||||
params: CodexAppServerRequestParams<M>,
|
||||
|
||||
@@ -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<void> {
|
||||
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> = {},
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<CodexComputerUsePluginCacheRepairResult> {
|
||||
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<string | undefined> {
|
||||
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<boolean> {
|
||||
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] : [],
|
||||
};
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<CodexComputerUseRepairStatus>;
|
||||
timer: ReturnType<typeof setInterval>;
|
||||
disposeCloseHandler: () => void;
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
type ComputerUseHealthMonitorState = {
|
||||
monitors: WeakMap<CodexAppServerClient, ComputerUseHealthMonitor>;
|
||||
};
|
||||
|
||||
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<CodexComputerUseRepairStatus>;
|
||||
}): { 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<CodexComputerUseRepairStatus>;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (monitor.running) {
|
||||
return;
|
||||
}
|
||||
monitor.running = true;
|
||||
try {
|
||||
const { liveTest, repair } = await runCodexComputerUseLiveTest({
|
||||
config,
|
||||
repairComputerUseMcpChildren: options.repairComputerUseMcpChildren,
|
||||
request: async <T>(
|
||||
method: string,
|
||||
requestParams?: unknown,
|
||||
requestOptions?: { timeoutMs?: number },
|
||||
) =>
|
||||
await client.request<T>(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,
|
||||
};
|
||||
@@ -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<string, unknown> {
|
||||
|
||||
function requestCalls(
|
||||
request: CodexComputerUseRequest,
|
||||
): ReadonlyArray<readonly [method: string, params?: unknown]> {
|
||||
return vi.mocked(request).mock.calls as ReadonlyArray<readonly [string, unknown?]>;
|
||||
): ReadonlyArray<readonly [method: string, params?: unknown, options?: { timeoutMs?: number }]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 = <T = JsonValue | undefined>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: { timeoutMs?: number },
|
||||
) => Promise<T>;
|
||||
|
||||
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<CodexComputerUseRepairStatus>;
|
||||
};
|
||||
|
||||
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<CodexComputerUseRepairStatus>;
|
||||
}): Promise<CodexComputerUseStatus> {
|
||||
const request = createComputerUseRequest(params);
|
||||
const repairComputerUseMcpChildren =
|
||||
params.repairComputerUseMcpChildren ??
|
||||
(params.client
|
||||
? () => killStaleComputerUseMcpChildren({ ancestorPid: params.client?.getTransportPid() })
|
||||
: undefined);
|
||||
if (params.installPlugin) {
|
||||
await request<JsonValue>("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<CodexComputerUseRepairStatus>;
|
||||
}): Promise<CodexComputerUseStatus> {
|
||||
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<CodexComputerUseRepairStatus>;
|
||||
}): 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<CodexThreadStartResponse>(
|
||||
"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<void> {
|
||||
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<MarketplaceResolution> {
|
||||
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<CodexComputerUseRepairStatus> {
|
||||
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<number>();
|
||||
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 <T = JsonValue | undefined>(method: string, requestParams?: unknown) =>
|
||||
return async <T = JsonValue | undefined>(
|
||||
method: string,
|
||||
requestParams?: unknown,
|
||||
options?: { timeoutMs?: number },
|
||||
) =>
|
||||
await params.client!.request<T>(method, requestParams, {
|
||||
timeoutMs: params.timeoutMs,
|
||||
timeoutMs: options?.timeoutMs ?? params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
return async <T = JsonValue | undefined>(method: string, requestParams?: unknown) =>
|
||||
return async <T = JsonValue | undefined>(
|
||||
method: string,
|
||||
requestParams?: unknown,
|
||||
options?: { timeoutMs?: number },
|
||||
) =>
|
||||
await requestCodexAppServerJson<T>({
|
||||
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<CodexComputerUseSetupParams, "pluginConfig" | "overrides" | "forceEnable">,
|
||||
): ResolvedCodexComputerUseConfig {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
|
||||
method: M;
|
||||
requestParams: CodexAppServerRequestParams<M>;
|
||||
timeoutMs?: number;
|
||||
pluginConfig?: unknown;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
@@ -68,6 +69,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
pluginConfig?: unknown;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
@@ -80,6 +82,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
timeoutMs?: number;
|
||||
pluginConfig?: unknown;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
@@ -105,6 +108,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient
|
||||
)({
|
||||
startOptions: params.startOptions,
|
||||
pluginConfig: params.pluginConfig,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string>();
|
||||
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"],
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user