mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +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:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user