Add Codex Computer Use setup for Codex mode (#71842)

* Add Codex Computer Use setup

* Tighten Codex Computer Use setup checks

* Handle fresh Codex Computer Use marketplace setup

* Fix channel setup manifest fixture

* Match Codex Computer Use marketplace loading

* Harden plugin manifest test fixtures

* Isolate auth choice legacy manifest test

* Update aggregate shard test expectation

* Improve Codex Computer Use first-run setup

* Harden Codex Computer Use auto-install

* Fix plugin auto-enable test fixture roots
This commit is contained in:
pash-openai
2026-04-26 13:21:56 -07:00
committed by GitHub
parent df542f75a9
commit 67ffa3df8b
18 changed files with 1691 additions and 37 deletions

View File

@@ -542,6 +542,72 @@ Environment overrides remain available for local testing:
preferred for repeatable deployments because it keeps the plugin behavior in the
same reviewed file as the rest of the Codex harness setup.
## Computer Use
Computer Use is a Codex-native MCP plugin. OpenClaw does not vendor the desktop
control app or execute desktop actions itself; it enables Codex app-server
plugins, installs the configured Codex marketplace plugin when requested, checks
that the `computer-use` MCP server is available, and then lets Codex handle the
native MCP tool calls during Codex-mode turns.
Set `plugins.entries.codex.config.computerUse` when you want Codex-mode turns to
require Computer Use:
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
computerUse: {
autoInstall: true,
},
},
},
},
},
agents: {
defaults: {
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
},
},
},
}
```
With no marketplace fields, OpenClaw asks Codex app-server to use its discovered
marketplaces. On a fresh Codex home, app-server seeds the official curated
marketplace and OpenClaw follows the same loading shape as Codex: it polls
`plugin/list` during install before treating Computer Use as unavailable. The
default discovery wait is 60 seconds and can be tuned with
`marketplaceDiscoveryTimeoutMs`. If multiple known Codex marketplaces contain
Computer Use, OpenClaw uses the Codex marketplace preference order before
failing closed for unknown ambiguous matches.
Use `marketplaceSource` for a non-default Codex marketplace source that
app-server can add, or `marketplacePath` for a local marketplace file that
already exists on the machine. If the marketplace is already registered with
Codex app-server, use `marketplaceName` instead. The defaults are
`pluginName: "computer-use"` and `mcpServerName: "computer-use"`.
For safety, turn-start auto-install only uses marketplaces app-server has
already discovered. Use `/codex computer-use install` for explicit installs from
a configured `marketplaceSource` or `marketplacePath`.
The same setup can be checked or installed from the command surface:
- `/codex computer-use status`
- `/codex computer-use install`
- `/codex computer-use install --source <marketplace-source>`
- `/codex computer-use install --marketplace-path <path>`
Computer Use is macOS-specific and may require local OS permissions before the
Codex MCP server can control apps. If `computerUse.enabled` is true and the MCP
server is unavailable, Codex-mode turns fail before the thread starts instead of
silently running without the native Computer Use tools.
## Common recipes
Local Codex with default stdio transport:
@@ -644,6 +710,8 @@ Common forms:
- `/codex resume <thread-id>` attaches the current OpenClaw session to an existing Codex thread.
- `/codex compact` asks Codex app-server to compact the attached thread.
- `/codex review` starts Codex native review for the attached thread.
- `/codex computer-use status` checks the configured Computer Use plugin and MCP server.
- `/codex computer-use install` installs the configured Computer Use plugin and reloads MCP servers.
- `/codex account` shows account and rate-limit status.
- `/codex mcp` lists Codex app-server MCP server status.
- `/codex skills` lists Codex app-server skills.

View File

@@ -43,6 +43,42 @@
}
}
},
"computerUse": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"autoInstall": {
"type": "boolean",
"default": false
},
"marketplaceDiscoveryTimeoutMs": {
"type": "number",
"minimum": 1,
"default": 60000
},
"marketplaceSource": {
"type": "string"
},
"marketplacePath": {
"type": "string"
},
"marketplaceName": {
"type": "string"
},
"pluginName": {
"type": "string",
"default": "computer-use"
},
"mcpServerName": {
"type": "string",
"default": "computer-use"
}
}
},
"appServer": {
"type": "object",
"additionalProperties": false,
@@ -112,6 +148,51 @@
"help": "Maximum time to wait for Codex app-server model discovery before falling back to the bundled model list.",
"advanced": true
},
"computerUse": {
"label": "Computer Use",
"help": "Controls Codex app-server setup for the Computer Use plugin.",
"advanced": true
},
"computerUse.enabled": {
"label": "Enable Computer Use",
"help": "When true, Codex-mode turns require the configured Computer Use MCP server to be available.",
"advanced": true
},
"computerUse.autoInstall": {
"label": "Auto Install",
"help": "Install the configured Computer Use plugin when Codex-mode turns start.",
"advanced": true
},
"computerUse.marketplaceDiscoveryTimeoutMs": {
"label": "Marketplace Discovery Timeout",
"help": "Maximum time to wait for Codex app-server to finish loading marketplaces during Computer Use install.",
"advanced": true
},
"computerUse.marketplaceSource": {
"label": "Marketplace Source",
"help": "Optional Codex marketplace source to add before installing Computer Use.",
"advanced": true
},
"computerUse.marketplacePath": {
"label": "Marketplace Path",
"help": "Optional local Codex marketplace file path containing the Computer Use plugin.",
"advanced": true
},
"computerUse.marketplaceName": {
"label": "Marketplace Name",
"help": "Optional registered Codex marketplace name containing the Computer Use plugin.",
"advanced": true
},
"computerUse.pluginName": {
"label": "Plugin Name",
"help": "Codex marketplace plugin name for Computer Use.",
"advanced": true
},
"computerUse.mcpServerName": {
"label": "MCP Server Name",
"help": "MCP server name exposed by the Computer Use plugin.",
"advanced": true
},
"appServer": {
"label": "App Server",
"help": "Runtime controls for connecting to Codex app-server.",

View File

@@ -0,0 +1,502 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CodexComputerUseSetupError,
ensureCodexComputerUse,
installCodexComputerUse,
readCodexComputerUseStatus,
type CodexComputerUseRequest,
} from "./computer-use.js";
describe("Codex Computer Use setup", () => {
afterEach(() => {
vi.useRealTimers();
});
it("stays disabled until configured", async () => {
await expect(
readCodexComputerUseStatus({ pluginConfig: {}, request: vi.fn() }),
).resolves.toEqual(
expect.objectContaining({
enabled: false,
ready: false,
message: "Computer Use is disabled.",
}),
);
});
it("reports an installed Computer Use MCP server from a registered marketplace", async () => {
const request = createComputerUseRequest({ installed: true });
await expect(
readCodexComputerUseStatus({
pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } },
request,
}),
).resolves.toEqual(
expect.objectContaining({
enabled: true,
ready: true,
installed: true,
pluginEnabled: true,
mcpServerAvailable: true,
marketplaceName: "desktop-tools",
tools: ["list_apps"],
message: "Computer Use is ready.",
}),
);
expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything());
expect(request).not.toHaveBeenCalledWith(
"experimentalFeature/enablement/set",
expect.anything(),
);
expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything());
});
it("does not register marketplace sources during status checks", async () => {
const request = createComputerUseRequest({ installed: true });
await expect(
readCodexComputerUseStatus({
pluginConfig: {
computerUse: {
enabled: true,
marketplaceSource: "github:example/desktop-tools",
},
},
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: true,
message: "Computer Use is ready.",
}),
);
expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything());
expect(request).not.toHaveBeenCalledWith(
"experimentalFeature/enablement/set",
expect.anything(),
);
});
it("fails closed when multiple marketplaces contain Computer Use", async () => {
const request = createAmbiguousComputerUseRequest();
await expect(
readCodexComputerUseStatus({
pluginConfig: { computerUse: { enabled: true } },
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: false,
message:
"Multiple Codex marketplaces contain computer-use. Configure computerUse.marketplaceName or computerUse.marketplacePath to choose one.",
}),
);
expect(request).not.toHaveBeenCalledWith("plugin/read", expect.anything());
});
it("installs Computer Use from a configured marketplace source", async () => {
const request = createComputerUseRequest({ installed: false });
await expect(
installCodexComputerUse({
pluginConfig: {
computerUse: {
marketplaceSource: "github:example/desktop-tools",
},
},
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: true,
installed: true,
pluginEnabled: true,
tools: ["list_apps"],
}),
);
expect(request).toHaveBeenCalledWith("experimentalFeature/enablement/set", {
enablement: { plugins: true },
});
expect(request).toHaveBeenCalledWith("marketplace/add", {
source: "github:example/desktop-tools",
});
expect(request).toHaveBeenCalledWith("plugin/install", {
marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
pluginName: "computer-use",
});
expect(request).toHaveBeenCalledWith("config/mcpServer/reload", undefined);
});
it("fails closed when Computer Use is required but not installed", async () => {
const request = createComputerUseRequest({ installed: false });
await expect(
ensureCodexComputerUse({
pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } },
request,
}),
).rejects.toThrow(CodexComputerUseSetupError);
expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything());
});
it("skips setup writes when auto-install is already ready", async () => {
const request = createComputerUseRequest({ installed: true });
await expect(
ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
marketplaceName: "desktop-tools",
},
},
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: true,
message: "Computer Use is ready.",
}),
);
expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything());
expect(request).not.toHaveBeenCalledWith(
"experimentalFeature/enablement/set",
expect.anything(),
);
expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything());
});
it("uses setup writes when auto-install needs to install", async () => {
const request = createComputerUseRequest({ installed: false });
await expect(
ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
},
},
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: true,
message: "Computer Use is ready.",
}),
);
expect(request).toHaveBeenCalledWith("experimentalFeature/enablement/set", {
enablement: { plugins: true },
});
expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything());
expect(request).toHaveBeenCalledWith("plugin/install", {
marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
pluginName: "computer-use",
});
});
it("requires an explicit install command for configured marketplace sources", async () => {
const request = createComputerUseRequest({ installed: false });
await expect(
ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
marketplaceSource: "github:example/desktop-tools",
},
},
request,
}),
).rejects.toThrow(CodexComputerUseSetupError);
expect(request).not.toHaveBeenCalledWith("marketplace/add", expect.anything());
expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything());
});
it("fails closed when a configured marketplace name is not discovered", async () => {
const request = createEmptyMarketplaceComputerUseRequest();
await expect(
readCodexComputerUseStatus({
pluginConfig: {
computerUse: {
enabled: true,
marketplaceName: "missing-marketplace",
},
},
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: false,
message:
"Configured Codex marketplace missing-marketplace was not found or does not contain computer-use. Run /codex computer-use install with a source or path to install from a new marketplace.",
}),
);
expect(request).not.toHaveBeenCalledWith("plugin/read", expect.anything());
});
it("waits for the default Codex marketplace during install", async () => {
vi.useFakeTimers();
const request = createComputerUseRequest({
installed: false,
marketplaceAvailableAfterListCalls: 3,
});
const installed = installCodexComputerUse({
pluginConfig: { computerUse: {} },
request,
});
await vi.advanceTimersByTimeAsync(4_000);
await expect(installed).resolves.toEqual(
expect.objectContaining({
ready: true,
message: "Computer Use is ready.",
}),
);
expect(request).toHaveBeenCalledWith("plugin/install", {
marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
pluginName: "computer-use",
});
expect(
vi.mocked(request).mock.calls.filter(([method]) => method === "plugin/list"),
).toHaveLength(3);
});
it("prefers the official Computer Use marketplace when multiple matches are present", async () => {
const request = createMultiMarketplaceComputerUseRequest();
await expect(
installCodexComputerUse({
pluginConfig: { computerUse: {} },
request,
}),
).resolves.toEqual(
expect.objectContaining({
ready: true,
marketplaceName: "openai-curated",
}),
);
expect(request).toHaveBeenCalledWith("plugin/install", {
marketplacePath: "/marketplaces/openai-curated/.agents/plugins/marketplace.json",
pluginName: "computer-use",
});
});
});
function createComputerUseRequest(params: {
installed: boolean;
marketplaceAvailableAfterListCalls?: number;
}): CodexComputerUseRequest {
let installed = params.installed;
let pluginListCalls = 0;
return vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
}
if (method === "marketplace/add") {
return {
marketplaceName: "desktop-tools",
installedRoot: "/marketplaces/desktop-tools",
alreadyAdded: false,
};
}
if (method === "plugin/list") {
pluginListCalls += 1;
const marketplaceAvailable =
pluginListCalls >= (params.marketplaceAvailableAfterListCalls ?? 1);
return {
marketplaces: marketplaceAvailable
? [
{
name: "desktop-tools",
path: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
interface: null,
plugins: [pluginSummary(installed)],
},
]
: [],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
expect(requestParams).toEqual(
expect.objectContaining({
pluginName: "computer-use",
}),
);
return {
plugin: {
marketplaceName: "desktop-tools",
marketplacePath: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
summary: pluginSummary(installed),
description: "Control desktop apps.",
skills: [],
apps: [],
mcpServers: ["computer-use"],
},
};
}
if (method === "plugin/install") {
installed = true;
return { authPolicy: "ON_INSTALL", appsNeedingAuth: [] };
}
if (method === "config/mcpServer/reload") {
return undefined;
}
if (method === "mcpServerStatus/list") {
return {
data: installed
? [
{
name: "computer-use",
tools: {
list_apps: {
name: "list_apps",
inputSchema: { type: "object" },
},
},
resources: [],
resourceTemplates: [],
authStatus: "unsupported",
},
]
: [],
nextCursor: null,
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}
function createAmbiguousComputerUseRequest(): CodexComputerUseRequest {
return vi.fn(async (method: string) => {
if (method === "plugin/list") {
return {
marketplaces: [
{
name: "desktop-tools",
path: "/marketplaces/desktop-tools/.agents/plugins/marketplace.json",
interface: null,
plugins: [pluginSummary(true, "desktop-tools")],
},
{
name: "other-tools",
path: "/marketplaces/other-tools/.agents/plugins/marketplace.json",
interface: null,
plugins: [pluginSummary(true, "other-tools")],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}
function createEmptyMarketplaceComputerUseRequest(): CodexComputerUseRequest {
return vi.fn(async (method: string) => {
if (method === "plugin/list") {
return {
marketplaces: [],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}
function createMultiMarketplaceComputerUseRequest(): CodexComputerUseRequest {
let installed = false;
return vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
}
if (method === "plugin/list") {
return {
marketplaces: [
marketplaceEntry("workspace-tools", false),
marketplaceEntry("openai-curated", installed),
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
return {
plugin: {
marketplaceName: "openai-curated",
marketplacePath: "/marketplaces/openai-curated/.agents/plugins/marketplace.json",
summary: pluginSummary(installed, "openai-curated"),
description: "Control desktop apps.",
skills: [],
apps: [],
mcpServers: ["computer-use"],
},
};
}
if (method === "plugin/install") {
expect(requestParams).toEqual({
marketplacePath: "/marketplaces/openai-curated/.agents/plugins/marketplace.json",
pluginName: "computer-use",
});
installed = true;
return { authPolicy: "ON_INSTALL", appsNeedingAuth: [] };
}
if (method === "config/mcpServer/reload") {
return undefined;
}
if (method === "mcpServerStatus/list") {
return {
data: installed
? [
{
name: "computer-use",
tools: {
list_apps: {
name: "list_apps",
inputSchema: { type: "object" },
},
},
resources: [],
resourceTemplates: [],
authStatus: "unsupported",
},
]
: [],
nextCursor: null,
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}
function marketplaceEntry(marketplaceName: string, installed: boolean) {
return {
name: marketplaceName,
path: `/marketplaces/${marketplaceName}/.agents/plugins/marketplace.json`,
interface: null,
plugins: [pluginSummary(installed, marketplaceName)],
};
}
function pluginSummary(installed: boolean, marketplaceName = "desktop-tools") {
return {
id: `computer-use@${marketplaceName}`,
name: "computer-use",
source: { type: "local", path: `/marketplaces/${marketplaceName}/plugins/computer-use` },
installed,
enabled: installed,
installPolicy: "AVAILABLE",
authPolicy: "ON_INSTALL",
interface: null,
};
}

View File

@@ -0,0 +1,511 @@
import { describeControlFailure } from "./capabilities.js";
import type { CodexAppServerClient } from "./client.js";
import {
resolveCodexAppServerRuntimeOptions,
resolveCodexComputerUseConfig,
type CodexComputerUseConfig,
type ResolvedCodexComputerUseConfig,
} from "./config.js";
import type { JsonValue } from "./protocol.js";
import { requestCodexAppServerJson } from "./request.js";
import type { v2 } from "./protocol-generated/typescript/index.js";
export type CodexComputerUseRequest = <T = JsonValue | undefined>(
method: string,
params?: unknown,
) => Promise<T>;
export type CodexComputerUseStatus = {
enabled: boolean;
ready: boolean;
installed: boolean;
pluginEnabled: boolean;
mcpServerAvailable: boolean;
pluginName: string;
mcpServerName: string;
marketplaceName?: string;
marketplacePath?: string;
tools: string[];
message: string;
};
export class CodexComputerUseSetupError extends Error {
readonly status: CodexComputerUseStatus;
constructor(status: CodexComputerUseStatus) {
super(status.message);
this.name = "CodexComputerUseSetupError";
this.status = status;
}
}
export type CodexComputerUseSetupParams = {
pluginConfig?: unknown;
overrides?: Partial<CodexComputerUseConfig>;
request?: CodexComputerUseRequest;
client?: CodexAppServerClient;
timeoutMs?: number;
signal?: AbortSignal;
forceEnable?: boolean;
};
type MarketplaceRef = {
name?: string;
path?: string;
remoteMarketplaceName?: string;
};
type MarketplaceResolution = {
marketplace?: MarketplaceRef;
message?: string;
};
const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000;
const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"];
export async function readCodexComputerUseStatus(
params: CodexComputerUseSetupParams = {},
): Promise<CodexComputerUseStatus> {
const config = resolveComputerUseConfig(params);
if (!config.enabled) {
return disabledStatus(config);
}
try {
return await inspectCodexComputerUse({
...params,
config,
installPlugin: false,
});
} catch (error) {
return unavailableStatus(config, `Computer Use check failed: ${describeControlFailure(error)}`);
}
}
export async function ensureCodexComputerUse(
params: CodexComputerUseSetupParams = {},
): Promise<CodexComputerUseStatus> {
const config = resolveComputerUseConfig(params);
if (!config.enabled) {
return disabledStatus(config);
}
const status = await inspectCodexComputerUse({
...params,
config,
installPlugin: false,
});
if (status.ready) {
return status;
}
if (config.autoInstall) {
const blockedAutoInstallStatus = blockUnsafeAutoInstallStatus(config);
if (blockedAutoInstallStatus) {
throw new CodexComputerUseSetupError(blockedAutoInstallStatus);
}
const installedStatus = await inspectCodexComputerUse({
...params,
config,
installPlugin: true,
});
if (!installedStatus.ready) {
throw new CodexComputerUseSetupError(installedStatus);
}
return installedStatus;
}
if (!status.ready) {
throw new CodexComputerUseSetupError(status);
}
return status;
}
export async function installCodexComputerUse(
params: CodexComputerUseSetupParams = {},
): Promise<CodexComputerUseStatus> {
const config = resolveComputerUseConfig({
...params,
forceEnable: true,
overrides: { ...params.overrides, enabled: true, autoInstall: true },
});
const status = await inspectCodexComputerUse({
...params,
config,
installPlugin: true,
});
if (!status.ready) {
throw new CodexComputerUseSetupError(status);
}
return status;
}
async function inspectCodexComputerUse(params: {
pluginConfig?: unknown;
request?: CodexComputerUseRequest;
client?: CodexAppServerClient;
timeoutMs?: number;
signal?: AbortSignal;
config: ResolvedCodexComputerUseConfig;
installPlugin: boolean;
}): Promise<CodexComputerUseStatus> {
const request = createComputerUseRequest(params);
if (params.installPlugin) {
await request<v2.ExperimentalFeatureEnablementSetResponse>(
"experimentalFeature/enablement/set",
{
enablement: { plugins: true },
} satisfies v2.ExperimentalFeatureEnablementSetParams,
);
}
const marketplace = await resolveMarketplaceRef({
request,
config: params.config,
allowAdd: params.installPlugin,
signal: params.signal,
});
if (!marketplace.marketplace) {
return unavailableStatus(
params.config,
marketplace.message ??
`No Codex marketplace containing ${params.config.pluginName} is registered. Configure computerUse.marketplaceSource or computerUse.marketplacePath, then run /codex computer-use install.`,
);
}
let plugin = await readComputerUsePlugin(
request,
marketplace.marketplace,
params.config.pluginName,
);
if (!plugin.summary.installed || !plugin.summary.enabled) {
if (!params.installPlugin) {
return statusFromPlugin({
config: params.config,
plugin,
tools: [],
message: `Computer Use is available but not installed. Run /codex computer-use install or enable computerUse.autoInstall.`,
});
}
await request<v2.PluginInstallResponse>(
"plugin/install",
pluginRequestParams(
marketplace.marketplace,
params.config.pluginName,
) satisfies v2.PluginInstallParams,
);
await reloadMcpServers(request);
plugin = await readComputerUsePlugin(
request,
marketplace.marketplace,
params.config.pluginName,
);
}
let server = await readMcpServerStatus(request, params.config.mcpServerName);
if (!server && params.installPlugin) {
await reloadMcpServers(request);
server = await readMcpServerStatus(request, params.config.mcpServerName);
}
if (!server) {
return statusFromPlugin({
config: params.config,
plugin,
tools: [],
message: `Computer Use is installed, but the ${params.config.mcpServerName} MCP server is not available.`,
});
}
return statusFromPlugin({
config: params.config,
plugin,
tools: Object.keys(server.tools).toSorted(),
message: "Computer Use is ready.",
});
}
async function resolveMarketplaceRef(params: {
request: CodexComputerUseRequest;
config: ResolvedCodexComputerUseConfig;
allowAdd: boolean;
signal?: AbortSignal;
}): Promise<MarketplaceResolution> {
let preferredMarketplaceName = params.config.marketplaceName;
if (params.config.marketplaceSource && params.allowAdd) {
const added = await params.request<v2.MarketplaceAddResponse>("marketplace/add", {
source: params.config.marketplaceSource,
} satisfies v2.MarketplaceAddParams);
preferredMarketplaceName ??= added.marketplaceName;
}
if (params.config.marketplacePath) {
const marketplace: MarketplaceRef = preferredMarketplaceName
? { name: preferredMarketplaceName, path: params.config.marketplacePath }
: { path: params.config.marketplacePath };
return { marketplace };
}
let candidates: MarketplaceRef[] = [];
const waitUntil = marketplaceDiscoveryWaitUntil(params);
while (candidates.length === 0) {
const listed = await params.request<v2.PluginListResponse>("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams);
candidates = findComputerUseMarketplaces(listed, params.config.pluginName);
if (candidates.length > 0) {
break;
}
if (Date.now() >= waitUntil) {
break;
}
await delay(
Math.min(CURATED_MARKETPLACE_POLL_INTERVAL_MS, waitUntil - Date.now()),
params.signal,
);
}
if (preferredMarketplaceName) {
const preferred = candidates.find((candidate) => candidate.name === preferredMarketplaceName);
if (preferred) {
return { marketplace: preferred };
}
return {
message: `Configured Codex marketplace ${preferredMarketplaceName} was not found or does not contain ${params.config.pluginName}. Run /codex computer-use install with a source or path to install from a new marketplace.`,
};
}
if (candidates.length > 1) {
const preferred = chooseKnownComputerUseMarketplace(candidates);
if (preferred) {
return { marketplace: preferred };
}
return {
message: `Multiple Codex marketplaces contain ${params.config.pluginName}. Configure computerUse.marketplaceName or computerUse.marketplacePath to choose one.`,
};
}
if (params.config.marketplaceSource && !params.allowAdd && candidates.length === 0) {
return {
message:
"Computer Use marketplace source is configured but has not been registered. Run /codex computer-use install to register it.",
};
}
const marketplace = candidates[0];
return marketplace ? { marketplace } : {};
}
function blockUnsafeAutoInstallStatus(
config: ResolvedCodexComputerUseConfig,
): CodexComputerUseStatus | undefined {
if (!config.marketplaceSource && !config.marketplacePath) {
return undefined;
}
return unavailableStatus(
config,
"Computer Use auto-install only uses marketplaces Codex app-server has already discovered. Run /codex computer-use install to install from a configured marketplace source or path.",
);
}
function findComputerUseMarketplaces(
listed: v2.PluginListResponse,
pluginName: string,
): MarketplaceRef[] {
return listed.marketplaces
.filter((marketplace) =>
marketplace.plugins.some(
(plugin) =>
plugin.name === pluginName ||
plugin.id === pluginName ||
plugin.id === `${pluginName}@${marketplace.name}`,
),
)
.map((marketplace) => {
if (marketplace.path) {
return { name: marketplace.name, path: marketplace.path };
}
return { name: marketplace.name, remoteMarketplaceName: marketplace.name };
});
}
function chooseKnownComputerUseMarketplace(
candidates: MarketplaceRef[],
): MarketplaceRef | undefined {
for (const marketplaceName of COMPUTER_USE_MARKETPLACE_NAME_PRIORITY) {
const candidate = candidates.find((marketplace) => marketplace.name === marketplaceName);
if (candidate) {
return candidate;
}
}
return undefined;
}
function marketplaceDiscoveryWaitUntil(params: {
config: ResolvedCodexComputerUseConfig;
allowAdd: boolean;
}): number {
if (
params.allowAdd &&
!params.config.marketplaceSource &&
!params.config.marketplacePath &&
!params.config.marketplaceName
) {
return Date.now() + params.config.marketplaceDiscoveryTimeoutMs;
}
return 0;
}
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
throw abortError(signal);
}
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout>;
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(abortError(signal));
};
timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function abortError(signal?: AbortSignal): Error {
const reason = signal?.reason;
return reason instanceof Error ? reason : new Error("Computer Use setup was aborted.");
}
async function readComputerUsePlugin(
request: CodexComputerUseRequest,
marketplace: MarketplaceRef,
pluginName: string,
): Promise<v2.PluginDetail> {
const response = await request<v2.PluginReadResponse>(
"plugin/read",
pluginRequestParams(marketplace, pluginName) satisfies v2.PluginReadParams,
);
return response.plugin;
}
async function readMcpServerStatus(
request: CodexComputerUseRequest,
serverName: string,
): Promise<v2.McpServerStatus | undefined> {
let cursor: string | null | undefined;
do {
const response = await request<v2.ListMcpServerStatusResponse>("mcpServerStatus/list", {
cursor,
limit: 100,
detail: "toolsAndAuthOnly",
} satisfies v2.ListMcpServerStatusParams);
const found = response.data.find((server) => server.name === serverName);
if (found) {
return found;
}
cursor = response.nextCursor;
} while (cursor);
return undefined;
}
async function reloadMcpServers(request: CodexComputerUseRequest): Promise<void> {
await request("config/mcpServer/reload", undefined);
}
function pluginRequestParams(marketplace: MarketplaceRef, pluginName: string) {
return {
...(marketplace.path ? { marketplacePath: marketplace.path } : {}),
...(!marketplace.path && marketplace.remoteMarketplaceName
? { remoteMarketplaceName: marketplace.remoteMarketplaceName }
: {}),
pluginName,
};
}
function statusFromPlugin(params: {
config: ResolvedCodexComputerUseConfig;
plugin: v2.PluginDetail;
tools: string[];
message: string;
}): CodexComputerUseStatus {
return {
enabled: true,
ready:
params.plugin.summary.installed && params.plugin.summary.enabled && params.tools.length > 0,
installed: params.plugin.summary.installed,
pluginEnabled: params.plugin.summary.enabled,
mcpServerAvailable: params.tools.length > 0,
pluginName: params.config.pluginName,
mcpServerName: params.config.mcpServerName,
marketplaceName: params.plugin.marketplaceName,
...(params.plugin.marketplacePath ? { marketplacePath: params.plugin.marketplacePath } : {}),
tools: params.tools,
message: params.message,
};
}
function disabledStatus(config: ResolvedCodexComputerUseConfig): CodexComputerUseStatus {
return {
enabled: false,
ready: false,
installed: false,
pluginEnabled: false,
mcpServerAvailable: false,
pluginName: config.pluginName,
mcpServerName: config.mcpServerName,
tools: [],
message: "Computer Use is disabled.",
};
}
function unavailableStatus(
config: ResolvedCodexComputerUseConfig,
message: string,
): CodexComputerUseStatus {
return {
enabled: true,
ready: false,
installed: false,
pluginEnabled: false,
mcpServerAvailable: false,
pluginName: config.pluginName,
mcpServerName: config.mcpServerName,
...(config.marketplaceName ? { marketplaceName: config.marketplaceName } : {}),
...(config.marketplacePath ? { marketplacePath: config.marketplacePath } : {}),
tools: [],
message,
};
}
function createComputerUseRequest(params: {
pluginConfig?: unknown;
request?: CodexComputerUseRequest;
client?: CodexAppServerClient;
timeoutMs?: number;
signal?: AbortSignal;
}): CodexComputerUseRequest {
if (params.request) {
return params.request;
}
if (params.client) {
return async <T = JsonValue | undefined>(method: string, requestParams?: unknown) =>
await params.client!.request<T>(method, requestParams, {
timeoutMs: params.timeoutMs,
signal: params.signal,
});
}
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
return async <T = JsonValue | undefined>(method: string, requestParams?: unknown) =>
await requestCodexAppServerJson<T>({
method,
requestParams,
timeoutMs: params.timeoutMs ?? runtime.requestTimeoutMs,
startOptions: runtime.start,
});
}
function resolveComputerUseConfig(
params: Pick<CodexComputerUseSetupParams, "pluginConfig" | "overrides" | "forceEnable">,
): ResolvedCodexComputerUseConfig {
const overrides = params.forceEnable ? { ...params.overrides, enabled: true } : params.overrides;
return resolveCodexComputerUseConfig({
pluginConfig: params.pluginConfig,
overrides,
});
}

View File

@@ -2,9 +2,11 @@ import fs from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
CODEX_APP_SERVER_CONFIG_KEYS,
CODEX_COMPUTER_USE_CONFIG_KEYS,
codexAppServerStartOptionsKey,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
resolveCodexComputerUseConfig,
} from "./config.js";
describe("Codex app-server config", () => {
@@ -130,6 +132,48 @@ describe("Codex app-server config", () => {
);
});
it("resolves Computer Use setup from plugin config and environment fallbacks", () => {
expect(
resolveCodexComputerUseConfig({
pluginConfig: {
computerUse: {
autoInstall: true,
marketplaceName: "desktop-tools",
},
},
env: {
OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_NAME: "env-fallback-plugin",
},
}),
).toEqual({
enabled: true,
autoInstall: true,
marketplaceDiscoveryTimeoutMs: 60_000,
pluginName: "env-fallback-plugin",
mcpServerName: "computer-use",
marketplaceName: "desktop-tools",
});
expect(
resolveCodexComputerUseConfig({
pluginConfig: {},
env: {
OPENCLAW_CODEX_COMPUTER_USE: "1",
OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_SOURCE: "github:example/plugins",
OPENCLAW_CODEX_COMPUTER_USE_AUTO_INSTALL: "true",
OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS: "30000",
},
}),
).toEqual(
expect.objectContaining({
enabled: true,
autoInstall: true,
marketplaceDiscoveryTimeoutMs: 30_000,
marketplaceSource: "github:example/plugins",
}),
);
});
it("allows plugin config to opt in to guardian-reviewed local execution", () => {
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
@@ -246,6 +290,7 @@ describe("Codex app-server config", () => {
configSchema: {
properties: {
appServer: { properties: Record<string, unknown> };
computerUse: { properties: Record<string, unknown> };
};
};
uiHints: Record<string, unknown>;
@@ -258,6 +303,13 @@ describe("Codex app-server config", () => {
for (const key of CODEX_APP_SERVER_CONFIG_KEYS) {
expect(manifest.uiHints[`appServer.${key}`]).toBeTruthy();
}
const computerUseManifestKeys = Object.keys(
manifest.configSchema.properties.computerUse.properties,
).toSorted();
expect(computerUseManifestKeys).toEqual([...CODEX_COMPUTER_USE_CONFIG_KEYS].toSorted());
for (const key of CODEX_COMPUTER_USE_CONFIG_KEYS) {
expect(manifest.uiHints[`computerUse.${key}`]).toBeTruthy();
}
});
it("does not schema-default mode-derived policy fields", async () => {

View File

@@ -9,6 +9,28 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexComputerUseConfig = {
enabled?: boolean;
autoInstall?: boolean;
marketplaceDiscoveryTimeoutMs?: number;
marketplaceSource?: string;
marketplacePath?: string;
marketplaceName?: string;
pluginName?: string;
mcpServerName?: string;
};
export type ResolvedCodexComputerUseConfig = {
enabled: boolean;
autoInstall: boolean;
marketplaceDiscoveryTimeoutMs: number;
pluginName: string;
mcpServerName: string;
marketplaceSource?: string;
marketplacePath?: string;
marketplaceName?: string;
};
export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
@@ -35,6 +57,7 @@ export type CodexPluginConfig = {
enabled?: boolean;
timeoutMs?: number;
};
computerUse?: CodexComputerUseConfig;
appServer?: {
mode?: CodexAppServerPolicyMode;
transport?: CodexAppServerTransportMode;
@@ -68,6 +91,21 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
"defaultWorkspaceDir",
] as const;
export const CODEX_COMPUTER_USE_CONFIG_KEYS = [
"enabled",
"autoInstall",
"marketplaceDiscoveryTimeoutMs",
"marketplaceSource",
"marketplacePath",
"marketplaceName",
"pluginName",
"mcpServerName",
] as const;
export const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
export const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
export const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
const codexAppServerApprovalPolicySchema = z.enum([
@@ -92,6 +130,19 @@ const codexPluginConfigSchema = z
})
.strict()
.optional(),
computerUse: z
.object({
enabled: z.boolean().optional(),
autoInstall: z.boolean().optional(),
marketplaceDiscoveryTimeoutMs: z.number().positive().optional(),
marketplaceSource: z.string().optional(),
marketplacePath: z.string().optional(),
marketplaceName: z.string().optional(),
pluginName: z.string().optional(),
mcpServerName: z.string().optional(),
})
.strict()
.optional(),
appServer: z
.object({
mode: codexAppServerPolicyModeSchema.optional(),
@@ -176,6 +227,64 @@ export function resolveCodexAppServerRuntimeOptions(
};
}
export function resolveCodexComputerUseConfig(
params: {
pluginConfig?: unknown;
env?: NodeJS.ProcessEnv;
overrides?: Partial<CodexComputerUseConfig>;
} = {},
): ResolvedCodexComputerUseConfig {
const env = params.env ?? process.env;
const config = readCodexPluginConfig(params.pluginConfig).computerUse ?? {};
const marketplaceSource =
readNonEmptyString(params.overrides?.marketplaceSource) ??
readNonEmptyString(config.marketplaceSource) ??
readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_SOURCE);
const marketplacePath =
readNonEmptyString(params.overrides?.marketplacePath) ??
readNonEmptyString(config.marketplacePath) ??
readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_PATH);
const marketplaceName =
readNonEmptyString(params.overrides?.marketplaceName) ??
readNonEmptyString(config.marketplaceName) ??
readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_NAME);
const autoInstall =
params.overrides?.autoInstall ??
config.autoInstall ??
readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE_AUTO_INSTALL) ??
false;
const marketplaceDiscoveryTimeoutMs = normalizePositiveNumber(
params.overrides?.marketplaceDiscoveryTimeoutMs ??
config.marketplaceDiscoveryTimeoutMs ??
readNumberEnv(env.OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS),
DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS,
);
const enabled =
params.overrides?.enabled ??
config.enabled ??
readBooleanEnv(env.OPENCLAW_CODEX_COMPUTER_USE) ??
Boolean(autoInstall || marketplaceSource || marketplacePath || marketplaceName);
return {
enabled,
autoInstall,
marketplaceDiscoveryTimeoutMs,
pluginName:
readNonEmptyString(params.overrides?.pluginName) ??
readNonEmptyString(config.pluginName) ??
readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_PLUGIN_NAME) ??
DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME,
mcpServerName:
readNonEmptyString(params.overrides?.mcpServerName) ??
readNonEmptyString(config.mcpServerName) ??
readNonEmptyString(env.OPENCLAW_CODEX_COMPUTER_USE_MCP_SERVER_NAME) ??
DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME,
...(marketplaceSource ? { marketplaceSource } : {}),
...(marketplacePath ? { marketplacePath } : {}),
...(marketplaceName ? { marketplaceName } : {}),
};
}
export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: { authProfileId?: string } = {},
@@ -264,6 +373,28 @@ function normalizeHeaders(value: unknown): Record<string, string> {
);
}
function readBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
function readNumberEnv(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function resolveArgs(configArgs: unknown, envArgs: string | undefined): string[] {
if (Array.isArray(configArgs)) {
return configArgs

View File

@@ -41,6 +41,7 @@ import {
defaultCodexAppServerClientFactory,
} from "./client-factory.js";
import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
@@ -311,6 +312,12 @@ export async function runCodexAppServerAttempt(
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory(appServer.start, startupAuthProfileId);
await ensureCodexComputerUse({
client: startupClient,
pluginConfig: options.pluginConfig,
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
const startupThread = await startOrResumeThread({
client: startupClient,
params,

View File

@@ -1,4 +1,5 @@
import type { CodexAppServerModelListResult } from "./app-server/models.js";
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import type { SafeValue } from "./command-rpc.js";
@@ -89,6 +90,28 @@ export function formatAccount(
].join("\n");
}
export function formatComputerUseStatus(status: CodexComputerUseStatus): string {
const lines = [
`Computer Use: ${status.ready ? "ready" : status.enabled ? "not ready" : "disabled"}`,
];
lines.push(
`Plugin: ${status.pluginName}${status.installed ? " (installed)" : " (not installed)"}`,
);
lines.push(
`MCP server: ${status.mcpServerName}${
status.mcpServerAvailable ? ` (${status.tools.length} tools)` : " (unavailable)"
}`,
);
if (status.marketplaceName) {
lines.push(`Marketplace: ${status.marketplaceName}`);
}
if (status.tools.length > 0) {
lines.push(`Tools: ${status.tools.slice(0, 8).join(", ")}`);
}
lines.push(status.message);
return lines.join("\n");
}
export function formatList(response: JsonValue | undefined, label: string): string {
const entries = extractArray(response);
if (entries.length === 0) {
@@ -120,6 +143,7 @@ export function buildHelp(): string {
"- /codex detach",
"- /codex compact",
"- /codex review",
"- /codex computer-use [status|install]",
"- /codex account",
"- /codex mcp",
"- /codex skills",

View File

@@ -1,5 +1,11 @@
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js";
import {
installCodexComputerUse,
readCodexComputerUseStatus,
type CodexComputerUseSetupParams,
} from "./app-server/computer-use.js";
import type { CodexComputerUseConfig } from "./app-server/config.js";
import { listAllCodexAppServerModels } from "./app-server/models.js";
import { isJsonObject, type JsonValue } from "./app-server/protocol.js";
import {
@@ -10,6 +16,7 @@ import {
import {
buildHelp,
formatAccount,
formatComputerUseStatus,
formatCodexStatus,
formatList,
formatModels,
@@ -49,6 +56,8 @@ export type CodexCommandDeps = {
safeCodexControlRequest: SafeCodexControlRequestFn;
writeCodexAppServerBinding: typeof writeCodexAppServerBinding;
clearCodexAppServerBinding: typeof clearCodexAppServerBinding;
readCodexComputerUseStatus: typeof readCodexComputerUseStatus;
installCodexComputerUse: typeof installCodexComputerUse;
resolveCodexDefaultWorkspaceDir: typeof resolveCodexDefaultWorkspaceDir;
startCodexConversationThread: typeof startCodexConversationThread;
readCodexConversationActiveTurn: typeof readCodexConversationActiveTurn;
@@ -80,6 +89,8 @@ const defaultCodexCommandDeps: CodexCommandDeps = {
safeCodexControlRequest,
writeCodexAppServerBinding,
clearCodexAppServerBinding,
readCodexComputerUseStatus,
installCodexComputerUse,
resolveCodexDefaultWorkspaceDir,
startCodexConversationThread,
readCodexConversationActiveTurn,
@@ -98,6 +109,13 @@ type ParsedBindArgs = {
help?: boolean;
};
type ParsedComputerUseArgs = {
action: "status" | "install";
overrides: Partial<CodexComputerUseConfig>;
hasOverrides: boolean;
help?: boolean;
};
export async function handleCodexSubcommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
@@ -170,6 +188,11 @@ export async function handleCodexSubcommand(
),
};
}
if (normalized === "computer-use" || normalized === "computeruse") {
return {
text: await handleComputerUseCommand(deps, options.pluginConfig, rest),
};
}
if (normalized === "mcp") {
return {
text: formatList(
@@ -204,6 +227,29 @@ export async function handleCodexSubcommand(
return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` };
}
async function handleComputerUseCommand(
deps: CodexCommandDeps,
pluginConfig: unknown,
args: string[],
): Promise<string> {
const parsed = parseComputerUseArgs(args);
if (parsed.help) {
return [
"Usage: /codex computer-use [status|install] [--source <marketplace-source>] [--marketplace-path <path>] [--marketplace <name>]",
"Checks or installs the configured Codex Computer Use plugin through app-server.",
].join("\n");
}
const params: CodexComputerUseSetupParams = {
pluginConfig,
forceEnable: parsed.action === "install" || parsed.hasOverrides,
...(Object.keys(parsed.overrides).length > 0 ? { overrides: parsed.overrides } : {}),
};
if (parsed.action === "install") {
return formatComputerUseStatus(await deps.installCodexComputerUse(params));
}
return formatComputerUseStatus(await deps.readCodexComputerUseStatus(params));
}
async function bindConversation(
deps: CodexCommandDeps,
ctx: PluginCommandContext,
@@ -504,6 +550,114 @@ function parseBindArgs(args: string[]): ParsedBindArgs {
return parsed;
}
function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs {
const parsed: ParsedComputerUseArgs = {
action: "status",
overrides: {},
hasOverrides: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
parsed.help = true;
continue;
}
if (arg === "status" || arg === "install") {
parsed.action = arg;
continue;
}
if (arg === "--source" || arg === "--marketplace-source") {
const value = readRequiredOptionValue(args, index);
if (!value) {
parsed.help = true;
continue;
}
parsed.overrides.marketplaceSource = value;
index += 1;
continue;
}
if (arg === "--marketplace-path" || arg === "--path") {
const value = readRequiredOptionValue(args, index);
if (!value) {
parsed.help = true;
continue;
}
parsed.overrides.marketplacePath = value;
index += 1;
continue;
}
if (arg === "--marketplace") {
const value = readRequiredOptionValue(args, index);
if (!value) {
parsed.help = true;
continue;
}
parsed.overrides.marketplaceName = value;
index += 1;
continue;
}
if (arg === "--plugin") {
const value = readRequiredOptionValue(args, index);
if (!value) {
parsed.help = true;
continue;
}
parsed.overrides.pluginName = value;
index += 1;
continue;
}
if (arg === "--server" || arg === "--mcp-server") {
const value = readRequiredOptionValue(args, index);
if (!value) {
parsed.help = true;
continue;
}
parsed.overrides.mcpServerName = value;
index += 1;
continue;
}
parsed.help = true;
}
parsed.overrides = normalizeComputerUseStringOverrides(parsed.overrides);
parsed.hasOverrides = Object.values(parsed.overrides).some(Boolean);
return parsed;
}
function readRequiredOptionValue(args: string[], index: number): string | undefined {
const value = args[index + 1];
if (!value || value.startsWith("-")) {
return undefined;
}
return value;
}
function normalizeComputerUseStringOverrides(
overrides: Partial<CodexComputerUseConfig>,
): Partial<CodexComputerUseConfig> {
const normalized: Partial<CodexComputerUseConfig> = {};
const marketplaceSource = normalizeOptionalString(overrides.marketplaceSource);
if (marketplaceSource) {
normalized.marketplaceSource = marketplaceSource;
}
const marketplacePath = normalizeOptionalString(overrides.marketplacePath);
if (marketplacePath) {
normalized.marketplacePath = marketplacePath;
}
const marketplaceName = normalizeOptionalString(overrides.marketplaceName);
if (marketplaceName) {
normalized.marketplaceName = marketplaceName;
}
const pluginName = normalizeOptionalString(overrides.pluginName);
if (pluginName) {
normalized.pluginName = pluginName;
}
const mcpServerName = normalizeOptionalString(overrides.mcpServerName);
if (mcpServerName) {
normalized.mcpServerName = mcpServerName;
}
return normalized;
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import type { CodexAppServerStartOptions } from "./app-server/config.js";
import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js";
import type { CodexCommandDeps } from "./command-handlers.js";
@@ -241,6 +242,67 @@ describe("codex command", () => {
});
});
it("checks Codex Computer Use setup", async () => {
const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus());
await expect(
handleCodexCommand(createContext("computer-use status"), {
deps: createDeps({ readCodexComputerUseStatus }),
}),
).resolves.toEqual({
text: [
"Computer Use: ready",
"Plugin: computer-use (installed)",
"MCP server: computer-use (1 tools)",
"Marketplace: desktop-tools",
"Tools: list_apps",
"Computer Use is ready.",
].join("\n"),
});
expect(readCodexComputerUseStatus).toHaveBeenCalledWith({
pluginConfig: undefined,
forceEnable: false,
});
});
it("installs Codex Computer Use from command overrides", async () => {
const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());
await expect(
handleCodexCommand(
createContext(
"computer-use install --source github:example/desktop-tools --marketplace desktop-tools",
),
{
deps: createDeps({ installCodexComputerUse }),
},
),
).resolves.toEqual({
text: expect.stringContaining("Computer Use: ready"),
});
expect(installCodexComputerUse).toHaveBeenCalledWith({
pluginConfig: undefined,
forceEnable: true,
overrides: {
marketplaceSource: "github:example/desktop-tools",
marketplaceName: "desktop-tools",
},
});
});
it("shows help when Computer Use option values are missing", async () => {
const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());
await expect(
handleCodexCommand(createContext("computer-use install --source"), {
deps: createDeps({ installCodexComputerUse }),
}),
).resolves.toEqual({
text: expect.stringContaining("Usage: /codex computer-use"),
});
expect(installCodexComputerUse).not.toHaveBeenCalled();
});
it("explains compaction when no Codex thread is attached", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
@@ -600,3 +662,18 @@ describe("codex command", () => {
});
});
});
function computerUseReadyStatus(): CodexComputerUseStatus {
return {
enabled: true,
ready: true,
installed: true,
pluginEnabled: true,
mcpServerAvailable: true,
pluginName: "computer-use",
mcpServerName: "computer-use",
marketplaceName: "desktop-tools",
tools: ["list_apps"],
message: "Computer Use is ready.",
};
}

View File

@@ -32,19 +32,34 @@ import {
resolveDeprecatedAuthChoiceReplacement,
} from "./auth-choice-legacy.js";
function authChoiceManifestEnv(): NodeJS.ProcessEnv {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: "extensions",
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "0",
OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1",
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
VITEST: "1",
} as NodeJS.ProcessEnv;
}
describe("auth choice legacy aliases", () => {
it("maps claude-cli to the new anthropic cli choice", () => {
expect(normalizeLegacyOnboardAuthChoice("claude-cli")).toBe("anthropic-cli");
expect(resolveDeprecatedAuthChoiceReplacement("claude-cli")).toEqual({
const env = authChoiceManifestEnv();
expect(normalizeLegacyOnboardAuthChoice("claude-cli", { env })).toBe("anthropic-cli");
expect(resolveDeprecatedAuthChoiceReplacement("claude-cli", { env })).toEqual({
normalized: "anthropic-cli",
message: 'Auth choice "claude-cli" is deprecated; using Anthropic Claude CLI setup instead.',
});
expect(formatDeprecatedNonInteractiveAuthChoiceError("claude-cli")).toBe(
expect(formatDeprecatedNonInteractiveAuthChoiceError("claude-cli", { env })).toBe(
'Auth choice "claude-cli" is deprecated.\nUse "--auth-choice anthropic-cli".',
);
});
it("sources deprecated cli aliases from plugin manifests", () => {
expect(resolveLegacyAuthChoiceAliasesForCli()).toEqual(["claude-cli", "codex-cli"]);
expect(resolveLegacyAuthChoiceAliasesForCli({ env: authChoiceManifestEnv() })).toEqual([
"claude-cli",
"codex-cli",
]);
});
});

View File

@@ -99,6 +99,7 @@ import fs from "node:fs";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import {
pinActivePluginChannelRegistry,
@@ -159,37 +160,15 @@ function makeSkipInstallPrompter() {
return { prompter, select };
}
function makeManifestRecord(plugin: {
id: string;
channels?: string[];
origin?: "bundled" | "global" | "workspace";
activation?: { onChannels?: string[] };
}) {
const rootDir = `/tmp/openclaw-plugins/${plugin.id}`;
return {
id: plugin.id,
origin: plugin.origin ?? "bundled",
channels: plugin.channels ?? [],
providers: [],
cliBackends: [],
hooks: [],
skills: [],
rootDir,
source: path.join(rootDir, "index.js"),
manifestPath: path.join(rootDir, "openclaw.plugin.json"),
...(plugin.activation ? { activation: plugin.activation } : {}),
};
}
function mockActivationOnlyPlugin(plugin: {
id: string;
origin?: "bundled" | "global" | "workspace";
}) {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
makeManifestRecord({
createManifestRecord({
id: plugin.id,
origin: plugin.origin,
...(plugin.origin === undefined ? {} : { origin: plugin.origin }),
activation: {
onChannels: ["external-chat"],
},
@@ -199,6 +178,27 @@ function mockActivationOnlyPlugin(plugin: {
});
}
function createManifestRecord(
overrides: Partial<PluginManifestRecord> & Pick<PluginManifestRecord, "id">,
): PluginManifestRecord {
const { id, ...rest } = overrides;
return {
id,
channels: [],
providers: [],
cliBackends: [],
syntheticAuthRefs: [],
nonSecretAuthMarkers: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: `/tmp/openclaw-test/${id}`,
source: `/tmp/openclaw-test/${id}/index.ts`,
manifestPath: `/tmp/openclaw-test/${id}/openclaw.plugin.json`,
...rest,
};
}
function expectSetupSnapshotDoesNotScopeToPlugin(params: {
cfg: OpenClawConfig;
runtime: ReturnType<typeof makeRuntime>;
@@ -216,10 +216,10 @@ function expectSetupSnapshotDoesNotScopeToPlugin(params: {
onlyPluginIds: [params.pluginId],
}),
);
expect(
(vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as { onlyPluginIds?: string[] })
.onlyPluginIds,
).toBeUndefined();
const firstLoadCall = vi.mocked(loadOpenClawPlugins).mock.calls[0]?.[0] as
| { onlyPluginIds?: string[] }
| undefined;
expect(firstLoadCall?.onlyPluginIds).toBeUndefined();
}
beforeEach(() => {
@@ -789,7 +789,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
const cfg: OpenClawConfig = {};
loadPluginManifestRegistry.mockReturnValue({
plugins: [
makeManifestRecord({
createManifestRecord({
id: "custom-external-chat-plugin",
channels: ["external-chat"],
}),

View File

@@ -8,6 +8,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
// ---------------------------------------------------------------------------
// Mocks (hoisted to module top level)
@@ -92,6 +93,21 @@ function createWorkspaceCatalogEntry(id: string, label: string) {
};
}
function createManifestChannelPlugin(id: string, channels: string[]): PluginManifestRecord {
return {
id,
channels,
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "workspace",
rootDir: `/tmp/openclaw-test/${id}`,
source: `/tmp/openclaw-test/${id}/index.ts`,
manifestPath: `/tmp/openclaw-test/${id}/openclaw.plugin.json`,
};
}
function mockWorkspaceOnlyCatalogEntry(entry: ReturnType<typeof createWorkspaceCatalogEntry>) {
listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace ? [] : [entry],
@@ -190,7 +206,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-
};
listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]);
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }],
plugins: [createManifestChannelPlugin("trusted-telegram-shadow", ["telegram"])],
diagnostics: [],
});
listPluginContributionIds.mockReturnValue(["telegram"]);
@@ -241,7 +257,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-
},
}));
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }],
plugins: [createManifestChannelPlugin("trusted-telegram-shadow", ["telegram"])],
diagnostics: [],
});
listPluginContributionIds.mockReturnValue(["telegram"]);
@@ -286,7 +302,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-
autoEnabledReasons: {},
}));
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "my-cool-plugin", channels: ["my-cool-plugin"] }],
plugins: [createManifestChannelPlugin("my-cool-plugin", ["my-cool-plugin"])],
diagnostics: [],
});
listPluginContributionIds.mockReturnValue(["my-cool-plugin"]);

View File

@@ -29,6 +29,8 @@ export function makeIsolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.Proce
const rootDir = makeTempDir();
return {
OPENCLAW_STATE_DIR: path.join(rootDir, "state"),
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"),
VITEST: "true",
...overrides,
};
}

View File

@@ -43,6 +43,9 @@ function createManifestRegistryFixture() {
manifestPath: "/tmp/firecrawl/openclaw.plugin.json",
channels: [],
providers: [],
cliBackends: [],
syntheticAuthRefs: [],
nonSecretAuthMarkers: [],
skills: [],
hooks: [],
configUiHints: { "webFetch.apiKey": { label: "key" } },
@@ -55,6 +58,9 @@ function createManifestRegistryFixture() {
manifestPath: "/tmp/noise/openclaw.plugin.json",
channels: [],
providers: [],
cliBackends: [],
syntheticAuthRefs: [],
nonSecretAuthMarkers: [],
skills: [],
hooks: [],
configUiHints: { unrelated: { label: "nope" } },

View File

@@ -147,6 +147,9 @@ function createManifestRegistryFixture() {
manifestPath: "/tmp/brave/openclaw.plugin.json",
channels: [],
providers: [],
cliBackends: [],
syntheticAuthRefs: [],
nonSecretAuthMarkers: [],
skills: [],
hooks: [],
configUiHints: { "webSearch.apiKey": { label: "key" } },
@@ -159,6 +162,9 @@ function createManifestRegistryFixture() {
manifestPath: "/tmp/noise/openclaw.plugin.json",
channels: [],
providers: [],
cliBackends: [],
syntheticAuthRefs: [],
nonSecretAuthMarkers: [],
skills: [],
hooks: [],
configUiHints: { unrelated: { label: "nope" } },

View File

@@ -474,6 +474,8 @@ describe("test-projects args", () => {
const configs = buildFullSuiteVitestRunPlans([]).map((plan) => plan.config);
expect(configs).toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
expect(configs).toContain("test/vitest/vitest.full-core-support-boundary.config.ts");
expect(configs).not.toContain("test/vitest/vitest.boundary.config.ts");
expect(configs).toContain("test/vitest/vitest.full-agentic.config.ts");
expect(configs).not.toContain("test/vitest/vitest.agents.config.ts");
expect(configs).not.toContain("test/vitest/vitest.plugins.config.ts");

View File

@@ -84,7 +84,7 @@ const disqualifyingPatterns = [
},
{
code: "module-mocking-helper",
pattern: /runtime-module-mocks/u,
pattern: /(?:runtime-module-mocks|plugins-cli-test-helpers)/u,
},
{
code: "vitest-mock-api",