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:
clawsweeper[bot]
2026-07-11 09:42:27 -05:00
committed by GitHub
parent 72aa3f1bcf
commit 1b6393c51e
23 changed files with 2453 additions and 173 deletions
+1
View File
@@ -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
+25
View File
@@ -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` |
+67
View File
@@ -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,
+17 -2
View File
@@ -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;
}
+500 -40
View File
@@ -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: {
+92
View File
@@ -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");
}
+56
View File
@@ -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(
[