feat(dashboard): plugin-declared widget data bindings and action verbs (#112083)

* feat(dashboard): add plugin capability declarations

* docs(dashboard): describe plugin capabilities

* fix(plugins): preserve registry map cloning

* fix(dashboard): make plugin grant ids unambiguous

* fix(dashboard): align generated plugin grant ids

* chore(boards): internalize verb ids and refresh protocol snapshots
This commit is contained in:
Peter Steinberger
2026-07-20 22:43:04 -07:00
committed by GitHub
parent b521a85d85
commit be5e427f56
28 changed files with 1111 additions and 60 deletions
@@ -869,28 +869,6 @@ public struct BoardDataReadParams: Codable, Sendable {
}
}
public struct BoardActionParams: Codable, Sendable {
public let ticket: String
public let action: String
public let jobid: String
public init(
ticket: String,
action: String,
jobid: String)
{
self.ticket = ticket
self.action = action
self.jobid = jobid
}
private enum CodingKeys: String, CodingKey {
case ticket
case action
case jobid = "jobId"
}
}
public struct BoardChangedEvent: Codable, Sendable {
public let sessionkey: String
public let revision: Int
+3
View File
@@ -5991,6 +5991,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Minimal example
- H2: Rich example
- H2: Top-level field reference
- H2: dashboard reference
- H2: catalog reference
- H2: Generation provider metadata reference
- H2: Tool metadata reference
@@ -10697,9 +10698,11 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Interaction tiers
- H2: Widget model and hosting
- H3: Widgets host content; MCP apps are one content kind
- H3: Plugin capability declarations
- H3: Modeled residual: WebRTC data channels
- H3: Transcript display: one widget card
- H3: Server-sourced widgets (pinned MCP apps)
- H3: WorkBoard integration
- H2: Layout: fluid grid
- H2: Data model (per-agent DB)
- H2: Protocol surface
+36
View File
@@ -31,6 +31,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m
- activation hints for control-plane surfaces
- shorthand model-family ownership
- static capability-ownership snapshots (`contracts`)
- dashboard widget data bindings and action verbs
- QA runner metadata the shared `openclaw qa` host can inspect
- channel-specific config metadata merged into catalog and validation surfaces
@@ -160,6 +161,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m
| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. |
| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. |
| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. |
| `dashboard` | No | `object` | Dashboard widget data bindings and action verbs. Each entry is validated against a Gateway method registered by this plugin with the required read or write scope. See [dashboard reference](#dashboard-reference). |
| `contracts` | No | `object` | Static capability ownership snapshot for external auth hooks, embeddings, speech, realtime transcription, realtime voice, media-understanding, image/video/music generation, web fetch, web search, worker providers, document/web-content extraction, and tool ownership. |
| `configContracts` | No | `object` | Manifest-owned config behavior consumed by generic core helpers: dangerous-flag detection, SecretRef migration targets, and legacy config-path narrowing. See [configContracts reference](#configcontracts-reference). |
| `mediaUnderstandingProviderMetadata` | No | `Record<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
@@ -176,6 +178,40 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m
| `version` | No | `string` | Informational plugin version. |
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
## dashboard reference
`dashboard` lets an enabled plugin expose existing Gateway RPCs to granted dashboard widgets without adding plugin policy to core. Data bindings must name a method the same plugin registers with `operator.read`; action verbs must name a method it registers with `operator.write`. A mismatch rejects the plugin during registration.
```json
{
"dashboard": {
"dataBindings": [
{
"id": "items.list",
"method": "example.items.list",
"description": "List example items."
}
],
"actionVerbs": [
{
"id": "refresh",
"method": "example.items.refresh",
"description": "Refresh example items.",
"paramShape": {
"type": "object",
"additionalProperties": false,
"properties": {
"force": { "type": "boolean" }
}
}
}
]
}
}
```
The manifest ids are plugin-local. Widget grants use `<plugin-id>.<id>`, such as `example.items.list` and `example.refresh`. To keep the persisted grant namespace unambiguous, OpenClaw escapes `%` and `.` in the plugin-id segment as `%25` and `%2E`; ordinary plugin ids keep the natural form. `paramShape` is an optional JSON Schema applied to the action params object before OpenClaw invokes the plugin RPC.
## catalog reference
`catalog` provides optional display hints to plugin browsers. Hosts may ignore these hints. They never install or enable the plugin, and they do not change its runtime behavior or trust level.
+5 -1
View File
@@ -16,7 +16,9 @@ Adds the Clickclack channel surface for sending and receiving OpenClaw messages.
## Surface
channels: `clickclack`
channels: `clickclack`; contracts: `tools`
<!-- openclaw-plugin-reference:manual-start -->
The plugin can optionally create a lifecycle-synchronized ClickClack channel
for each OpenClaw session. Managed discussion channels use a same-agent side
@@ -24,6 +26,8 @@ session for observation and relay, while the attached main session receives a
pull-only `discussion` tool. See [ClickClack session discussions](/channels/clickclack#session-discussions)
for configuration and session-tool visibility requirements.
<!-- openclaw-plugin-reference:manual-end -->
## Related docs
- [clickclack](/channels/clickclack)
+1 -1
View File
@@ -16,7 +16,7 @@ Dashboard workboard for agent-owned issues and sessions.
## Surface
contracts: `tools`
contracts: `tools`; dashboard data bindings: `workboard.cards.list`, `workboard.stats`, `workboard.boards.list`; dashboard action verbs: `workboard.dispatch`
## Related docs
+21 -6
View File
@@ -161,6 +161,19 @@ Shared infrastructure underneath (this is where the simplification lands):
request channel; size reporting and theme tokens remain separate host
notifications.
### Plugin capability declarations
Enabled plugins can extend the widget host through `dashboard.dataBindings`
and `dashboard.actionVerbs` in `openclaw.plugin.json`. Plugin-local ids become
grant names prefixed by the plugin id, such as `workboard.cards.list` and
`workboard.dispatch`; `%` and `.` in the plugin-id segment are escaped so a
different plugin/local-id split cannot inherit the same persisted grant. During
plugin registration, OpenClaw verifies that every binding targets an RPC
registered by the same plugin with `operator.read` and every action targets one
with `operator.write`; invalid declarations fail the plugin load. The validated
registry is rebuilt only with plugin lifecycle changes, while widget grants
remain per-widget and byte-and-revision-bound.
### Modeled residual: WebRTC data channels
The sandbox CSP emits the proposed `webrtc 'block'` directive, but
@@ -214,6 +227,10 @@ dashboards. v1 pins to the originating session's board; cross-session pinning
needs a lease broker and waits. Coordinate with open PR #109807 (`ui/message`
composer routing, theme/size propagation).
### WorkBoard integration
The WorkBoard integration program keeps cards and boards plugin-owned while stitching dispatched cards back to their session boards through the existing `sessionKey` and `runId`, exposing WorkBoard feeds and dispatch through plugin-declared bindings and actions, and composing those results with the existing `html` and `mcp-app` widget kinds instead of introducing a WorkBoard-specific widget type.
## Layout: fluid grid
12 columns, fixed row height, **auto-compacting** (gravity-up, push-aside on
@@ -283,10 +300,10 @@ RPCs (core method table, typebox schemas in `gateway-protocol`):
- `board.prompt.authorize { ticket }` — returns whether a visible prompt send
still needs per-click confirmation — `operator.read`
- `board.data.read { ticket, bindingId, params? }` — gateway-side allowlisted
read binding resolution — `operator.read`
- `board.action { ticket, action: "cron.trigger", jobId }` — exact-grant
automation dispatch through the existing cron run-now path
`operator.write`
core or active-plugin read binding resolution — `operator.read`
- `board.action { ticket, action, ... }` — exact-grant automation dispatch
through the existing cron run-now path or an active plugin's validated action
verb — `operator.write`
Events (in `EVENT_SCOPE_GUARDS`, read scope):
@@ -327,7 +344,6 @@ false`, never in a stable release (first appeared in 2026.7.2 betas). No
A2UI. The `pluginSurfaceUrls["canvas"]` advertisement and
`/__openclaw__/canvas` paths are shipped native-client contracts and stay
stable. Discord sessions keep the Discord-owned `show_widget` variant.
- **WorkBoard is untouched** (integration is a follow-up program).
## Non-goals (this program)
@@ -336,7 +352,6 @@ false`, never in a stable release (first appeared in 2026.7.2 betas). No
Control UI; the inline-widget path is unchanged).
- Builtin data widgets (sessions/usage/cron cards) — the capability bridge plus
agent-authored widgets cover v1; a builtin kind registry can come later.
- WorkBoard-on-dashboard.
## Implementation plan
+2
View File
@@ -71,6 +71,8 @@ with one tap:
- **Prompt** (`prompt`): send messages into your thread without the per-click
confirmation that unapproved widgets require.
Enabled plugins can add their own named read-only feeds and actions to these capability lists; disabling the plugin removes those integrations.
Grants are bound to the exact widget bytes and revision you reviewed. If the
agent changes the widget and asks for _more_ than you approved, it goes back
to pending; refreshing content within the same permissions keeps the grant.
+33
View File
@@ -5,6 +5,39 @@
"onStartup": true,
"onCommands": ["workboard"]
},
"dashboard": {
"dataBindings": [
{
"id": "cards.list",
"method": "workboard.cards.list",
"description": "List Workboard cards and statuses."
},
{
"id": "stats",
"method": "workboard.cards.stats",
"description": "Read Workboard card statistics."
},
{
"id": "boards.list",
"method": "workboard.boards.list",
"description": "List Workboard boards."
}
],
"actionVerbs": [
{
"id": "dispatch",
"method": "workboard.cards.dispatch",
"description": "Dispatch ready Workboard cards.",
"paramShape": {
"type": "object",
"additionalProperties": false,
"properties": {
"boardId": { "type": "string", "minLength": 1 }
}
}
}
]
},
"name": "Workboard",
"description": "Dashboard workboard for agent-owned issues and sessions.",
"catalog": { "featured": true, "order": 10 },
@@ -1,6 +1,7 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
BoardActionParamsSchema,
BoardSnapshotSchema,
BoardWidgetAppViewParamsSchema,
BoardWidgetAppViewResultSchema,
@@ -9,6 +10,32 @@ import {
BoardWidgetResizeOpSchema,
} from "./board.js";
describe("BoardActionParamsSchema", () => {
it("accepts both exact cron triggers and plugin action verbs", () => {
expect(
Value.Check(BoardActionParamsSchema, {
ticket: "v1.ticket.signature",
action: "cron.trigger",
jobId: "nightly",
}),
).toBe(true);
expect(
Value.Check(BoardActionParamsSchema, {
ticket: "v1.ticket.signature",
action: "workboard.dispatch",
params: { boardId: "release" },
}),
).toBe(true);
expect(
Value.Check(BoardActionParamsSchema, {
ticket: "v1.ticket.signature",
action: "workboard.dispatch",
params: "release",
}),
).toBe(false);
});
});
describe("BoardSnapshotSchema", () => {
it("accepts optional HTML widget view metadata", () => {
const snapshot = {
+16 -2
View File
@@ -34,6 +34,7 @@ export const BOARD_CRON_JOB_ID_MAX_LENGTH = 256;
export const BOARD_CRON_TRIGGER_PREFIX = "cron.trigger:";
export const BOARD_WIDGET_TOOL_MAX_LENGTH =
BOARD_CRON_TRIGGER_PREFIX.length + BOARD_CRON_JOB_ID_MAX_LENGTH;
export const BOARD_DATA_BINDING_ID_MAX_LENGTH = 64;
export const BoardTabSchema = closedObject({
tabId: BoardTabIdSchema,
@@ -263,7 +264,7 @@ export type BoardPromptAuthorizeParams = Static<typeof BoardPromptAuthorizeParam
export const BoardDataReadParamsSchema = closedObject({
ticket: BoardViewTicketSchema,
bindingId: Type.String({ minLength: 1, maxLength: 64 }),
bindingId: Type.String({ minLength: 1, maxLength: BOARD_DATA_BINDING_ID_MAX_LENGTH }),
params: Type.Optional(
Type.Record(Type.String({ minLength: 1, maxLength: 80 }), Type.Unknown(), {
maxProperties: 64,
@@ -272,11 +273,24 @@ export const BoardDataReadParamsSchema = closedObject({
});
export type BoardDataReadParams = Static<typeof BoardDataReadParamsSchema>;
export const BoardActionParamsSchema = closedObject({
export const BoardCronActionParamsSchema = closedObject({
ticket: BoardViewTicketSchema,
action: Type.Literal("cron.trigger"),
jobId: Type.String({ minLength: 1, maxLength: BOARD_CRON_JOB_ID_MAX_LENGTH }),
});
export const BoardPluginActionParamsSchema = closedObject({
ticket: BoardViewTicketSchema,
action: Type.String({ minLength: 1, maxLength: BOARD_WIDGET_TOOL_MAX_LENGTH }),
params: Type.Optional(
Type.Record(Type.String({ minLength: 1, maxLength: 80 }), Type.Unknown(), {
maxProperties: 64,
}),
),
});
export const BoardActionParamsSchema = Type.Union([
BoardCronActionParamsSchema,
BoardPluginActionParamsSchema,
]);
export type BoardActionParams = Static<typeof BoardActionParamsSchema>;
export const BoardChangedEventSchema = closedObject({
+5
View File
@@ -1,7 +1,12 @@
export type PluginSurfaceManifest = {
id?: string;
channels?: string[];
providers?: string[];
contracts?: Record<string, unknown>;
dashboard?: {
dataBindings?: Array<{ id?: string }>;
actionVerbs?: Array<{ id?: string }>;
};
skills?: unknown[];
};
+26
View File
@@ -2,6 +2,24 @@ function formatIdentifiers(values) {
return values.map((value) => `\`${value}\``).join(", ");
}
function encodeDashboardPluginIdSegment(pluginId) {
return pluginId.replaceAll("%", "%25").replaceAll(".", "%2E");
}
function resolveDashboardCapabilityIds(manifest, field) {
if (typeof manifest.id !== "string" || !Array.isArray(manifest.dashboard?.[field])) {
return [];
}
const pluginIdSegment = encodeDashboardPluginIdSegment(manifest.id);
return manifest.dashboard[field]
.map((entry) =>
typeof entry?.id === "string" && entry.id.length > 0
? `${pluginIdSegment}.${entry.id}`
: null,
)
.filter((value) => value !== null);
}
export function resolvePluginSurface(manifest) {
const parts = [];
if (Array.isArray(manifest.channels) && manifest.channels.length > 0) {
@@ -16,6 +34,14 @@ export function resolvePluginSurface(manifest) {
if (contracts.length > 0) {
parts.push(`contracts: ${formatIdentifiers(contracts)}`);
}
const dashboardDataBindings = resolveDashboardCapabilityIds(manifest, "dataBindings");
if (dashboardDataBindings.length > 0) {
parts.push(`dashboard data bindings: ${formatIdentifiers(dashboardDataBindings)}`);
}
const dashboardActionVerbs = resolveDashboardCapabilityIds(manifest, "actionVerbs");
if (dashboardActionVerbs.length > 0) {
parts.push(`dashboard action verbs: ${formatIdentifiers(dashboardActionVerbs)}`);
}
if (Array.isArray(manifest.skills) && manifest.skills.length > 0) {
parts.push("skills");
}
+17
View File
@@ -0,0 +1,17 @@
export const CORE_BOARD_DATA_BINDING_IDS = [
"sessions.list",
"usage.status",
"usage.cost",
"cron.list",
"cron.status",
"agents.list",
"health",
] as const;
const CORE_BOARD_ACTION_VERB_IDS = ["cron.trigger"] as const;
/** Widget grants share one string namespace across reads and actions. */
export const CORE_BOARD_HOST_CAPABILITY_IDS = [
...CORE_BOARD_DATA_BINDING_IDS,
...CORE_BOARD_ACTION_VERB_IDS,
] as const;
+44 -14
View File
@@ -1,5 +1,8 @@
import type { ErrorShape } from "../../packages/gateway-protocol/src/index.js";
import { CORE_BOARD_DATA_BINDING_IDS } from "../boards/board-host-capability-ids.js";
import { BoardValidationError } from "../boards/board-layout.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { cronHandlers } from "./server-methods/cron.js";
import { healthHandlers } from "./server-methods/health.js";
@@ -7,17 +10,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
import type { GatewayRequestHandlers } from "./server-methods/types.js";
import { usageHandlers } from "./server-methods/usage.js";
const BOARD_DATA_BINDING_IDS = [
"sessions.list",
"usage.status",
"usage.cost",
"cron.list",
"cron.status",
"agents.list",
"health",
] as const;
type BoardDataBindingId = (typeof BOARD_DATA_BINDING_IDS)[number];
type BoardDataBindingId = (typeof CORE_BOARD_DATA_BINDING_IDS)[number];
type GatewayHandlerInvocation = Parameters<GatewayRequestHandlers[string]>[0];
const BOARD_DATA_HANDLERS: Record<BoardDataBindingId, GatewayRequestHandlers[string]> = {
@@ -31,7 +24,7 @@ const BOARD_DATA_HANDLERS: Record<BoardDataBindingId, GatewayRequestHandlers[str
};
function isBoardDataBindingId(value: string): value is BoardDataBindingId {
return (BOARD_DATA_BINDING_IDS as readonly string[]).includes(value);
return (CORE_BOARD_DATA_BINDING_IDS as readonly string[]).includes(value);
}
async function invokeGatewayHandler(
@@ -78,13 +71,50 @@ export async function readBoardDataBinding(
params: Record<string, unknown>,
invocation: GatewayHandlerInvocation,
): Promise<unknown> {
if (!isBoardDataBindingId(bindingId)) {
if (isBoardDataBindingId(bindingId)) {
return await invokeGatewayHandler(
BOARD_DATA_HANDLERS[bindingId],
bindingId,
params,
invocation,
);
}
const registration = getActivePluginRegistry()?.dashboardDataBindings.get(bindingId);
if (!registration) {
throw new BoardValidationError(
"invalid_operation",
`board widget data binding is not allowed: ${bindingId}`,
);
}
return await invokeGatewayHandler(BOARD_DATA_HANDLERS[bindingId], bindingId, params, invocation);
return await invokeGatewayHandler(registration.handler, registration.method, params, invocation);
}
export async function runBoardActionVerb(
actionId: string,
params: Record<string, unknown>,
invocation: GatewayHandlerInvocation,
): Promise<unknown> {
const registration = getActivePluginRegistry()?.dashboardActionVerbs.get(actionId);
if (!registration) {
throw new BoardValidationError(
"invalid_operation",
`board widget action verb is not allowed: ${actionId}`,
);
}
if (registration.paramShape) {
const validation = validateJsonSchemaValue({
schema: registration.paramShape,
cacheKey: `dashboard-action:${registration.pluginId}:${registration.id}`,
value: params,
});
if (!validation.ok) {
throw new BoardValidationError(
"invalid_operation",
`board widget action params do not match ${actionId}: ${validation.errors.map((error) => error.text).join(", ")}`,
);
}
}
return await invokeGatewayHandler(registration.handler, registration.method, params, invocation);
}
export async function triggerBoardCronJob(
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import type { BoardSnapshot } from "../../../packages/gateway-protocol/src/index.js";
import { registerPluginDashboardCapabilities } from "../../plugins/dashboard-capabilities.js";
import { createPluginRecord } from "../../plugins/loader-records.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import {
getActivePluginRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import { createPluginGatewayMethodDescriptor } from "../methods/registry.js";
import { createBoardHarness } from "./board.test-support.js";
import type { GatewayRequestHandlers } from "./types.js";
describe("board plugin capabilities", () => {
it("routes granted bindings and actions only while their plugin registry is active", async () => {
const previousRegistry = getActivePluginRegistry();
const registry = createEmptyPluginRegistry();
const readHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ params, respond }) => {
respond(true, { items: [params.filter ?? "all"] });
});
const actionHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ params, respond }) => {
respond(true, { refreshed: params.force });
});
registry.gatewayHandlers["workboard.cards.list"] = readHandler;
registry.gatewayHandlers["workboard.cards.dispatch"] = actionHandler;
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "workboard",
name: "workboard.cards.list",
handler: readHandler,
scope: "operator.read",
}),
createPluginGatewayMethodDescriptor({
pluginId: "workboard",
name: "workboard.cards.dispatch",
handler: actionHandler,
scope: "operator.write",
}),
);
const plugin = createPluginRecord({
id: "workboard",
source: "workboard-stub-plugin-fixture",
origin: "bundled",
enabled: true,
configSchema: false,
dashboard: {
dataBindings: [
{
id: "cards.list",
method: "workboard.cards.list",
description: "List fixture cards",
},
],
actionVerbs: [
{
id: "dispatch",
method: "workboard.cards.dispatch",
description: "Dispatch fixture cards",
paramShape: {
type: "object",
additionalProperties: false,
required: ["force"],
properties: { force: { type: "boolean" } },
},
},
],
},
});
registerPluginDashboardCapabilities({ record: plugin, registry });
registry.plugins.push(plugin);
setActivePluginRegistry(registry);
try {
const { invoke, store } = createBoardHarness();
const put = await invoke("board.widget.put", {
sessionKey: "session",
name: "plugin-widget",
content: { kind: "html", html: "plugin" },
declared: { tools: ["workboard.cards.list", "workboard.dispatch"] },
});
expect(put.mock.calls[0]?.[1]).toMatchObject({
widgets: [
{
declaredSummary: [
"Tool access: workboard.cards.list",
"Tool access: workboard.dispatch",
],
},
],
});
await invoke("board.widget.grant", {
sessionKey: "session",
name: "plugin-widget",
decision: "granted",
revision: 1,
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
});
const board = await invoke("board.get", { sessionKey: "session" });
const snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
const ticket = snapshot.widgets[0]?.viewTicket;
const read = await invoke("board.data.read", {
ticket,
bindingId: "workboard.cards.list",
params: { filter: "ready" },
});
expect(read.mock.calls[0]?.[1]).toEqual({ items: ["ready"] });
expect(readHandler).toHaveBeenCalledOnce();
const invalidAction = await invoke("board.action", {
ticket,
action: "workboard.dispatch",
params: { force: "yes" },
});
expect(invalidAction.mock.calls[0]?.[0]).toBe(false);
expect(actionHandler).not.toHaveBeenCalled();
const action = await invoke("board.action", {
ticket,
action: "workboard.dispatch",
params: { force: true },
});
expect(action.mock.calls[0]?.[1]).toEqual({ refreshed: true });
expect(actionHandler).toHaveBeenCalledOnce();
setActivePluginRegistry(createEmptyPluginRegistry());
const unavailable = await invoke("board.data.read", {
ticket,
bindingId: "workboard.cards.list",
});
expect(unavailable.mock.calls[0]?.[0]).toBe(false);
expect(unavailable.mock.calls[0]?.[2]?.message).toContain("not allowed");
} finally {
if (previousRegistry) {
setActivePluginRegistry(previousRegistry);
} else {
resetPluginRuntimeStateForTest();
}
}
});
});
+30 -9
View File
@@ -31,7 +31,11 @@ import { appendBoardEventNotice, BoardEventPayloadError } from "../../boards/boa
import type { BoardStore } from "../../boards/board-store.js";
import { readCanvasDocumentHtmlSource } from "../../canvas/documents.js";
import { buildWidgetDocument } from "../../canvas/wrap.js";
import { readBoardDataBinding, triggerBoardCronJob } from "../board-host-tools.js";
import {
readBoardDataBinding,
runBoardActionVerb,
triggerBoardCronJob,
} from "../board-host-tools.js";
import { buildBoardWidgetSandboxPath } from "../board-sandbox.js";
import { boardStore } from "../board-store.js";
import {
@@ -56,9 +60,11 @@ type McpAppDependencies = {
mintFromTranscript: typeof mintMcpAppViewFromTranscript;
};
type BoardDataReader = typeof readBoardDataBinding;
type BoardActionVerbRunner = typeof runBoardActionVerb;
type BoardCronTrigger = typeof triggerBoardCronJob;
type BoardHandlerDependencies = Partial<McpAppDependencies> & {
readDataBinding?: BoardDataReader;
runActionVerb?: BoardActionVerbRunner;
triggerCronJob?: BoardCronTrigger;
};
@@ -94,6 +100,18 @@ function respondBoardError(
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(error)));
}
function assertCapabilityParamsSize(
params: Record<string, unknown>,
capability: "action" | "data binding",
): void {
if (Buffer.byteLength(JSON.stringify(params), "utf8") > 8 * 1024) {
throw new BoardValidationError(
"invalid_operation",
`board widget ${capability} params exceed 8192 UTF-8 bytes`,
);
}
}
export function createBoardHandlers(
store: BoardStore,
appendNotice: NoticeAppender = appendBoardEventNotice,
@@ -109,6 +127,7 @@ export function createBoardHandlers(
dependencies.mintFromTranscript ?? defaultMcpAppDependencies.mintFromTranscript,
};
const readDataBinding = dependencies.readDataBinding ?? readBoardDataBinding;
const runActionVerb = dependencies.runActionVerb ?? runBoardActionVerb;
const triggerCronJob = dependencies.triggerCronJob ?? triggerBoardCronJob;
return {
"board.get": async ({ params, respond, context }) => {
@@ -411,12 +430,7 @@ export function createBoardHandlers(
try {
const boardParams = params as BoardDataReadParams;
const bindingParams = boardParams.params ?? {};
if (Buffer.byteLength(JSON.stringify(bindingParams), "utf8") > 8 * 1024) {
throw new BoardValidationError(
"invalid_operation",
"board widget data binding params exceed 8192 UTF-8 bytes",
);
}
assertCapabilityParamsSize(bindingParams, "data binding");
const { document } = resolveAuthorizedBoardWidgetView(store, boardParams.ticket);
if (
!boardWidgetHasGrantedTool(document.declared, document.grantState, boardParams.bindingId)
@@ -440,14 +454,21 @@ export function createBoardHandlers(
try {
const boardParams = params as BoardActionParams;
const { document } = resolveAuthorizedBoardWidgetView(store, boardParams.ticket);
const capability = `cron.trigger:${boardParams.jobId}`;
const capability =
"jobId" in boardParams ? `cron.trigger:${boardParams.jobId}` : boardParams.action;
if (!boardWidgetHasGrantedTool(document.declared, document.grantState, capability)) {
throw new BoardValidationError(
"invalid_operation",
`board widget tool is not granted: ${capability}`,
);
}
respond(true, await triggerCronJob(boardParams.jobId, invocation));
if ("jobId" in boardParams) {
respond(true, await triggerCronJob(boardParams.jobId, invocation));
return;
}
const actionParams = boardParams.params ?? {};
assertCapabilityParamsSize(actionParams, "action");
respond(true, await runActionVerb(boardParams.action, actionParams, invocation));
} catch (error) {
respondBoardError(error, respond);
}
+351
View File
@@ -0,0 +1,351 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
cleanupPluginLoaderFixturesForTest,
loadOpenClawPlugins,
resetPluginLoaderTestStateForTest,
type TempPlugin,
useNoBundledPlugins,
writePlugin,
} from "./loader.test-fixtures.js";
import { loadPluginManifest } from "./manifest.js";
afterEach(resetPluginLoaderTestStateForTest);
afterAll(cleanupPluginLoaderFixturesForTest);
function updateDashboardManifest(plugin: TempPlugin, dashboard: Record<string, unknown>): void {
const manifestPath = path.join(plugin.dir, "openclaw.plugin.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, dashboard }, null, 2), "utf8");
}
function loadFixture(plugin: TempPlugin) {
return loadOpenClawPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: [plugin.id],
},
},
onlyPluginIds: [plugin.id],
});
}
describe("plugin dashboard declarations", () => {
it("loads the Workboard bindings and dispatch action from its manifest", () => {
const result = loadPluginManifest(path.join(process.cwd(), "extensions", "workboard"));
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest.dashboard).toEqual({
dataBindings: [
{
id: "cards.list",
method: "workboard.cards.list",
description: "List Workboard cards and statuses.",
},
{
id: "stats",
method: "workboard.cards.stats",
description: "Read Workboard card statistics.",
},
{
id: "boards.list",
method: "workboard.boards.list",
description: "List Workboard boards.",
},
],
actionVerbs: [
{
id: "dispatch",
method: "workboard.cards.dispatch",
description: "Dispatch ready Workboard cards.",
paramShape: {
type: "object",
additionalProperties: false,
properties: { boardId: { type: "string", minLength: 1 } },
},
},
],
});
});
it("rejects gateway methods owned outside the declaring plugin", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "dashboard-foreign-method",
body: `module.exports = {
id: "dashboard-foreign-method",
register(api) {
api.registerGatewayMethod(
"dashboard-foreign-method.read",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.read" },
);
},
};`,
});
updateDashboardManifest(plugin, {
dataBindings: [{ id: "foreign", method: "sessions.list", description: "Foreign method" }],
});
const registry = loadFixture(plugin);
const record = registry.plugins.find((entry) => entry.id === plugin.id);
expect(record).toMatchObject({ status: "error", failurePhase: "register" });
expect(record?.error).toContain("must be registered by the declaring plugin");
expect(registry.dashboardDataBindings.size).toBe(0);
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
pluginId: plugin.id,
code: "dashboard-declaration-invalid",
}),
);
});
it("rejects dashboard data bindings registered with the wrong scope", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "dashboard-wrong-scope",
body: `module.exports = {
id: "dashboard-wrong-scope",
register(api) {
api.registerGatewayMethod(
"dashboard-wrong-scope.read",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.write" },
);
},
};`,
});
updateDashboardManifest(plugin, {
dataBindings: [
{
id: "read",
method: "dashboard-wrong-scope.read",
description: "Wrong-scope method",
},
],
});
const registry = loadFixture(plugin);
const record = registry.plugins.find((entry) => entry.id === plugin.id);
expect(record).toMatchObject({ status: "error", failurePhase: "register" });
expect(record?.error).toContain("must use operator.read, got operator.write");
expect(registry.dashboardDataBindings.size).toBe(0);
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
pluginId: plugin.id,
code: "dashboard-declaration-invalid",
}),
);
});
it("rejects action verbs that collide with core data-binding grants", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "sessions",
body: `module.exports = {
id: "sessions",
register(api) {
api.registerGatewayMethod(
"sessions.pluginWrite",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.write" },
);
},
};`,
});
updateDashboardManifest(plugin, {
actionVerbs: [
{
id: "list",
method: "sessions.pluginWrite",
description: "Colliding write action",
},
],
});
const registry = loadFixture(plugin);
const record = registry.plugins.find((entry) => entry.id === plugin.id);
expect(record).toMatchObject({ status: "error", failurePhase: "register" });
expect(record?.error).toContain('capability id "sessions.list" is reserved by core');
expect(registry.dashboardActionVerbs.size).toBe(0);
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
pluginId: plugin.id,
code: "dashboard-declaration-invalid",
}),
);
});
it("escapes plugin ids that would otherwise overlap dynamic cron grants", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "cron.trigger:nightly",
filename: "cron-trigger-nightly.cjs",
body: `module.exports = {
id: "cron.trigger:nightly",
register(api) {
api.registerGatewayMethod(
"plugin.nightly.read",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.read" },
);
},
};`,
});
updateDashboardManifest(plugin, {
dataBindings: [
{
id: "run",
method: "plugin.nightly.read",
description: "Colliding cron binding",
},
],
});
const registry = loadFixture(plugin);
const record = registry.plugins.find((entry) => entry.id === plugin.id);
expect(record?.status).toBe("loaded");
expect(registry.dashboardDataBindings.has("cron%2Etrigger:nightly.run")).toBe(true);
});
it("keeps dotted plugin owners and literal escape markers distinct", () => {
useNoBundledPlugins();
const dataPlugin = writePlugin({
id: "dashboard",
filename: "dashboard-data.cjs",
body: `module.exports = {
id: "dashboard",
register(api) {
api.registerGatewayMethod(
"dashboard.items",
({ respond }) => respond(true, { items: [] }),
{ scope: "operator.read" },
);
},
};`,
});
updateDashboardManifest(dataPlugin, {
dataBindings: [
{
id: "segmented.refresh",
method: "dashboard.items",
description: "Read segmented items",
},
],
});
const actionPlugin = writePlugin({
id: "dashboard.segmented",
filename: "dashboard-segmented-action.cjs",
body: `module.exports = {
id: "dashboard.segmented",
register(api) {
api.registerGatewayMethod(
"dashboard.segmented.refresh",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.write" },
);
},
};`,
});
updateDashboardManifest(actionPlugin, {
actionVerbs: [
{
id: "refresh",
method: "dashboard.segmented.refresh",
description: "Refresh segmented items",
},
],
});
const literalEscapePlugin = writePlugin({
id: "dashboard%2Esegmented",
filename: "dashboard-literal-escape.cjs",
body: `module.exports = {
id: "dashboard%2Esegmented",
register(api) {
api.registerGatewayMethod(
"dashboard.literal-escape.items",
({ respond }) => respond(true, { items: [] }),
{ scope: "operator.read" },
);
},
};`,
});
updateDashboardManifest(literalEscapePlugin, {
dataBindings: [
{
id: "refresh",
method: "dashboard.literal-escape.items",
description: "Read literal-escape items",
},
],
});
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: dataPlugin.dir,
config: {
plugins: {
load: { paths: [dataPlugin.file, actionPlugin.file, literalEscapePlugin.file] },
allow: [dataPlugin.id, actionPlugin.id, literalEscapePlugin.id],
},
},
onlyPluginIds: [dataPlugin.id, actionPlugin.id, literalEscapePlugin.id],
});
expect(registry.plugins.filter((entry) => entry.status === "loaded")).toHaveLength(3);
expect(registry.dashboardDataBindings.has("dashboard.segmented.refresh")).toBe(true);
expect(registry.dashboardActionVerbs.has("dashboard%2Esegmented.refresh")).toBe(true);
expect(registry.dashboardDataBindings.has("dashboard%252Esegmented.refresh")).toBe(true);
});
it("publishes validated dashboard bindings and action verbs", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "dashboard-valid",
body: `module.exports = {
id: "dashboard-valid",
register(api) {
api.registerGatewayMethod(
"dashboard-valid.items",
({ respond }) => respond(true, { items: [] }),
{ scope: "operator.read" },
);
api.registerGatewayMethod(
"dashboard-valid.refresh",
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.write" },
);
},
};`,
});
updateDashboardManifest(plugin, {
dataBindings: [{ id: "items", method: "dashboard-valid.items", description: "List items" }],
actionVerbs: [
{
id: "refresh",
method: "dashboard-valid.refresh",
description: "Refresh items",
paramShape: { type: "object", additionalProperties: false },
},
],
});
const registry = loadFixture(plugin);
expect(registry.plugins.find((entry) => entry.id === plugin.id)?.status).toBe("loaded");
expect(registry.dashboardDataBindings.get("dashboard-valid.items")).toMatchObject({
pluginId: plugin.id,
method: "dashboard-valid.items",
});
expect(registry.dashboardActionVerbs.get("dashboard-valid.refresh")).toMatchObject({
pluginId: plugin.id,
method: "dashboard-valid.refresh",
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import {
BOARD_CRON_TRIGGER_PREFIX,
BOARD_DATA_BINDING_ID_MAX_LENGTH,
BOARD_WIDGET_TOOL_MAX_LENGTH,
} from "../../packages/gateway-protocol/src/index.js";
import { CORE_BOARD_HOST_CAPABILITY_IDS } from "../boards/board-host-capability-ids.js";
import type {
PluginDashboardActionVerbRegistration,
PluginDashboardDataBindingRegistration,
PluginRecord,
PluginRegistry,
} from "./registry-types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
export class PluginDashboardDeclarationError extends Error {
constructor(message: string) {
super(message);
this.name = "PluginDashboardDeclarationError";
}
}
function fail(pluginId: string, message: string): never {
throw new PluginDashboardDeclarationError(
`invalid dashboard declaration for plugin ${JSON.stringify(pluginId)}: ${message}`,
);
}
function buildCapabilityId(params: {
pluginId: string;
localId: string;
maxLength: number;
}): string {
// Grants outlive plugin activation. Escape the owner delimiter and escape marker so
// different plugin/local-id splits cannot reuse one persisted authorization string.
const pluginIdSegment = params.pluginId.replaceAll("%", "%25").replaceAll(".", "%2E");
const capabilityId = `${pluginIdSegment}.${params.localId}`;
if (capabilityId.length > params.maxLength) {
return fail(
params.pluginId,
`capability id ${JSON.stringify(capabilityId)} exceeds ${params.maxLength} characters`,
);
}
return capabilityId;
}
function requireOwnedMethod(params: {
pluginId: string;
method: string;
expectedScope: "operator.read" | "operator.write";
registry: PluginRegistry;
}) {
const descriptor = params.registry.gatewayMethodDescriptors.find(
(candidate) => candidate.name === params.method,
);
if (descriptor?.owner.kind !== "plugin" || descriptor.owner.pluginId !== params.pluginId) {
return fail(
params.pluginId,
`method ${JSON.stringify(params.method)} must be registered by the declaring plugin`,
);
}
if (descriptor.scope !== params.expectedScope) {
return fail(
params.pluginId,
`method ${JSON.stringify(params.method)} must use ${params.expectedScope}, got ${descriptor.scope}`,
);
}
const handler = params.registry.gatewayHandlers[params.method];
if (!handler) {
return fail(
params.pluginId,
`method ${JSON.stringify(params.method)} is missing its registered handler`,
);
}
return handler;
}
/** Validates and publishes one plugin's manifest-declared dashboard capabilities atomically. */
export function registerPluginDashboardCapabilities(params: {
record: PluginRecord;
registry: PluginRegistry;
}): void {
const dashboard = params.record.dashboard;
if (!dashboard) {
return;
}
const dataBindings: PluginDashboardDataBindingRegistration[] = [];
const actionVerbs: PluginDashboardActionVerbRegistration[] = [];
const capabilityIds = new Set<string>();
const claimCapabilityId = (capabilityId: string): void => {
if (
capabilityIds.has(capabilityId) ||
params.registry.dashboardDataBindings.has(capabilityId) ||
params.registry.dashboardActionVerbs.has(capabilityId)
) {
fail(params.record.id, `duplicate capability id ${JSON.stringify(capabilityId)}`);
}
if (
(CORE_BOARD_HOST_CAPABILITY_IDS as readonly string[]).includes(capabilityId) ||
capabilityId.startsWith(BOARD_CRON_TRIGGER_PREFIX)
) {
fail(params.record.id, `capability id ${JSON.stringify(capabilityId)} is reserved by core`);
}
capabilityIds.add(capabilityId);
};
for (const declaration of dashboard.dataBindings ?? []) {
const capabilityId = buildCapabilityId({
pluginId: params.record.id,
localId: declaration.id,
maxLength: BOARD_DATA_BINDING_ID_MAX_LENGTH,
});
claimCapabilityId(capabilityId);
dataBindings.push({
...declaration,
pluginId: params.record.id,
capabilityId,
handler: requireOwnedMethod({
pluginId: params.record.id,
method: declaration.method,
expectedScope: "operator.read",
registry: params.registry,
}),
});
}
for (const declaration of dashboard.actionVerbs ?? []) {
const capabilityId = buildCapabilityId({
pluginId: params.record.id,
localId: declaration.id,
maxLength: BOARD_WIDGET_TOOL_MAX_LENGTH,
});
claimCapabilityId(capabilityId);
const handler = requireOwnedMethod({
pluginId: params.record.id,
method: declaration.method,
expectedScope: "operator.write",
registry: params.registry,
});
if (declaration.paramShape) {
try {
validateJsonSchemaValue({
schema: declaration.paramShape,
cacheKey: `dashboard-action:${params.record.id}:${declaration.id}`,
value: undefined,
});
} catch (error) {
fail(
params.record.id,
`action ${JSON.stringify(capabilityId)} has an invalid paramShape: ${String(error)}`,
);
}
}
actionVerbs.push({
...declaration,
pluginId: params.record.id,
capabilityId,
handler,
});
}
for (const registration of dataBindings) {
params.registry.dashboardDataBindings.set(registration.capabilityId, registration);
}
for (const registration of actionVerbs) {
params.registry.dashboardActionVerbs.set(registration.capabilityId, registration);
}
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import type { PluginCompatCode } from "./compat/registry.js";
import type { PluginActivationState } from "./config-state.js";
import type { PluginBundleFormat, PluginDiagnosticCode, PluginFormat } from "./manifest-types.js";
import type { PluginManifestContracts } from "./manifest.js";
import type { PluginManifestContracts, PluginManifestDashboard } from "./manifest.js";
import { isPluginLifecycleTraceEnabled } from "./plugin-lifecycle-trace.js";
import type { PluginRecord, PluginRegistry } from "./registry.js";
import {
@@ -35,6 +35,7 @@ export function createPluginRecord(params: {
providerIds?: readonly string[];
configSchema: boolean;
contracts?: PluginManifestContracts;
dashboard?: PluginManifestDashboard;
}): PluginRecord {
return {
id: params.id,
@@ -89,6 +90,7 @@ export function createPluginRecord(params: {
configUiHints: undefined,
configJsonSchema: undefined,
contracts: params.contracts,
dashboard: params.dashboard,
};
}
+12
View File
@@ -6,6 +6,10 @@ import {
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "./config-state.js";
import {
PluginDashboardDeclarationError,
registerPluginDashboardCapabilities,
} from "./dashboard-capabilities.js";
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
import type { PluginCandidate } from "./discovery.js";
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
@@ -529,6 +533,11 @@ export function loadRuntimePluginCandidate(params: {
`${registrationPlan.mode}:register`,
() => runPluginRegisterSync(register, api),
);
// Dashboard entries stay inside the same registry snapshot as their RPC handlers.
// Non-activating snapshots are private until cached activation; rollback restores both.
if (registrationPlan.runRuntimeCapabilityPolicy) {
registerPluginDashboardCapabilities({ record, registry });
}
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
transaction.commit({ activate: context.shouldActivate });
@@ -550,6 +559,9 @@ export function loadRuntimePluginCandidate(params: {
error,
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
diagnosticMessagePrefix: "plugin failed during register: ",
...(error instanceof PluginDashboardDeclarationError
? { diagnosticCode: "dashboard-declaration-invalid" }
: {}),
});
registerFailed = true;
} finally {
+1
View File
@@ -310,6 +310,7 @@ export function createManifestPluginRecord(params: {
providerIds: manifestRecord.providers,
configSchema: Boolean(manifestRecord.configSchema),
contracts: manifestRecord.contracts,
dashboard: manifestRecord.dashboard,
});
}
+3
View File
@@ -43,6 +43,7 @@ import {
type PluginManifestChannelCommandDefaults,
type PluginManifestChannelConfig,
type PluginManifestContracts,
type PluginManifestDashboard,
type PluginManifestMediaUnderstandingProviderMetadata,
type PluginManifestModelCatalog,
type PluginManifestModelIdNormalization,
@@ -248,6 +249,7 @@ export type PluginManifestRecord = {
packageInstall?: PluginPackageInstall;
trustedOfficialInstall?: boolean;
qaRunners?: PluginManifestQaRunner[];
dashboard?: PluginManifestDashboard;
skills: string[];
settingsFiles?: string[];
hooks: string[];
@@ -598,6 +600,7 @@ function buildRecord(params: {
packageInstall: params.candidate.packageManifest?.install,
trustedOfficialInstall: params.trustedOfficialInstall === true ? true : undefined,
qaRunners: params.manifest.qaRunners,
dashboard: params.manifest.dashboard,
skills: params.manifest.skills ?? [],
settingsFiles: [],
hooks: [],
+4 -1
View File
@@ -18,7 +18,10 @@ export type PluginBundleFormat = "codex" | "claude" | "cursor";
* Closed classification codes for plugin diagnostics. Health surfaces branch
* on these instead of matching freeform diagnostic message text.
*/
export type PluginDiagnosticCode = "channel-setup-failure" | "plugin-verification";
export type PluginDiagnosticCode =
| "channel-setup-failure"
| "dashboard-declaration-invalid"
| "plugin-verification";
/** Diagnostic emitted while discovering or validating plugins. */
export type PluginDiagnostic = {
+116
View File
@@ -243,6 +243,29 @@ export type PluginManifestQaRunner = {
description?: string;
};
export type PluginManifestDashboardDataBinding = {
/** Plugin-local id. Widget grants receive the plugin-id prefix. */
id: string;
/** Read-scoped Gateway method registered by this plugin. */
method: string;
description: string;
};
export type PluginManifestDashboardActionVerb = {
/** Plugin-local id. Widget grants receive the plugin-id prefix. */
id: string;
/** Write-scoped Gateway method registered by this plugin. */
method: string;
description: string;
/** Optional JSON Schema for the action params object. */
paramShape?: JsonSchemaObject;
};
export type PluginManifestDashboard = {
dataBindings?: PluginManifestDashboardDataBinding[];
actionVerbs?: PluginManifestDashboardActionVerb[];
};
type PluginManifestConfigLiteral = string | number | boolean | null;
type PluginManifestDangerousConfigFlag = {
@@ -372,6 +395,8 @@ export type PluginManifest = {
setup?: PluginManifestSetup;
/** Cheap QA runner metadata exposed before plugin runtime loads. */
qaRunners?: PluginManifestQaRunner[];
/** Widget data and action capabilities validated against runtime registrations. */
dashboard?: PluginManifestDashboard;
skills?: string[];
name?: string;
description?: string;
@@ -1527,6 +1552,87 @@ function normalizeManifestQaRunners(value: unknown): PluginManifestQaRunner[] |
return normalized.length > 0 ? normalized : undefined;
}
type DashboardManifestResult =
| { ok: true; dashboard?: PluginManifestDashboard }
| { ok: false; error: string };
function normalizeDashboardCapabilityBase(
value: unknown,
field: string,
index: number,
): { id: string; method: string; description: string } | string {
if (!isRecord(value)) {
return `${field}[${index}] must be an object`;
}
const id = normalizeOptionalString(value.id);
const method = normalizeOptionalString(value.method);
const description = normalizeOptionalString(value.description);
if (!id || !/^[a-z0-9][a-z0-9._-]*$/u.test(id)) {
return `${field}[${index}].id must be a lowercase capability id`;
}
if (!method) {
return `${field}[${index}].method must be a non-empty string`;
}
if (!description) {
return `${field}[${index}].description must be a non-empty string`;
}
return { id, method, description };
}
function normalizeManifestDashboard(value: unknown): DashboardManifestResult {
if (value === undefined) {
return { ok: true };
}
if (!isRecord(value)) {
return { ok: false, error: "dashboard must be an object" };
}
if (value.dataBindings !== undefined && !Array.isArray(value.dataBindings)) {
return { ok: false, error: "dashboard.dataBindings must be an array" };
}
if (value.actionVerbs !== undefined && !Array.isArray(value.actionVerbs)) {
return { ok: false, error: "dashboard.actionVerbs must be an array" };
}
const dataBindings: PluginManifestDashboardDataBinding[] = [];
for (const [index, entry] of (value.dataBindings ?? []).entries()) {
const normalized = normalizeDashboardCapabilityBase(entry, "dashboard.dataBindings", index);
if (typeof normalized === "string") {
return { ok: false, error: normalized };
}
dataBindings.push(normalized);
}
const actionVerbs: PluginManifestDashboardActionVerb[] = [];
for (const [index, entry] of (value.actionVerbs ?? []).entries()) {
const normalized = normalizeDashboardCapabilityBase(entry, "dashboard.actionVerbs", index);
if (typeof normalized === "string") {
return { ok: false, error: normalized };
}
const rawParamShape = isRecord(entry) ? entry.paramShape : undefined;
if (rawParamShape !== undefined && !isRecord(rawParamShape)) {
return {
ok: false,
error: `dashboard.actionVerbs[${index}].paramShape must be a JSON Schema object`,
};
}
actionVerbs.push({
...normalized,
...(rawParamShape ? { paramShape: rawParamShape as JsonSchemaObject } : {}),
});
}
if (dataBindings.length === 0 && actionVerbs.length === 0) {
return { ok: true };
}
return {
ok: true,
dashboard: {
...(dataBindings.length > 0 ? { dataBindings } : {}),
...(actionVerbs.length > 0 ? { actionVerbs } : {}),
},
};
}
function normalizeManifestHttpsUrl(value: unknown): string | undefined {
const normalized = normalizeOptionalString(value);
if (!normalized) {
@@ -1872,6 +1978,15 @@ export function loadPluginManifest(
const activation = normalizeManifestActivation(raw.activation);
const setup = normalizeManifestSetup(raw.setup);
const qaRunners = normalizeManifestQaRunners(raw.qaRunners);
const dashboardResult = normalizeManifestDashboard(raw.dashboard);
if (!dashboardResult.ok) {
return cacheResult({
ok: false,
error: `invalid plugin manifest dashboard: ${dashboardResult.error}`,
manifestPath,
});
}
const dashboard = dashboardResult.dashboard;
const skills = normalizeTrimmedStringList(raw.skills);
const contracts = normalizeManifestContracts(raw.contracts);
const mediaUnderstandingProviderMetadata = normalizeMediaUnderstandingProviderMetadata(
@@ -1928,6 +2043,7 @@ export function loadPluginManifest(
activation,
setup,
qaRunners,
dashboard,
skills,
name,
description,
@@ -93,7 +93,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistry {
return [key, [...value]];
}
if (value instanceof Map) {
return [key, new Map(value)];
return [key, new Map(value as ReadonlyMap<unknown, unknown>)];
}
if (value && typeof value === "object") {
return [key, { ...value }];
+2
View File
@@ -33,6 +33,8 @@ export function createEmptyPluginRegistry(): PluginRegistry {
agentHarnesses: [],
gatewayHandlers: {},
gatewayMethodDescriptors: [],
dashboardDataBindings: new Map(),
dashboardActionVerbs: new Map(),
coreGatewayMethodNames: [],
httpRoutes: [],
hostedMediaResolvers: [],
+21 -1
View File
@@ -28,7 +28,12 @@ import type {
PluginDiagnostic,
PluginFormat,
} from "./manifest-types.js";
import type { PluginManifestContracts } from "./manifest.js";
import type {
PluginManifestContracts,
PluginManifestDashboard,
PluginManifestDashboardActionVerb,
PluginManifestDashboardDataBinding,
} from "./manifest.js";
import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js";
import type { PluginKind } from "./plugin-kind.types.js";
import type { PluginRuntime } from "./runtime/types.js";
@@ -172,6 +177,18 @@ type PluginSessionCatalogRegistration = {
rootDir?: string;
};
export type PluginDashboardDataBindingRegistration = PluginManifestDashboardDataBinding & {
pluginId: string;
capabilityId: string;
handler: GatewayRequestHandlers[string];
};
export type PluginDashboardActionVerbRegistration = PluginManifestDashboardActionVerb & {
pluginId: string;
capabilityId: string;
handler: GatewayRequestHandlers[string];
};
type PluginCliBackendRegistration = {
pluginId: string;
pluginName?: string;
@@ -449,6 +466,7 @@ export type PluginRecord = {
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: JsonSchemaObject;
contracts?: PluginManifestContracts;
dashboard?: PluginManifestDashboard;
memorySlotSelected?: boolean;
dependencyStatus?: PluginDependencyStatus;
};
@@ -484,6 +502,8 @@ export type PluginRegistry = {
agentHarnesses: PluginAgentHarnessRegistration[];
gatewayHandlers: GatewayRequestHandlers;
gatewayMethodDescriptors: GatewayMethodDescriptor[];
dashboardDataBindings: Map<string, PluginDashboardDataBindingRegistration>;
dashboardActionVerbs: Map<string, PluginDashboardActionVerbRegistration>;
coreGatewayMethodNames: string[];
httpRoutes: PluginHttpRouteRegistration[];
hostedMediaResolvers: PluginHostedMediaResolverRegistration[];
+21 -1
View File
@@ -5,20 +5,40 @@ describe("resolvePluginSurface", () => {
it("keeps manifest identifiers as inline code while leaving labels visible", () => {
expect(
resolvePluginSurface({
id: "example",
channels: ["discord"],
providers: ["openai"],
contracts: {
webSearchProviders: {},
tools: {},
},
dashboard: {
dataBindings: [{ id: "items.list" }],
actionVerbs: [{ id: "refresh" }],
},
skills: ["example"],
}),
).toBe(
"channels: `discord`; providers: `openai`; contracts: `tools`, `webSearchProviders`; skills",
"channels: `discord`; providers: `openai`; contracts: `tools`, `webSearchProviders`; dashboard data bindings: `example.items.list`; dashboard action verbs: `example.refresh`; skills",
);
});
it("retains the generic fallback", () => {
expect(resolvePluginSurface({})).toBe("plugin");
});
it("escapes dashboard plugin owner delimiters and literal escape markers", () => {
expect(
resolvePluginSurface({
id: "dashboard.segmented",
dashboard: { actionVerbs: [{ id: "refresh" }] },
}),
).toBe("dashboard action verbs: `dashboard%2Esegmented.refresh`");
expect(
resolvePluginSurface({
id: "dashboard%2Esegmented",
dashboard: { dataBindings: [{ id: "refresh" }] },
}),
).toBe("dashboard data bindings: `dashboard%252Esegmented.refresh`");
});
});