diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index acd9d283c17d..814092c95ac0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/docs_map.md b/docs/docs_map.md index 4b449ee17b3f..42d8bea4b434 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index f4b650e91e80..814ed93dc99c 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` | 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` | 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 `.`, 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. diff --git a/docs/plugins/reference/clickclack.md b/docs/plugins/reference/clickclack.md index e9a14df9e7f0..9c46f969526c 100644 --- a/docs/plugins/reference/clickclack.md +++ b/docs/plugins/reference/clickclack.md @@ -16,7 +16,9 @@ Adds the Clickclack channel surface for sending and receiving OpenClaw messages. ## Surface -channels: `clickclack` +channels: `clickclack`; contracts: `tools` + + 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. + + ## Related docs - [clickclack](/channels/clickclack) diff --git a/docs/plugins/reference/workboard.md b/docs/plugins/reference/workboard.md index 00e7962e3df9..a99431d78742 100644 --- a/docs/plugins/reference/workboard.md +++ b/docs/plugins/reference/workboard.md @@ -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 diff --git a/docs/web/dashboard-architecture.md b/docs/web/dashboard-architecture.md index 059cb57c485d..8e6f43ff339f 100644 --- a/docs/web/dashboard-architecture.md +++ b/docs/web/dashboard-architecture.md @@ -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 diff --git a/docs/web/dashboards.md b/docs/web/dashboards.md index 288b20016734..05f593359c3e 100644 --- a/docs/web/dashboards.md +++ b/docs/web/dashboards.md @@ -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. diff --git a/extensions/workboard/openclaw.plugin.json b/extensions/workboard/openclaw.plugin.json index 1378f878f88f..0a6ef293b002 100644 --- a/extensions/workboard/openclaw.plugin.json +++ b/extensions/workboard/openclaw.plugin.json @@ -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 }, diff --git a/packages/gateway-protocol/src/schema/board.test.ts b/packages/gateway-protocol/src/schema/board.test.ts index 9b4c587d14ea..98ab3af645be 100644 --- a/packages/gateway-protocol/src/schema/board.test.ts +++ b/packages/gateway-protocol/src/schema/board.test.ts @@ -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 = { diff --git a/packages/gateway-protocol/src/schema/board.ts b/packages/gateway-protocol/src/schema/board.ts index 0a5e061347ba..e5a062228007 100644 --- a/packages/gateway-protocol/src/schema/board.ts +++ b/packages/gateway-protocol/src/schema/board.ts @@ -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; -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; export const BoardChangedEventSchema = closedObject({ diff --git a/scripts/lib/plugin-inventory-doc.d.mts b/scripts/lib/plugin-inventory-doc.d.mts index 181cbb4c140d..12a7c86b811e 100644 --- a/scripts/lib/plugin-inventory-doc.d.mts +++ b/scripts/lib/plugin-inventory-doc.d.mts @@ -1,7 +1,12 @@ export type PluginSurfaceManifest = { + id?: string; channels?: string[]; providers?: string[]; contracts?: Record; + dashboard?: { + dataBindings?: Array<{ id?: string }>; + actionVerbs?: Array<{ id?: string }>; + }; skills?: unknown[]; }; diff --git a/scripts/lib/plugin-inventory-doc.mjs b/scripts/lib/plugin-inventory-doc.mjs index f42d72dc8c14..cb071921b589 100644 --- a/scripts/lib/plugin-inventory-doc.mjs +++ b/scripts/lib/plugin-inventory-doc.mjs @@ -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"); } diff --git a/src/boards/board-host-capability-ids.ts b/src/boards/board-host-capability-ids.ts new file mode 100644 index 000000000000..2619713bb5ad --- /dev/null +++ b/src/boards/board-host-capability-ids.ts @@ -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; diff --git a/src/gateway/board-host-tools.ts b/src/gateway/board-host-tools.ts index 4822efd506e7..050c0b1e94f3 100644 --- a/src/gateway/board-host-tools.ts +++ b/src/gateway/board-host-tools.ts @@ -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[0]; const BOARD_DATA_HANDLERS: Record = { @@ -31,7 +24,7 @@ const BOARD_DATA_HANDLERS: Record, invocation: GatewayHandlerInvocation, ): Promise { - 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, + invocation: GatewayHandlerInvocation, +): Promise { + 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( diff --git a/src/gateway/server-methods/board.plugin-capabilities.test.ts b/src/gateway/server-methods/board.plugin-capabilities.test.ts new file mode 100644 index 000000000000..2e1f5fc35e33 --- /dev/null +++ b/src/gateway/server-methods/board.plugin-capabilities.test.ts @@ -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(async ({ params, respond }) => { + respond(true, { items: [params.filter ?? "all"] }); + }); + const actionHandler = vi.fn(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(); + } + } + }); +}); diff --git a/src/gateway/server-methods/board.ts b/src/gateway/server-methods/board.ts index 29ce3f679981..2db39b90425d 100644 --- a/src/gateway/server-methods/board.ts +++ b/src/gateway/server-methods/board.ts @@ -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 & { readDataBinding?: BoardDataReader; + runActionVerb?: BoardActionVerbRunner; triggerCronJob?: BoardCronTrigger; }; @@ -94,6 +100,18 @@ function respondBoardError( respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(error))); } +function assertCapabilityParamsSize( + params: Record, + 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); } diff --git a/src/plugins/dashboard-capabilities.test.ts b/src/plugins/dashboard-capabilities.test.ts new file mode 100644 index 000000000000..09705850271b --- /dev/null +++ b/src/plugins/dashboard-capabilities.test.ts @@ -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): void { + const manifestPath = path.join(plugin.dir, "openclaw.plugin.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; + 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", + }); + }); +}); diff --git a/src/plugins/dashboard-capabilities.ts b/src/plugins/dashboard-capabilities.ts new file mode 100644 index 000000000000..2334ee6eb5fb --- /dev/null +++ b/src/plugins/dashboard-capabilities.ts @@ -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(); + 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); + } +} diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index 3495f3d324df..e1585672d833 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -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, }; } diff --git a/src/plugins/loader-runtime-candidate.ts b/src/plugins/loader-runtime-candidate.ts index bfe7218bc2ba..0bb159d247a9 100644 --- a/src/plugins/loader-runtime-candidate.ts +++ b/src/plugins/loader-runtime-candidate.ts @@ -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 { diff --git a/src/plugins/loader-shared.ts b/src/plugins/loader-shared.ts index d21ecd96799f..6771651deb07 100644 --- a/src/plugins/loader-shared.ts +++ b/src/plugins/loader-shared.ts @@ -310,6 +310,7 @@ export function createManifestPluginRecord(params: { providerIds: manifestRecord.providers, configSchema: Boolean(manifestRecord.configSchema), contracts: manifestRecord.contracts, + dashboard: manifestRecord.dashboard, }); } diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 30ccdb0d6181..5892eac22707 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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: [], diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 491d4c2e7846..3600f14ad420 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -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 = { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 66ab3bdbf34c..8c105a015e19 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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, diff --git a/src/plugins/plugin-registration-transaction.ts b/src/plugins/plugin-registration-transaction.ts index 5cac2082886f..d06b8db2dd9d 100644 --- a/src/plugins/plugin-registration-transaction.ts +++ b/src/plugins/plugin-registration-transaction.ts @@ -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)]; } if (value && typeof value === "object") { return [key, { ...value }]; diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 058f214eb04f..ddc5c3af2d87 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -33,6 +33,8 @@ export function createEmptyPluginRegistry(): PluginRegistry { agentHarnesses: [], gatewayHandlers: {}, gatewayMethodDescriptors: [], + dashboardDataBindings: new Map(), + dashboardActionVerbs: new Map(), coreGatewayMethodNames: [], httpRoutes: [], hostedMediaResolvers: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 9b981906de5b..6dcc09d5fa8e 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -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; 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; + dashboardActionVerbs: Map; coreGatewayMethodNames: string[]; httpRoutes: PluginHttpRouteRegistration[]; hostedMediaResolvers: PluginHostedMediaResolverRegistration[]; diff --git a/test/scripts/plugin-inventory-doc.test.ts b/test/scripts/plugin-inventory-doc.test.ts index cde815407557..4306f10519b8 100644 --- a/test/scripts/plugin-inventory-doc.test.ts +++ b/test/scripts/plugin-inventory-doc.test.ts @@ -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`"); + }); });