mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:01:01 +00:00
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:
@@ -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.",
|
||||
|
||||
502
extensions/codex/src/app-server/computer-use.test.ts
Normal file
502
extensions/codex/src/app-server/computer-use.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
511
extensions/codex/src/app-server/computer-use.ts
Normal file
511
extensions/codex/src/app-server/computer-use.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user