mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
2 Commits
v2026.5.20
...
codex/comp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b09680ae6 | ||
|
|
c766bdaeac |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.*`,
|
||||
|
||||
@@ -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.*`.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-api.ts",
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
}
|
||||
|
||||
165
extensions/codex/setup-api.test.ts
Normal file
165
extensions/codex/setup-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
285
extensions/codex/setup-api.ts
Normal file
285
extensions/codex/setup-api.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerConfigMigration() {},
|
||||
registerMigrationProvider() {},
|
||||
registerAutoEnableProbe() {},
|
||||
registerOnboardingHook() {},
|
||||
registerProvider() {},
|
||||
registerSpeechProvider() {},
|
||||
registerRealtimeTranscriptionProvider() {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user