mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
b521a85d85
commit
be5e427f56
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -310,6 +310,7 @@ export function createManifestPluginRecord(params: {
|
||||
providerIds: manifestRecord.providers,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
contracts: manifestRecord.contracts,
|
||||
dashboard: manifestRecord.dashboard,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -33,6 +33,8 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodDescriptors: [],
|
||||
dashboardDataBindings: new Map(),
|
||||
dashboardActionVerbs: new Map(),
|
||||
coreGatewayMethodNames: [],
|
||||
httpRoutes: [],
|
||||
hostedMediaResolvers: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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`");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user