Compare commits

...

2 Commits

Author SHA1 Message Date
pash
3b09680ae6 Guide Codex Computer Use setup during onboarding 2026-04-28 20:16:25 -04:00
pash
c766bdaeac Add Codex Computer Use setup command 2026-04-28 19:45:26 -04:00
22 changed files with 1046 additions and 23 deletions

View File

@@ -6,7 +6,7 @@ read_when:
- You are deciding between Codex Computer Use, PeekabooBridge, and direct cua-driver MCP
- You are deciding between Codex Computer Use and a direct cua-driver MCP setup
- You are configuring computerUse for the bundled Codex plugin
- You are troubleshooting /codex computer-use status or install
- You are troubleshooting /codex computer-use status, install, or setup
---
Computer Use is a Codex-native MCP plugin for local desktop control. OpenClaw
@@ -115,6 +115,15 @@ register the bundled Codex marketplace from
fails. If setup still cannot make the MCP server available, the turn fails
before the thread starts.
During interactive onboarding, if you choose Codex login and opt into the native
Codex runtime on macOS, OpenClaw offers to set up Codex Computer Use immediately.
That setup installs or re-enables Computer Use if needed and invokes a read-only
Computer Use tool so native first-run permissions can appear while you are
present.
You can also run `/codex computer-use setup` later from an OpenClaw chat
surface. It uses the same install and read-only probe path.
Existing sessions keep their runtime and Codex thread binding. After changing
`agentRuntime` or Computer Use config, use `/new` or `/reset` in the affected
chat before testing.
@@ -128,6 +137,7 @@ not `openclaw codex ...` CLI subcommands:
```text
/codex computer-use status
/codex computer-use install
/codex computer-use setup
/codex computer-use install --source <marketplace-source>
/codex computer-use install --marketplace-path <path>
/codex computer-use install --marketplace <name>
@@ -140,6 +150,10 @@ enable Codex plugin support.
marketplace source, installs or re-enables the configured plugin through Codex
app-server, reloads MCP servers, and verifies that the MCP server exposes tools.
`setup` runs `install`, starts a temporary Codex thread, and calls the read-only
`list_apps` Computer Use MCP tool. This deliberately starts the native Computer
Use path before an agent needs it for real work.
## Marketplace choices
OpenClaw uses the same app-server API that Codex itself exposes. The
@@ -241,6 +255,15 @@ status for chat:
The chat output includes the plugin state, MCP server state, marketplace, tools
when available, and the specific message for the failing setup step.
The `setup` command also reports a setup probe result:
| Probe state | Meaning |
| --------------------- | ------------------------------------------------------------------- |
| `completed` | The read-only Computer Use probe returned normally. |
| `permissions pending` | The native permission flow opened and still needs user action. |
| `failed` | The setup probe returned an error or app-server request failed. |
| `skipped` | Computer Use is ready, but the read-only setup tool is unavailable. |
## macOS permissions
Computer Use is macOS-specific. The Codex-owned MCP server may need local OS
@@ -255,6 +278,16 @@ Use setup first:
- macOS has granted the required permissions for the desktop-control app.
- The current host session can access the desktop being controlled.
On macOS, onboarding and `/codex computer-use setup` can surface the native
Computer Use permissions flow before a normal agent turn needs it. If a Codex
Computer Use window or macOS System Settings opens, finish the prompts and rerun
setup or status.
On Windows or Linux, Codex Computer Use is not expected to become available
through this path. OpenClaw reports the missing plugin, MCP server, or tools
instead of silently running a Codex-mode turn without the required desktop
control path.
OpenClaw intentionally fails closed when `computerUse.enabled` is true. A
Codex-mode turn should not silently proceed without the native desktop tools
that the config required.
@@ -267,6 +300,9 @@ marketplace is not discovered, pass `--source` or `--marketplace-path`.
**Status says installed but disabled.** Run `/codex computer-use install` again.
Codex app-server install writes the plugin config back to enabled.
**Setup says permissions are pending.** Finish the Codex Computer Use and macOS
System Settings prompts, then rerun `/codex computer-use setup`.
**Status says remote install is unsupported.** Use a local marketplace source or
path. Remote-only catalog entries can be inspected but not installed through the
current app-server API.

View File

@@ -633,6 +633,7 @@ The setup can be checked or installed from the command surface:
- `/codex computer-use status`
- `/codex computer-use install`
- `/codex computer-use setup`
- `/codex computer-use install --source <marketplace-source>`
- `/codex computer-use install --marketplace-path <path>`
@@ -643,6 +644,11 @@ silently running without the native Computer Use tools. See
[Codex Computer Use](/plugins/codex-computer-use) for marketplace choices,
remote catalog limits, status reasons, and troubleshooting.
Interactive onboarding also offers this setup path when a user chooses Codex
login, opts into the native Codex runtime, and is running on macOS. Windows and
Linux onboarding skip the Computer Use prompt because this Codex desktop-control
path is macOS-specific.
When `computerUse.autoInstall` is true, OpenClaw can register the standard
bundled Codex Desktop marketplace from
`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled` if Codex
@@ -755,6 +761,7 @@ Common forms:
- `/codex diagnostics [note]` asks before sending Codex diagnostics feedback 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 computer-use setup` installs Computer Use if needed and opens the first-run native setup path.
- `/codex account` shows account and rate-limit status.
- `/codex mcp` lists Codex app-server MCP server status.
- `/codex skills` lists Codex app-server skills.

View File

@@ -155,7 +155,7 @@ Examples of non-Plan consumers:
| Approval workflow | Session extension, command continuation, next-turn injection, UI descriptor |
| Budget/workspace policy gate | Trusted tool policy, tool metadata, session projection |
| Background lifecycle monitor | Runtime lifecycle cleanup, agent event subscription, session scheduler ownership/cleanup, heartbeat prompt contribution, UI descriptor |
| Setup or onboarding wizard | Session extension, scoped commands, Control UI descriptor |
| Setup or onboarding wizard | Setup entry, onboarding hook, session extension, scoped commands, Control UI descriptor |
<Note>
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,

View File

@@ -306,6 +306,8 @@ Bundled workspace channels that keep setup-safe exports in sidecar modules can u
- The channel plugin object (via `defineSetupPluginEntry`).
- Any HTTP routes required before gateway listen.
- Any gateway methods needed during startup.
- Optional onboarding hooks via `api.registerOnboardingHook(...)` when the
plugin needs an interactive setup step after core onboarding choices.
Those startup gateway methods should still avoid reserved core admin namespaces such as `config.*` or `update.*`.

View File

@@ -17,6 +17,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-api.ts",
"bundle": {
"stageRuntimeDependencies": true
}

View File

@@ -0,0 +1,165 @@
import type { OpenClawConfig, PluginOnboardingContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { __testing } from "./setup-api.js";
function createContext(params: {
config: OpenClawConfig;
confirms?: boolean[];
}): PluginOnboardingContext & {
notes: Array<{ message: string; title?: string }>;
} {
const notes: Array<{ message: string; title?: string }> = [];
const confirms = [...(params.confirms ?? [])];
return {
config: params.config,
env: {},
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
workspaceDir: "/tmp/openclaw-workspace",
notes,
prompter: {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message, title) => {
notes.push({ message, title });
}),
select: vi.fn(async () => {
throw new Error("select should not be called");
}),
multiselect: vi.fn(async () => {
throw new Error("multiselect should not be called");
}),
text: vi.fn(async () => {
throw new Error("text should not be called");
}),
confirm: vi.fn(async () => confirms.shift() ?? false),
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
},
};
}
function createReadyComputerUseResult() {
return {
status: {
enabled: true,
ready: true,
reason: "ready",
installed: true,
pluginEnabled: true,
mcpServerAvailable: true,
pluginName: "computer-use",
mcpServerName: "computer-use",
tools: ["list_apps"],
message: "Computer Use is ready.",
},
probe: {
attempted: true,
state: "completed",
toolName: "list_apps",
message: "Computer Use setup probe completed.",
},
} as const;
}
describe("Codex setup onboarding hook", () => {
it("offers native Codex runtime after OpenAI Codex login without forcing Computer Use", async () => {
const ctx = createContext({
config: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.5" },
},
},
},
confirms: [true, false],
});
const next = await __testing.runCodexOnboardingHook(ctx, { platform: "darwin" });
expect(next.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" });
expect(next.agents?.defaults?.models).toMatchObject({ "openai/gpt-5.5": {} });
expect(next.agents?.defaults?.agentRuntime).toMatchObject({
id: "codex",
fallback: "none",
});
expect(next.plugins?.entries?.codex).toMatchObject({ enabled: true });
expect(
(next.plugins?.entries?.codex as { config?: { computerUse?: unknown } } | undefined)?.config
?.computerUse,
).toBeUndefined();
});
it("sets up Computer Use on macOS when Codex runtime is configured", async () => {
const setupCodexComputerUsePermissions = vi.fn(async () => createReadyComputerUseResult());
const ctx = createContext({
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
agentRuntime: { id: "codex" },
},
},
plugins: {
entries: {
codex: { enabled: true },
},
},
},
confirms: [true],
});
const next = await __testing.runCodexOnboardingHook(ctx, {
platform: "darwin",
setupCodexComputerUsePermissions,
});
expect(setupCodexComputerUsePermissions).toHaveBeenCalledWith({
cwd: "/tmp/openclaw-workspace",
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
},
},
});
expect(next.plugins?.entries?.codex).toMatchObject({
enabled: true,
config: {
computerUse: {
enabled: true,
autoInstall: true,
},
},
});
expect(ctx.notes.some((note) => note.message.includes("Setup probe: completed"))).toBe(true);
});
it("does not show Computer Use setup on non-macOS platforms", async () => {
const setupCodexComputerUsePermissions = vi.fn(async () => createReadyComputerUseResult());
const ctx = createContext({
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
agentRuntime: { id: "codex" },
},
},
},
confirms: [true],
});
const next = await __testing.runCodexOnboardingHook(ctx, {
platform: "win32",
setupCodexComputerUsePermissions,
});
expect(setupCodexComputerUsePermissions).not.toHaveBeenCalled();
expect(next).toBe(ctx.config);
});
});

View File

@@ -0,0 +1,285 @@
import type { OpenClawConfig, PluginOnboardingContext } from "openclaw/plugin-sdk/plugin-entry";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { formatComputerUseSetupResult } from "./src/command-formatters.js";
type CodexComputerUseSetupPermissions =
typeof import("./src/app-server/computer-use.js").setupCodexComputerUsePermissions;
type CodexOnboardingDeps = {
platform?: NodeJS.Platform;
setupCodexComputerUsePermissions?: CodexComputerUseSetupPermissions;
};
const CODEX_PLUGIN_ID = "codex";
const CODEX_RUNTIME_ID = "codex";
const OPENAI_PROVIDER_PREFIX = "openai/";
const OPENAI_CODEX_PROVIDER_PREFIX = "openai-codex/";
const LEGACY_CODEX_PROVIDER_PREFIX = "codex/";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function readPrimaryModel(config: OpenClawConfig): string {
const model = config.agents?.defaults?.model;
if (typeof model === "string") {
return model.trim();
}
return isRecord(model) ? normalizeString(model.primary) : "";
}
function hasCodexRuntime(config: OpenClawConfig): boolean {
const defaultsRuntime = config.agents?.defaults?.agentRuntime;
if (normalizeString(defaultsRuntime?.id).toLowerCase() === CODEX_RUNTIME_ID) {
return true;
}
const agents = config.agents?.list;
return Array.isArray(agents)
? agents.some(
(agent) =>
isRecord(agent) &&
isRecord(agent.agentRuntime) &&
normalizeString(agent.agentRuntime.id).toLowerCase() === CODEX_RUNTIME_ID,
)
: false;
}
function resolveNativeCodexModelRef(primaryModel: string): string | null {
if (primaryModel.startsWith(OPENAI_CODEX_PROVIDER_PREFIX)) {
const modelId = primaryModel.slice(OPENAI_CODEX_PROVIDER_PREFIX.length).trim();
return modelId ? `${OPENAI_PROVIDER_PREFIX}${modelId}` : null;
}
if (primaryModel.startsWith(LEGACY_CODEX_PROVIDER_PREFIX)) {
const modelId = primaryModel.slice(LEGACY_CODEX_PROVIDER_PREFIX.length).trim();
return modelId ? `${OPENAI_PROVIDER_PREFIX}${modelId}` : null;
}
return null;
}
function withPrimaryModel(config: OpenClawConfig, primaryModel: string): OpenClawConfig {
const defaults = config.agents?.defaults ?? {};
const existingModel = defaults.model;
const existingModels = defaults.models ?? {};
const model = isRecord(existingModel)
? {
...existingModel,
primary: primaryModel,
}
: {
primary: primaryModel,
};
return {
...config,
agents: {
...config.agents,
defaults: {
...defaults,
models: {
...existingModels,
[primaryModel]: existingModels[primaryModel] ?? {},
},
model,
},
},
};
}
function withCodexRuntime(config: OpenClawConfig): OpenClawConfig {
const defaults = config.agents?.defaults ?? {};
return {
...config,
agents: {
...config.agents,
defaults: {
...defaults,
agentRuntime: {
...defaults.agentRuntime,
id: CODEX_RUNTIME_ID,
fallback: defaults.agentRuntime?.fallback ?? "none",
},
},
},
};
}
function readCodexPluginEntry(config: OpenClawConfig): Record<string, unknown> {
const entry = config.plugins?.entries?.[CODEX_PLUGIN_ID];
return isRecord(entry) ? entry : {};
}
function readCodexPluginConfig(config: OpenClawConfig): Record<string, unknown> {
const pluginConfig = readCodexPluginEntry(config).config;
return isRecord(pluginConfig) ? pluginConfig : {};
}
function withCodexPluginEnabled(config: OpenClawConfig): OpenClawConfig {
const entry = readCodexPluginEntry(config);
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[CODEX_PLUGIN_ID]: {
...entry,
enabled: true,
config: readCodexPluginConfig(config),
},
},
},
};
}
function withComputerUseConfig(config: OpenClawConfig): OpenClawConfig {
const withPlugin = withCodexPluginEnabled(config);
const entry = readCodexPluginEntry(withPlugin);
const pluginConfig = readCodexPluginConfig(withPlugin);
const computerUse = isRecord(pluginConfig.computerUse) ? pluginConfig.computerUse : {};
return {
...withPlugin,
plugins: {
...withPlugin.plugins,
entries: {
...withPlugin.plugins?.entries,
[CODEX_PLUGIN_ID]: {
...entry,
enabled: true,
config: {
...pluginConfig,
computerUse: {
...computerUse,
enabled: true,
autoInstall: true,
},
},
},
},
},
};
}
function isComputerUseExplicitlyDisabled(config: OpenClawConfig): boolean {
const computerUse = readCodexPluginConfig(config).computerUse;
return isRecord(computerUse) && computerUse.enabled === false;
}
function hasComputerUseConfig(config: OpenClawConfig): boolean {
return isRecord(readCodexPluginConfig(config).computerUse);
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function loadComputerUseSetup(): Promise<CodexComputerUseSetupPermissions> {
const { setupCodexComputerUsePermissions } = await import("./src/app-server/computer-use.js");
return setupCodexComputerUsePermissions;
}
async function maybeConfigureNativeCodexRuntime(
ctx: PluginOnboardingContext,
config: OpenClawConfig,
): Promise<OpenClawConfig> {
if (hasCodexRuntime(config)) {
return config;
}
const nativeModel = resolveNativeCodexModelRef(readPrimaryModel(config));
if (!nativeModel) {
return config;
}
await ctx.prompter.note(
[
"OpenAI Codex login can use the normal OpenClaw runner, or it can run agent turns through the native Codex app-server runtime.",
"Native Codex runtime is required for Codex Computer Use.",
].join("\n"),
"Codex runtime",
);
const useNativeRuntime = await ctx.prompter.confirm({
message: "Use native Codex runtime for this agent?",
initialValue: true,
});
if (!useNativeRuntime) {
return config;
}
return withCodexPluginEnabled(withCodexRuntime(withPrimaryModel(config, nativeModel)));
}
async function maybeSetupComputerUse(
ctx: PluginOnboardingContext,
config: OpenClawConfig,
deps: CodexOnboardingDeps,
): Promise<OpenClawConfig> {
const platform = deps.platform ?? process.platform;
if (
platform !== "darwin" ||
!hasCodexRuntime(config) ||
isComputerUseExplicitlyDisabled(config)
) {
return config;
}
await ctx.prompter.note(
[
"Codex Computer Use lets native Codex-mode agents control this Mac through Codex's Computer Use plugin.",
"Setup installs or re-enables the plugin, then starts the macOS permission flow while you are here.",
].join("\n"),
"Codex Computer Use",
);
const shouldSetup = await ctx.prompter.confirm({
message: "Set up Codex Computer Use now?",
initialValue: !hasComputerUseConfig(config),
});
if (!shouldSetup) {
return config;
}
const candidate = withComputerUseConfig(config);
const setupCodexComputerUsePermissions =
deps.setupCodexComputerUsePermissions ?? (await loadComputerUseSetup());
try {
const result = await setupCodexComputerUsePermissions({
cwd: ctx.workspaceDir,
pluginConfig: readCodexPluginConfig(candidate),
});
await ctx.prompter.note(formatComputerUseSetupResult(result), "Codex Computer Use");
return candidate;
} catch (error) {
await ctx.prompter.note(
[
`Computer Use setup did not finish: ${formatError(error)}`,
"You can rerun setup later from chat with /codex computer-use setup.",
].join("\n"),
"Codex Computer Use",
);
return config;
}
}
export async function runCodexOnboardingHook(
ctx: PluginOnboardingContext,
deps: CodexOnboardingDeps = {},
): Promise<OpenClawConfig> {
const nativeConfig = await maybeConfigureNativeCodexRuntime(ctx, ctx.config);
return await maybeSetupComputerUse(ctx, nativeConfig, deps);
}
export const __testing = {
runCodexOnboardingHook,
withComputerUseConfig,
withCodexRuntime,
withPrimaryModel,
};
export default definePluginEntry({
id: CODEX_PLUGIN_ID,
name: "Codex Setup",
description: "Lightweight Codex setup hooks",
register(api) {
api.registerOnboardingHook((ctx) => runCodexOnboardingHook(ctx));
},
});

View File

@@ -6,6 +6,7 @@ import {
ensureCodexComputerUse,
installCodexComputerUse,
readCodexComputerUseStatus,
setupCodexComputerUsePermissions,
type CodexComputerUseRequest,
} from "./computer-use.js";
@@ -442,16 +443,110 @@ describe("Codex Computer Use setup", () => {
pluginName: "computer-use",
});
});
it("runs the Computer Use setup probe through app-server MCP", async () => {
const request = createComputerUseRequest({ installed: false });
await expect(
setupCodexComputerUsePermissions({
pluginConfig: { computerUse: { enabled: true, autoInstall: true } },
request,
cwd: "/repo",
}),
).resolves.toEqual({
status: expect.objectContaining({
ready: true,
reason: "ready",
installed: true,
pluginEnabled: true,
tools: ["list_apps"],
}),
probe: {
attempted: true,
state: "completed",
toolName: "list_apps",
threadId: "thread-computer-use-setup",
message:
"Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.",
},
});
expect(request).toHaveBeenCalledWith(
"thread/start",
expect.objectContaining({
cwd: "/repo",
ephemeral: true,
experimentalRawEvents: false,
persistExtendedHistory: false,
}),
);
expect(request).toHaveBeenCalledWith("mcpServer/tool/call", {
threadId: "thread-computer-use-setup",
server: "computer-use",
tool: "list_apps",
arguments: {},
});
});
it("reports pending native permissions from the setup probe", async () => {
const request = createComputerUseRequest({
installed: true,
toolCallText: "Computer Use permissions are still pending.",
toolCallIsError: true,
});
await expect(
setupCodexComputerUsePermissions({
pluginConfig: { computerUse: { enabled: true } },
request,
}),
).resolves.toEqual(
expect.objectContaining({
probe: expect.objectContaining({
attempted: true,
state: "permissions_pending",
message:
"Computer Use opened its permission flow. Finish the Codex Computer Use window and macOS System Settings, then run /codex computer-use setup again.",
}),
}),
);
});
it("skips the setup probe when the read-only setup tool is unavailable", async () => {
const request = createComputerUseRequest({ installed: true, tools: ["get_app_state"] });
await expect(
setupCodexComputerUsePermissions({
pluginConfig: { computerUse: { enabled: true } },
request,
}),
).resolves.toEqual(
expect.objectContaining({
probe: {
attempted: false,
state: "skipped",
toolName: "list_apps",
message:
"Computer Use is ready, but setup did not run because the list_apps MCP tool is unavailable.",
},
}),
);
expect(request).not.toHaveBeenCalledWith("thread/start", expect.anything());
expect(request).not.toHaveBeenCalledWith("mcpServer/tool/call", expect.anything());
});
});
function createComputerUseRequest(params: {
installed: boolean;
enabled?: boolean;
marketplaceAvailableAfterListCalls?: number;
toolCallIsError?: boolean;
toolCallText?: string;
tools?: string[];
}): CodexComputerUseRequest {
let installed = params.installed;
let enabled = params.enabled ?? installed;
let pluginListCalls = 0;
const tools = params.tools ?? ["list_apps"];
return vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
@@ -515,12 +610,9 @@ function createComputerUseRequest(params: {
? [
{
name: "computer-use",
tools: {
list_apps: {
name: "list_apps",
inputSchema: { type: "object" },
},
},
tools: Object.fromEntries(
tools.map((tool) => [tool, { name: tool, inputSchema: { type: "object" } }]),
),
resources: [],
resourceTemplates: [],
authStatus: "unsupported",
@@ -530,6 +622,27 @@ function createComputerUseRequest(params: {
nextCursor: null,
};
}
if (method === "thread/start") {
return {
thread: { id: "thread-computer-use-setup", cwd: "/repo" },
model: "gpt-5.5",
modelProvider: "openai",
serviceTier: null,
cwd: "/repo",
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
if (method === "mcpServer/tool/call") {
return {
content: [{ type: "text", text: params.toolCallText ?? "[]" }],
isError: params.toolCallIsError ?? false,
};
}
throw new Error(`unexpected request ${method}`);
}) as CodexComputerUseRequest;
}

View File

@@ -8,7 +8,7 @@ import {
type ResolvedCodexComputerUseConfig,
} from "./config.js";
import type { v2 } from "./protocol-generated/typescript/index.js";
import type { JsonValue } from "./protocol.js";
import { isJsonObject, type JsonValue } from "./protocol.js";
import { requestCodexAppServerJson } from "./request.js";
export type CodexComputerUseRequest = <T = JsonValue | undefined>(
@@ -42,6 +42,25 @@ export type CodexComputerUseStatus = {
message: string;
};
export type CodexComputerUseSetupProbeState =
| "completed"
| "failed"
| "permissions_pending"
| "skipped";
export type CodexComputerUseSetupProbe = {
attempted: boolean;
state: CodexComputerUseSetupProbeState;
toolName: string;
message: string;
threadId?: string;
};
export type CodexComputerUseSetupResult = {
status: CodexComputerUseStatus;
probe: CodexComputerUseSetupProbe;
};
export class CodexComputerUseSetupError extends Error {
readonly status: CodexComputerUseStatus;
@@ -63,6 +82,11 @@ export type CodexComputerUseSetupParams = {
defaultBundledMarketplacePath?: string;
};
export type CodexComputerUsePermissionSetupParams = CodexComputerUseSetupParams & {
cwd?: string;
setupToolName?: string;
};
type MarketplaceRef =
| {
kind: "local";
@@ -94,6 +118,10 @@ const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000;
const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"];
const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH =
"/Applications/Codex.app/Contents/Resources/plugins/openai-bundled";
const DEFAULT_COMPUTER_USE_SETUP_TOOL_NAME = "list_apps";
const COMPUTER_USE_PERMISSION_PENDING_RE =
/Computer Use permissions are (?:still pending|not granted)/i;
const COMPUTER_USE_SETUP_ERROR_MAX_CHARS = 300;
export async function readCodexComputerUseStatus(
params: CodexComputerUseSetupParams = {},
@@ -172,6 +200,62 @@ export async function installCodexComputerUse(
return status;
}
export async function setupCodexComputerUsePermissions(
params: CodexComputerUsePermissionSetupParams = {},
): Promise<CodexComputerUseSetupResult> {
const status = await installCodexComputerUse(params);
const toolName = params.setupToolName ?? DEFAULT_COMPUTER_USE_SETUP_TOOL_NAME;
if (!status.tools.includes(toolName)) {
return {
status,
probe: {
attempted: false,
state: "skipped",
toolName,
message: `Computer Use is ready, but setup did not run because the ${toolName} MCP tool is unavailable.`,
},
};
}
const request = createComputerUseRequest(params);
try {
const thread = await request<v2.ThreadStartResponse>("thread/start", {
cwd: params.cwd ?? process.cwd(),
developerInstructions:
"This temporary thread checks whether Computer Use can start. Do not perform user work in this thread.",
ephemeral: true,
experimentalRawEvents: false,
persistExtendedHistory: false,
} satisfies v2.ThreadStartParams);
const result = await request<v2.McpServerToolCallResponse>("mcpServer/tool/call", {
threadId: thread.thread.id,
server: status.mcpServerName,
tool: toolName,
arguments: {},
} satisfies v2.McpServerToolCallParams);
return {
status,
probe: {
attempted: true,
state: computerUseSetupProbeState(result),
toolName,
threadId: thread.thread.id,
message: computerUseSetupProbeMessage(result),
},
};
} catch (error) {
return {
status,
probe: {
attempted: true,
state: "failed",
toolName,
message: `Computer Use setup probe failed: ${describeControlFailure(error)}`,
},
};
}
}
async function inspectCodexComputerUse(params: {
pluginConfig?: unknown;
request?: CodexComputerUseRequest;
@@ -517,6 +601,45 @@ async function readComputerUsePlugin(
return response.plugin;
}
function computerUseSetupProbeState(
result: v2.McpServerToolCallResponse,
): CodexComputerUseSetupProbeState {
const text = readToolCallText(result);
if (COMPUTER_USE_PERMISSION_PENDING_RE.test(text)) {
return "permissions_pending";
}
return result.isError ? "failed" : "completed";
}
function computerUseSetupProbeMessage(result: v2.McpServerToolCallResponse): string {
const text = readToolCallText(result);
if (COMPUTER_USE_PERMISSION_PENDING_RE.test(text)) {
return "Computer Use opened its permission flow. Finish the Codex Computer Use window and macOS System Settings, then run /codex computer-use setup again.";
}
if (result.isError) {
return `Computer Use setup probe returned an error: ${truncateSetupMessage(text || "unknown error")}`;
}
return "Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.";
}
function readToolCallText(result: v2.McpServerToolCallResponse): string {
return result.content.map(readTextContent).filter(Boolean).join("\n").trim();
}
function readTextContent(value: JsonValue): string {
if (isJsonObject(value)) {
const text = value.text;
return typeof text === "string" ? text : "";
}
return "";
}
function truncateSetupMessage(value: string): string {
return value.length > COMPUTER_USE_SETUP_ERROR_MAX_CHARS
? `${value.slice(0, COMPUTER_USE_SETUP_ERROR_MAX_CHARS)}...`
: value;
}
async function readMcpServerStatus(
request: CodexComputerUseRequest,
serverName: string,

View File

@@ -1,4 +1,8 @@
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import type {
CodexComputerUseSetupProbeState,
CodexComputerUseSetupResult,
CodexComputerUseStatus,
} from "./app-server/computer-use.js";
import type { CodexAppServerModelListResult } from "./app-server/models.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import type { SafeValue } from "./command-rpc.js";
@@ -110,6 +114,21 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string
return lines.join("\n");
}
export function formatComputerUseSetupResult(result: CodexComputerUseSetupResult): string {
return [
formatComputerUseStatus(result.status),
`Setup probe: ${formatComputerUseProbeState(result.probe.state)}`,
result.probe.message,
].join("\n");
}
function formatComputerUseProbeState(state: CodexComputerUseSetupProbeState): string {
if (state === "permissions_pending") {
return "permissions pending";
}
return state;
}
function computerUsePluginState(status: CodexComputerUseStatus): string {
if (!status.installed) {
return "not installed";
@@ -149,7 +168,7 @@ export function buildHelp(): string {
"- /codex compact",
"- /codex review",
"- /codex diagnostics [note]",
"- /codex computer-use [status|install]",
"- /codex computer-use [status|install|setup]",
"- /codex account",
"- /codex mcp",
"- /codex skills",

View File

@@ -4,6 +4,7 @@ import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/cap
import {
installCodexComputerUse,
readCodexComputerUseStatus,
setupCodexComputerUsePermissions,
type CodexComputerUseSetupParams,
} from "./app-server/computer-use.js";
import type { CodexComputerUseConfig } from "./app-server/config.js";
@@ -17,6 +18,7 @@ import {
import {
buildHelp,
formatAccount,
formatComputerUseSetupResult,
formatComputerUseStatus,
formatCodexStatus,
formatList,
@@ -59,6 +61,7 @@ export type CodexCommandDeps = {
clearCodexAppServerBinding: typeof clearCodexAppServerBinding;
readCodexComputerUseStatus: typeof readCodexComputerUseStatus;
installCodexComputerUse: typeof installCodexComputerUse;
setupCodexComputerUsePermissions: typeof setupCodexComputerUsePermissions;
resolveCodexDefaultWorkspaceDir: typeof resolveCodexDefaultWorkspaceDir;
startCodexConversationThread: typeof startCodexConversationThread;
readCodexConversationActiveTurn: typeof readCodexConversationActiveTurn;
@@ -92,6 +95,7 @@ const defaultCodexCommandDeps: CodexCommandDeps = {
clearCodexAppServerBinding,
readCodexComputerUseStatus,
installCodexComputerUse,
setupCodexComputerUsePermissions,
resolveCodexDefaultWorkspaceDir,
startCodexConversationThread,
readCodexConversationActiveTurn,
@@ -111,7 +115,7 @@ type ParsedBindArgs = {
};
type ParsedComputerUseArgs = {
action: "status" | "install";
action: "status" | "install" | "setup";
overrides: Partial<CodexComputerUseConfig>;
hasOverrides: boolean;
help?: boolean;
@@ -302,18 +306,26 @@ async function handleComputerUseCommand(
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.",
"Usage: /codex computer-use [status|install|setup] [--source <marketplace-source>] [--marketplace-path <path>] [--marketplace <name>]",
"Checks, installs, or opens first-run setup for the configured Codex Computer Use plugin through app-server.",
].join("\n");
}
const params: CodexComputerUseSetupParams = {
pluginConfig,
forceEnable: parsed.action === "install" || parsed.hasOverrides,
forceEnable: parsed.action !== "status" || parsed.hasOverrides,
...(Object.keys(parsed.overrides).length > 0 ? { overrides: parsed.overrides } : {}),
};
if (parsed.action === "install") {
return formatComputerUseStatus(await deps.installCodexComputerUse(params));
}
if (parsed.action === "setup") {
return formatComputerUseSetupResult(
await deps.setupCodexComputerUsePermissions({
...params,
cwd: deps.resolveCodexDefaultWorkspaceDir(pluginConfig),
}),
);
}
return formatComputerUseStatus(await deps.readCodexComputerUseStatus(params));
}
@@ -1457,7 +1469,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs {
parsed.help = true;
continue;
}
if (arg === "status" || arg === "install") {
if (arg === "status" || arg === "install" || arg === "setup") {
parsed.action = arg;
continue;
}

View File

@@ -348,6 +348,45 @@ describe("codex command", () => {
});
});
it("runs Codex Computer Use first-run setup from the command surface", async () => {
const setupCodexComputerUsePermissions = vi.fn(async () => ({
status: computerUseReadyStatus(),
probe: {
attempted: true,
state: "completed" as const,
toolName: "list_apps",
message:
"Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.",
},
}));
const resolveCodexDefaultWorkspaceDir = vi.fn(() => "/repo");
await expect(
handleCodexCommand(createContext("computer-use setup"), {
deps: createDeps({
setupCodexComputerUsePermissions,
resolveCodexDefaultWorkspaceDir,
}),
}),
).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.",
"Setup probe: completed",
"Computer Use setup probe completed. If a Codex Computer Use permissions window appeared, follow it to finish macOS setup.",
].join("\n"),
});
expect(setupCodexComputerUsePermissions).toHaveBeenCalledWith({
pluginConfig: undefined,
forceEnable: true,
cwd: "/repo",
});
});
it("shows help when Computer Use option values are missing", async () => {
const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());

View File

@@ -91,6 +91,8 @@ import type {
PluginNextTurnInjection,
PluginNextTurnInjectionEnqueueResult,
PluginNextTurnInjectionRecord,
PluginOnboardingContext,
PluginOnboardingHook,
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,
@@ -133,6 +135,8 @@ export type {
PluginNextTurnInjection,
PluginNextTurnInjectionEnqueueResult,
PluginNextTurnInjectionRecord,
PluginOnboardingContext,
PluginOnboardingHook,
PluginRunContextGetParams,
PluginRunContextPatch,
PluginRuntimeLifecycleRegistration,

View File

@@ -27,6 +27,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerConfigMigration() {},
registerMigrationProvider() {},
registerAutoEnableProbe() {},
registerOnboardingHook() {},
registerProvider() {},
registerSpeechProvider() {},
registerRealtimeTranscriptionProvider() {},

View File

@@ -7,7 +7,14 @@ type QaRuntimeModule = {
loadQaRuntimeModule: () => unknown;
};
type SurfaceLoaderMock = ReturnType<typeof vi.fn>;
type TestMock = ((...args: any[]) => any) & {
mockReturnValue(value: unknown): unknown;
};
type QaRuntimeSurface = {
defaultQaRuntimeModelForMode: (...args: any[]) => any;
startQaLiveLaneGateway: (...args: any[]) => any;
};
export function cleanupTempDirs(tempDirs: string[]): void {
for (const dir of tempDirs.splice(0)) {
@@ -33,7 +40,7 @@ export function makePrivateQaSourceRoot(tempDirs: string[], prefix: string): str
return sourceRoot;
}
export function makeQaRuntimeSurface() {
export function makeQaRuntimeSurface(): QaRuntimeSurface {
return {
defaultQaRuntimeModelForMode: vi.fn(),
startQaLiveLaneGateway: vi.fn(),
@@ -42,7 +49,7 @@ export function makeQaRuntimeSurface() {
export async function expectQaLabRuntimeSurfaceLoad(params: {
importRuntime: () => Promise<QaRuntimeModule>;
loadBundledPluginPublicSurfaceModuleSync: SurfaceLoaderMock;
loadBundledPluginPublicSurfaceModuleSync: TestMock;
}) {
const runtimeSurface = makeQaRuntimeSurface();
params.loadBundledPluginPublicSurfaceModuleSync.mockReturnValue(runtimeSurface);
@@ -59,8 +66,8 @@ export async function expectQaLabRuntimeSurfaceLoad(params: {
export async function expectPrivateQaLabRuntimeSurfaceLoad(params: {
tempDirs: string[];
importRuntime: () => Promise<QaRuntimeModule>;
loadBundledPluginPublicSurfaceModuleSync: SurfaceLoaderMock;
resolveOpenClawPackageRootSync: SurfaceLoaderMock;
loadBundledPluginPublicSurfaceModuleSync: TestMock;
resolveOpenClawPackageRootSync: TestMock;
}) {
const sourceRoot = makePrivateQaSourceRoot(params.tempDirs, "openclaw-qa-runtime-root-");
params.resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);

View File

@@ -13,6 +13,27 @@ type SanitizeConfiguredModelProviderRequestParams = Parameters<
typeof sanitizeConfiguredModelProviderRequest
>[0];
type TestMock = ((...args: any[]) => any) & {
mock: { calls: any[][] };
mockClear(): unknown;
mockImplementation(fn: (...args: any[]) => any): unknown;
mockRejectedValue(value: unknown): unknown;
mockReset(): unknown;
mockResolvedValue(value: unknown): unknown;
mockResolvedValueOnce(value: unknown): unknown;
};
type ProviderHttpMocks = {
resolveApiKeyForProviderMock: TestMock;
postJsonRequestMock: TestMock;
fetchWithTimeoutMock: TestMock;
pollProviderOperationJsonMock: TestMock;
assertOkOrThrowHttpErrorMock: TestMock;
assertOkOrThrowProviderErrorMock: TestMock;
sanitizeConfiguredModelProviderRequestMock: TestMock;
resolveProviderHttpRequestConfigMock: TestMock;
};
const providerHttpMocks = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
postJsonRequestMock: vi.fn(),
@@ -29,7 +50,7 @@ const providerHttpMocks = vi.hoisted(() => ({
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
})) as ProviderHttpMocks;
providerHttpMocks.pollProviderOperationJsonMock.mockImplementation(
async (params: PollProviderOperationJsonParams) => {
@@ -85,7 +106,7 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({
waitProviderOperationPollInterval: async () => {},
}));
export function getProviderHttpMocks() {
export function getProviderHttpMocks(): ProviderHttpMocks {
return providerHttpMocks;
}

View File

@@ -34,6 +34,7 @@ export type BuildPluginApiParams = {
| "registerConfigMigration"
| "registerMigrationProvider"
| "registerAutoEnableProbe"
| "registerOnboardingHook"
| "registerProvider"
| "registerSpeechProvider"
| "registerRealtimeTranscriptionProvider"
@@ -94,6 +95,7 @@ const noopRegisterTextTransforms: OpenClawPluginApi["registerTextTransforms"] =
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
const noopRegisterMigrationProvider: OpenClawPluginApi["registerMigrationProvider"] = () => {};
const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {};
const noopRegisterOnboardingHook: OpenClawPluginApi["registerOnboardingHook"] = () => {};
const noopRegisterProvider: OpenClawPluginApi["registerProvider"] = () => {};
const noopRegisterSpeechProvider: OpenClawPluginApi["registerSpeechProvider"] = () => {};
const noopRegisterRealtimeTranscriptionProvider: OpenClawPluginApi["registerRealtimeTranscriptionProvider"] =
@@ -181,6 +183,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,
registerMigrationProvider: handlers.registerMigrationProvider ?? noopRegisterMigrationProvider,
registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe,
registerOnboardingHook: handlers.registerOnboardingHook ?? noopRegisterOnboardingHook,
registerProvider: handlers.registerProvider ?? noopRegisterProvider,
registerSpeechProvider: handlers.registerSpeechProvider ?? noopRegisterSpeechProvider,
registerRealtimeTranscriptionProvider:

View File

@@ -27,6 +27,7 @@ let resolvePluginSetupRegistry: typeof import("./setup-registry.js").resolvePlug
let resolvePluginSetupProvider: typeof import("./setup-registry.js").resolvePluginSetupProvider;
let resolvePluginSetupCliBackend: typeof import("./setup-registry.js").resolvePluginSetupCliBackend;
let runPluginSetupConfigMigrations: typeof import("./setup-registry.js").runPluginSetupConfigMigrations;
let runPluginOnboardingHooks: typeof import("./setup-registry.js").runPluginOnboardingHooks;
function forceNodeRuntimeVersionsForTest(): () => void {
const originalVersions = process.versions;
@@ -183,6 +184,7 @@ describe("setup-registry getJiti", () => {
resolvePluginSetupProvider,
resolvePluginSetupCliBackend,
runPluginSetupConfigMigrations,
runPluginOnboardingHooks,
} = await import("./setup-registry.js"));
clearPluginSetupRegistryCache();
});
@@ -359,6 +361,54 @@ describe("setup-registry getJiti", () => {
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
});
it("runs setup-registered onboarding hooks and returns their config", async () => {
const pluginRoot = makeTempDir();
writeSetupApiStub(pluginRoot);
mockSinglePlugin({ id: "demo", rootDir: pluginRoot });
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerOnboardingHook: (
hook: (ctx: { config: unknown }) => unknown | Promise<unknown>,
) => void;
}) {
api.registerOnboardingHook(async ({ config }) => ({
...(config as Record<string, unknown>),
plugins: {
entries: {
demo: { enabled: true },
},
},
}));
},
},
});
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await runPluginOnboardingHooks({
config: {},
env: {},
prompter: {} as never,
runtime,
workspaceDir: pluginRoot,
});
expect(result).toEqual({
plugins: {
entries: {
demo: { enabled: true },
},
},
});
expect(runtime.error).not.toHaveBeenCalled();
});
it("prefers setup provider descriptors over top-level provider ids", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
@@ -432,6 +482,7 @@ describe("setup-registry getJiti", () => {
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
onboardingHooks: [],
diagnostics: [
expect.objectContaining({
pluginId: "openai",
@@ -468,6 +519,7 @@ describe("setup-registry getJiti", () => {
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
onboardingHooks: [],
diagnostics: [],
});
expect(mocks.createJiti).not.toHaveBeenCalled();

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { buildPluginApi } from "./api-builder.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
@@ -17,6 +18,7 @@ import type {
OpenClawPluginModule,
PluginConfigMigration,
PluginLogger,
PluginOnboardingHook,
PluginSetupAutoEnableProbe,
ProviderPlugin,
} from "./types.js";
@@ -47,6 +49,11 @@ type SetupAutoEnableProbeEntry = {
probe: PluginSetupAutoEnableProbe;
};
type SetupOnboardingHookEntry = {
pluginId: string;
hook: PluginOnboardingHook;
};
export type PluginSetupRegistryDiagnosticCode =
| "setup-descriptor-runtime-disabled"
| "setup-descriptor-provider-missing-runtime"
@@ -67,6 +74,7 @@ type PluginSetupRegistry = {
cliBackends: SetupCliBackendEntry[];
configMigrations: SetupConfigMigrationEntry[];
autoEnableProbes: SetupAutoEnableProbeEntry[];
onboardingHooks: SetupOnboardingHookEntry[];
diagnostics: PluginSetupRegistryDiagnostic[];
};
@@ -509,6 +517,7 @@ export function resolvePluginSetupRegistry(params?: {
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
onboardingHooks: [],
diagnostics: [],
} satisfies PluginSetupRegistry;
setCachedSetupValue(setupRegistryCache, cacheKey, empty);
@@ -519,6 +528,7 @@ export function resolvePluginSetupRegistry(params?: {
const cliBackends: SetupCliBackendEntry[] = [];
const configMigrations: SetupConfigMigrationEntry[] = [];
const autoEnableProbes: SetupAutoEnableProbeEntry[] = [];
const onboardingHooks: SetupOnboardingHookEntry[] = [];
const diagnostics: PluginSetupRegistryDiagnostic[] = [];
const providerKeys = new Set<string>();
const cliBackendKeys = new Set<string>();
@@ -588,6 +598,12 @@ export function resolvePluginSetupRegistry(params?: {
probe,
});
},
registerOnboardingHook(hook) {
onboardingHooks.push({
pluginId: record.id,
hook,
});
},
},
});
@@ -613,6 +629,7 @@ export function resolvePluginSetupRegistry(params?: {
cliBackends,
configMigrations,
autoEnableProbes,
onboardingHooks,
diagnostics,
} satisfies PluginSetupRegistry;
setCachedSetupValue(setupRegistryCache, cacheKey, registry);
@@ -674,6 +691,7 @@ export function resolvePluginSetupProvider(params: {
},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerOnboardingHook() {},
},
});
@@ -740,6 +758,7 @@ export function resolvePluginSetupCliBackend(params: {
registerProvider() {},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerOnboardingHook() {},
registerCliBackend(backend) {
const key = normalizeProviderId(backend.id);
if (localBackendKeys.has(key)) {
@@ -769,6 +788,41 @@ export function resolvePluginSetupCliBackend(params: {
return resolvedEntry ?? undefined;
}
export async function runPluginOnboardingHooks(params: {
config: OpenClawConfig;
prompter: import("../wizard/prompts.js").WizardPrompter;
runtime: import("../runtime.js").RuntimeEnv;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<OpenClawConfig> {
const env = params.env ?? process.env;
let next = params.config;
const hooks = resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
}).onboardingHooks;
for (const entry of hooks) {
try {
const result = await entry.hook({
config: next,
env,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
});
if (result) {
next = result;
}
} catch (error) {
params.runtime.error(
`Plugin ${entry.pluginId} onboarding warning: ${formatErrorMessage(error)}`,
);
}
}
return next;
}
export function runPluginSetupConfigMigrations(params: {
config: OpenClawConfig;
workspaceDir?: string;

View File

@@ -2242,6 +2242,18 @@ export type PluginSetupAutoEnableProbe = (
ctx: PluginSetupAutoEnableContext,
) => string | string[] | null | undefined;
export type PluginOnboardingContext = {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir?: string;
};
export type PluginOnboardingHook = (
ctx: PluginOnboardingContext,
) => OpenClawConfig | void | Promise<OpenClawConfig | void>;
/** Main registration API injected into native plugin entry files. */
export type OpenClawPluginApi = {
id: string;
@@ -2316,6 +2328,8 @@ export type OpenClawPluginApi = {
registerMigrationProvider: (provider: MigrationProviderPlugin) => void;
/** Register a lightweight config probe that can auto-enable this plugin generically. */
registerAutoEnableProbe: (probe: PluginSetupAutoEnableProbe) => void;
/** Register an interactive setup wizard hook for plugin-owned onboarding steps. */
registerOnboardingHook: (hook: PluginOnboardingHook) => void;
/** Register a native model/provider plugin (text inference capability). */
registerProvider: (provider: ProviderPlugin) => void;
/** Register a speech synthesis provider (speech capability). */

View File

@@ -18,6 +18,8 @@ type ResolvePluginSetupProvider =
typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider;
type ResolveManifestProviderAuthChoice =
typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice;
type RunPluginOnboardingHooks =
typeof import("../plugins/setup-registry.js").runPluginOnboardingHooks;
type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel;
type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice;
@@ -33,6 +35,9 @@ const resolveManifestProviderAuthChoice = vi.hoisted(() =>
const resolvePluginSetupProvider = vi.hoisted(() =>
vi.fn<ResolvePluginSetupProvider>(() => undefined),
);
const runPluginOnboardingHooks = vi.hoisted(() =>
vi.fn<RunPluginOnboardingHooks>(async (args) => args.config),
);
const resolveProviderPluginChoice = vi.hoisted(() =>
vi.fn<ResolveProviderPluginChoice>(() => null),
);
@@ -182,6 +187,7 @@ vi.mock("../plugins/provider-auth-choices.js", () => ({
vi.mock("../plugins/setup-registry.js", () => ({
resolvePluginSetupProvider,
runPluginOnboardingHooks,
}));
vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
@@ -1034,4 +1040,56 @@ describe("runSetupWizard", () => {
expect(resolvePluginProvidersRuntime).not.toHaveBeenCalled();
expect(promptDefaultModel).toHaveBeenCalledWith(expect.objectContaining({ allowKeep: false }));
});
it("runs plugin onboarding hooks before finalizing setup", async () => {
runPluginOnboardingHooks.mockClear();
finalizeSetupWizard.mockClear();
runPluginOnboardingHooks.mockImplementationOnce(async ({ config }) => ({
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
demo: { enabled: true },
},
},
}));
const caseDir = await makeCaseDir("plugin-onboarding-");
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: caseDir,
},
runtime,
prompter,
);
expect(runPluginOnboardingHooks).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: caseDir,
prompter,
runtime,
}),
);
expect(finalizeSetupWizard).toHaveBeenCalledWith(
expect.objectContaining({
nextConfig: expect.objectContaining({
plugins: {
entries: {
demo: { enabled: true },
},
},
}),
}),
);
});
});

View File

@@ -772,6 +772,13 @@ export async function runSetupWizard(
// Setup hooks (session memory on /new)
const { setupInternalHooks } = await import("../commands/onboard-hooks.js");
nextConfig = await setupInternalHooks(nextConfig, runtime, prompter);
const { runPluginOnboardingHooks } = await import("../plugins/setup-registry.js");
nextConfig = await runPluginOnboardingHooks({
config: nextConfig,
prompter,
runtime,
workspaceDir,
});
nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode });
nextConfig = await writeWizardConfigFile(nextConfig);