Files
openclaw/scripts/control-ui-mock-plugins.ts
Peter Steinberger 0dbd5c81d5 feat(plugins): one consent screen for plugin capabilities, bound to the reviewed artifact (#130168)
* feat(plugins): surface plugin capability consent in Control UI and CLI

Adds plugins.inspect (declared manifest surface, operator grants, install
provenance/integrity, ClawHub trust), a Control UI consent dialog on install
and external-plugin enable, a server-side acceptance gate persisted on the
install record, artifact-anchored widen diffing, and --accept-capabilities
for non-interactive CLI use.

NOT READY TO LAND: autoreview found critical gaps (see PR notes) — the
declared surface omits 20 of 21 contract families, native plugins always
report zero hooks, several install/enable paths bypass the gate, and the
acknowledgment is not bound to the reviewed surface.

* refactor(plugins): bind capability consent to the reviewed surface

Collapses the consent error payload to the fields the client cannot fetch
(reviewToken, widened, acceptedAt) and pulls identity/declared/grants/source/
trust from plugins.inspect, shrinking the registry-free protocol reader from
395 to 91 lines and removing its divergence from the closed schema.

Acknowledgment now carries the SHA-256 reviewToken of the surface the operator
saw; the server recomputes the final staged artifact's surface and rejects any
mismatch before persisting acceptance. That closes review-then-swap, laundering
of forged acceptance through an unchanged update, and cross-artifact replay.

All 22 manifest contract families are now declared, hashed and diffed, so a
privileged family such as gatewayMethodDispatch can no longer be added without
re-consent. Consent reads the manifest runtime discovery will execute, ambiguous
install ownership fails closed, integrity resolution has one owner and no longer
labels npm SHA-1 shasums as SHA-256, and code plugins disclose that hooks
register at runtime instead of rendering an empty "no hooks" row.

* fix(gateway): register plugins.inspect in method inventories and regenerate protocol

Adds plugins.inspect to the advertised-method inventories (widening the
fixed-size slice windows so older indices stay stable), regenerates the Kotlin
protocol bindings, drops an unused exported type, and replaces two nested
conditional spreads with a plain conditional.

* refactor(plugins): split oversized consent modules and clear lint findings

Extracts the MCP controller out of the plugins page, unchanged-install
reconciliation out of update-installed, and the install lifecycle suite out of
the management-service tests, bringing all three back under the max-lines limit
without suppressions. Also renames a shadowed binding, drops an unnecessary
generic, removes a spread-to-modify in a map, and types catch callbacks as
unknown.

* chore(protocol): regenerate Kotlin bindings after rebase

* feat(plugins): let chat /plugins install review and accept capabilities

The consent gate applies to chat installs too, but the command had no way to
give consent, so external installs dead-ended on a CLI-only flag. Chat now
replies with the plugin's declared capability surface and the exact command to
rerun, and accepts a trailing --accept-capabilities mirroring the existing
--force acknowledgement. ClawHub trust acknowledgement stays CLI-only.

Staged-artifact verification is unchanged: the reviewToken is still checked
against the final artifact before acceptance is recorded.

* refactor(plugins): single-source the declared-surface groups and manifest precedence

The ordered capability group list was defined independently in the consent
engine, the protocol error reader, the CLI formatter and the Control UI, so a
new contract family had to be added in four places with nothing enforcing it.
All four now derive from one canonical list in the protocol schema with a
compile-time exhaustiveness guard.

Native-versus-bundle manifest precedence is centralized in one helper that both
discovery and staged consent call, so the two cannot drift again — that
divergence was a real bug where consent read one manifest and the runtime
executed another.

Also documents that carrying acceptance forward requires pinned artifact
integrity, so integrity-less sources such as local paths ask on every install.

* fix(plugins): enforce reviewed consent across activation flows

Route setup, repair, linked installs, updates, and chat activation through artifact-bound capability consent. Reuse canonical package discovery and recheck staged activation before config publication. Invalidate stale Control UI review requests on reconnect.

Verified focused owner and sibling tests, runtime rebuild, and real isolated CLI/Gateway install, inspect, enable, widening, and stale-token rejection flows.

* test(plugins): cover beta installs through capability consent

* test(plugins): align consent fixtures with staged artifacts

* fix(ui): review staged plugin capabilities once

* test(ui): inline the remaining plugin consent confirmation

* test(plugins): verify consent with deferred install transactions

* refactor(setup): share inference execution plan construction

* test(ui): settle applied config before deferring refresh

* fix(plugins): protect consent provenance and reuse acceptance
2026-08-27 02:58:07 -07:00

243 lines
7.1 KiB
TypeScript

// Plugin-catalog fixtures for the Control UI mock dev harness.
import { createHash } from "node:crypto";
import type {
PluginDeclaredSurface,
PluginsInspectResult,
} from "../packages/gateway-protocol/src/schema/plugins.js";
export function buildPluginCatalogMock() {
const entry = (params: {
id: string;
name: string;
description: string;
category: string;
origin: string;
installed: boolean;
enabled?: boolean;
featured?: boolean;
install?: { source: "official"; pluginId: string };
}) => ({
id: params.id,
name: params.name,
description: params.description,
version: "1.4.0",
origin: params.origin,
installed: params.installed,
enabled: params.installed && (params.enabled ?? true),
state: params.installed ? ((params.enabled ?? true) ? "enabled" : "disabled") : "not-installed",
category: params.category,
featured: params.featured ?? false,
removable: params.installed && params.origin !== "bundled",
...(params.install ? { install: params.install } : {}),
});
return {
plugins: [
entry({
id: "telegram",
name: "Telegram",
description: "Chat with your agent from Telegram DMs and groups.",
category: "channel",
origin: "bundled",
installed: true,
}),
entry({
id: "discord",
name: "Discord",
description: "Bridge agents into Discord servers and DMs.",
category: "channel",
origin: "global",
installed: true,
enabled: false,
}),
entry({
id: "memory-wiki",
name: "Memory Wiki",
description: "Long-term wiki-style memory for people and projects.",
category: "memory",
origin: "bundled",
installed: true,
}),
entry({
id: "browser",
name: "Browser",
description: "Drive a managed browser profile for research and automation.",
category: "tool",
origin: "official",
installed: false,
featured: true,
install: { source: "official", pluginId: "browser" },
}),
entry({
id: "canvas",
name: "Canvas",
description: "Generate and preview visual artifacts from sessions.",
category: "tool",
origin: "official",
installed: false,
install: { source: "official", pluginId: "canvas" },
}),
],
diagnostics: [],
mutationAllowed: true,
};
}
/** Parameterized plugins.inspect fixtures for the consent dialog and detail overlay. */
export function buildPluginInspectMock() {
const emptyDeclared: PluginDeclaredSurface = {
channels: [],
providers: [],
tools: [],
contracts: [],
hooks: [],
mcpServers: [],
cliCommands: [],
cliBackends: [],
skills: [],
dangerousConfigFlags: [],
};
const fixtures = new Map<
string,
{
source: NonNullable<PluginsInspectResult["source"]>;
declared: Partial<PluginDeclaredSurface>;
trust?: PluginsInspectResult["trust"];
}
>([
[
"telegram",
{
source: { kind: "bundled" },
declared: { channels: ["telegram"], cliCommands: ["telegram"] },
},
],
[
"discord",
{
source: {
kind: "npm",
spec: "@openclaw/discord@1.4.0",
packageName: "@openclaw/discord",
integrity: "sha512-Zt8FjB1uT0mMyF5b0z0aH4dKq7wVn0m8rW3o5cQx1JYb1sB4kQ2u5w9c1p6nEo3q",
integrityKind: "ssri",
},
declared: {
channels: ["discord"],
providers: ["discord-intelligence"],
tools: ["discord_actions", "discord_moderate"],
contracts: ["tools: discord_actions", "tools: discord_moderate"],
skills: ["discord"],
},
trust: { disposition: "clean", checkedAt: "2026-08-20T14:03:00Z" },
},
],
[
"memory-wiki",
{ source: { kind: "bundled" }, declared: { tools: ["memory_search", "memory_write"] } },
],
[
"browser",
{
source: {
kind: "official-catalog",
spec: "clawhub:openclaw/browser@1.4.0",
packageName: "openclaw/browser",
integrity: "2f7c1a9be03d5c44a8a14a4e9d0d5375f4f3f0f5f7f1b9f2c3d4e5f60718293a",
integrityKind: "sha256",
},
declared: {
tools: ["browser_click", "browser_navigate", "browser_screenshot"],
cliCommands: ["browser"],
dangerousConfigFlags: ["allowHostControl"],
},
trust: { disposition: "clean", checkedAt: "2026-08-22T09:41:00Z" },
},
],
[
"canvas",
{
source: { kind: "official-catalog", packageName: "openclaw/canvas" },
declared: { tools: ["canvas_render"] },
},
],
]);
const cases = buildPluginCatalogMock().plugins.map((plugin) => {
const fixture = fixtures.get(plugin.id);
if (!fixture) {
throw new Error(`Mock inspection is missing for plugin "${plugin.id}".`);
}
const declared = { ...emptyDeclared, ...fixture.declared };
const response = {
ok: true,
plugin: {
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
origin: plugin.origin,
installed: plugin.installed,
enabled: plugin.enabled,
},
source: fixture.source,
declared,
reviewToken: createHash("sha256").update(JSON.stringify(declared)).digest("hex"),
grants: {
hooks: {
allowPromptInjection: { effective: true },
allowConversationAccess: { effective: plugin.origin === "bundled" },
},
},
...(fixture.trust ? { trust: fixture.trust } : {}),
} satisfies PluginsInspectResult;
return { match: { pluginId: plugin.id }, response };
});
return { cases };
}
export function buildPluginSetEnabledMock() {
const plugin = buildPluginCatalogMock().plugins.find((entry) => entry.id === "discord");
const inspection = buildPluginInspectMock().cases.find(
(entry) => entry.match.pluginId === "discord",
)?.response;
if (!plugin || !inspection) {
throw new Error("Discord mock plugin fixtures are missing");
}
return {
cases: [
{
match: {
pluginId: plugin.id,
enabled: true,
acknowledgeCapabilities: { reviewToken: inspection.reviewToken },
},
response: {
ok: true,
plugin: { ...plugin, enabled: true, state: "enabled" },
restartRequired: true,
},
},
{
match: { pluginId: plugin.id, enabled: true },
response: {
__mockError: {
code: "INVALID_REQUEST",
message: 'Plugin "discord" requires capability consent',
details: {
capabilityConsentCode: "PLUGIN_CAPABILITY_CONSENT_REQUIRED",
pluginId: plugin.id,
reviewToken: inspection.reviewToken,
widened: {
providers: ["discord-intelligence"],
tools: ["discord_moderate"],
contracts: ["tools: discord_moderate"],
},
acceptedAt: "2026-08-20T14:03:00Z",
},
},
},
},
],
};
}