From 3ccf2a07393ebb09c0851f5675c28cdc0b0b84aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 12:29:50 +0100 Subject: [PATCH] feat: render inline web chat widgets via capability-gated show_widget tool (#101840) Adds client-capability-gated tool availability: gateway clients declare capabilities at connect (new inline-widgets cap), chat.send stamps them into the run context, and every tool assembly path (embedded runner, queued followups, Codex app-server harness, plugin-only construction plans) drops tools whose requiredClientCaps the originating client did not declare. The Canvas plugin ships the first such tool, show_widget: agents pass SVG or an HTML fragment plus a title; the plugin hosts it as a bounded, retention-scoped Canvas document and returns the existing canvas preview handle, which web chat renders as a sandboxed iframe fitted to the widget's reported content height. Widget frames never get allow-same-origin (per-preview sandbox ceiling, including the sidebar path) and the Canvas host serves widget documents with a CSP sandbox header so direct navigation runs in an opaque origin. Verified live end-to-end on a Testbox with gpt-5.5 (screenshots on the PR). CLI-backed model backends do not carry client caps yet and stay fail-closed (#102577). Closes #101790 --- docs/docs.json | 1 + docs/docs_map.md | 9 ++ docs/gateway/protocol.md | 9 ++ docs/tools/index.md | 1 + docs/tools/show-widget.md | 44 ++++++ docs/web/control-ui.md | 2 + extensions/canvas/index.ts | 37 ++++- extensions/canvas/openclaw.plugin.json | 2 +- extensions/canvas/src/documents.ts | 67 ++++++++- extensions/canvas/src/host/server.test.ts | 46 ++++++ extensions/canvas/src/host/server.ts | 32 ++++ extensions/canvas/src/tool-schema.ts | 9 ++ extensions/canvas/src/widget-tool.test.ts | 142 ++++++++++++++++++ extensions/canvas/src/widget-tool.ts | 114 ++++++++++++++ .../src/app-server/dynamic-tool-build.test.ts | 18 +++ .../src/app-server/dynamic-tool-build.ts | 3 + packages/gateway-protocol/src/client-info.ts | 1 + src/agents/agent-tools.cron-scope.test.ts | 16 +- src/agents/agent-tools.safe-bins.test.ts | 10 +- src/agents/agent-tools.ts | 13 +- src/agents/embedded-agent-runner/compact.ts | 1 + .../embedded-agent-runner/compact.types.ts | 4 +- .../compaction-runtime-context.ts | 3 + src/agents/embedded-agent-runner/run.ts | 3 + .../embedded-agent-runner/run/attempt.ts | 1 + .../embedded-agent-runner/run/params.ts | 2 + src/agents/openclaw-tools.client-caps.test.ts | 81 ++++++++++ src/agents/openclaw-tools.ts | 21 +++ .../test-helpers/fast-openclaw-tools.ts | 19 ++- src/agents/tools/common.ts | 4 + src/agents/tools/image-tool.test.ts | 4 +- src/auto-reply/reply/agent-runner-memory.ts | 1 + .../reply/agent-runner-run-params.ts | 1 + src/auto-reply/reply/commands-compact.ts | 1 + src/auto-reply/reply/followup-runner.test.ts | 33 ++++ src/auto-reply/reply/followup-runner.ts | 3 + src/auto-reply/reply/get-reply-run.ts | 1 + .../reply/queue/drain.client-caps.test.ts | 26 ++++ src/auto-reply/reply/queue/drain.ts | 1 + src/auto-reply/reply/queue/types.ts | 1 + src/auto-reply/templating.ts | 2 + src/chat/canvas-render.ts | 9 ++ .../chat.directive-tags.test.ts | 23 +++ src/gateway/server-methods/chat.ts | 1 + src/gateway/server-methods/tools-invoke.ts | 1 + src/gateway/server-restart-sentinel.test.ts | 2 + src/gateway/server-restart-sentinel.ts | 1 + src/gateway/tool-resolution.ts | 2 + src/gateway/tools-invoke-shared.ts | 2 + src/plugins/tool-descriptor-cache.test.ts | 18 +++ src/plugins/tool-descriptor-cache.ts | 4 + src/plugins/tools.optional.test.ts | 27 ++++ src/plugins/tools.ts | 19 ++- ui/src/api/gateway.node.test.ts | 2 + ui/src/api/gateway.ts | 3 +- ui/src/lib/chat/chat-types.ts | 1 + ui/src/lib/chat/message-normalizer.test.ts | 23 +++ ui/src/lib/chat/message-normalizer.ts | 3 + ui/src/lib/chat/tool-display.node.test.ts | 16 ++ ui/src/lib/chat/tool-display.ts | 11 +- ui/src/pages/chat/components/chat-sidebar.ts | 6 +- .../components/chat-tool-cards.node.test.ts | 24 ++- .../pages/chat/components/chat-tool-cards.ts | 83 +++++++++- 63 files changed, 1038 insertions(+), 32 deletions(-) create mode 100644 docs/tools/show-widget.md create mode 100644 extensions/canvas/src/widget-tool.test.ts create mode 100644 extensions/canvas/src/widget-tool.ts create mode 100644 src/agents/openclaw-tools.client-caps.test.ts create mode 100644 src/auto-reply/reply/queue/drain.client-caps.test.ts create mode 100644 ui/src/lib/chat/tool-display.node.test.ts diff --git a/docs/docs.json b/docs/docs.json index 6654d212c94..aa2cd2d2c67 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1330,6 +1330,7 @@ "tools/btw", "tools/code-execution", "tools/diffs", + "tools/show-widget", "tools/elevated", "tools/permission-modes", "tools/exec-approvals", diff --git a/docs/docs_map.md b/docs/docs_map.md index 1328a66cd31..f326ec2efb8 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3606,6 +3606,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Transport and framing - H2: Handshake + - H3: Client capabilities - H3: Node connect example - H2: Roles and scopes - H2: Presence @@ -9623,6 +9624,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Notes - H2: Related +## tools/show-widget.md + +- Route: /tools/show-widget +- Headings: + - H2: Use the tool + - H2: Security and storage + - H2: Related + ## tools/skill-workshop.md - Route: /tools/skill-workshop diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 28d650cc5a4..d36a3284417 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -167,6 +167,15 @@ stale CLI/device pairing baselines blocking local backend work. Remote, browser-origin, node, and explicit device-token/device-identity clients still go through normal pairing and scope-upgrade checks. +### Client capabilities + +Operator clients may advertise optional capabilities in `connect.params.caps`: + +- `tool-events`: accepts structured tool lifecycle events. +- `inline-widgets`: can render hosted inline widget tool results. + +Client capabilities describe the connected client, not authorization. Agent tools may declare required capabilities; the Gateway omits those tools unless every requirement appears in the originating client's `caps`. Channel-originated runs have no Gateway client capabilities, so capability-gated tools are unavailable even when tool policy explicitly allows them. + ### Node connect example ```json diff --git a/docs/tools/index.md b/docs/tools/index.md index cfa0b67b697..950b0f923df 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -109,6 +109,7 @@ for contract details. Common plugin-provided tools include: - [Diffs](/tools/diffs) for rendering file and markdown diffs +- [Show widget](/tools/show-widget) for self-contained inline SVG and HTML in web chat - [LLM Task](/tools/llm-task) for JSON-only workflow steps - [Lobster](/tools/lobster) for typed workflows with resumable approvals - [Tokenjuice](/tools/tokenjuice) for compacting noisy `exec` and `bash` tool diff --git a/docs/tools/show-widget.md b/docs/tools/show-widget.md new file mode 100644 index 00000000000..a0ee6fd33f4 --- /dev/null +++ b/docs/tools/show-widget.md @@ -0,0 +1,44 @@ +--- +summary: "Render self-contained SVG or HTML widgets inline in web chat" +title: "Show widget" +sidebarTitle: "Show widget" +read_when: + - You want an agent to render an interactive result inside web chat + - You need the show_widget input, security, or retention contract +--- + +`show_widget` renders a self-contained SVG or HTML fragment inline in the Control UI chat transcript. The bundled Canvas plugin owns the tool and hosts each result as a same-origin Canvas document. + +The tool is available only when the originating Gateway client declares the `inline-widgets` capability. The Control UI declares this capability automatically. Channel runs such as Telegram and WhatsApp do not receive `show_widget`. + +Capability transport currently covers the embedded runner and the Codex app-server backend. CLI-backed model backends do not yet carry client capabilities, so capability-gated tools stay unavailable (fail closed) on those backends. + +## Use the tool + +The agent supplies two required strings: + + + Short title shown with the inline preview and in the hosted document title. + + + + Self-contained SVG or HTML fragment. Input beginning with ` + +The tool result includes a Canvas preview handle, so web chat renders the widget directly from the tool call and restores it after history reload. Transcripts that do not render previews still show the hosted Canvas path. + +## Security and storage + +Widget documents use a restrictive Content Security Policy: inline style and script are allowed, images may use `data:` URLs, and external fetches and resource loads are blocked. Keep all markup, styles, scripts, and image data inside `widget_code`. + +The iframe always omits `allow-same-origin`, even when the Control UI's global embed mode is `trusted`, so widget scripts cannot read the parent application origin. The Canvas host also serves widget documents with a `Content-Security-Policy: sandbox allow-scripts` response header, so opening the hosted URL directly still runs the widget in an opaque origin instead of the Control UI origin. Browser sandboxing does not prevent a script from navigating its own iframe; only render widget code you are willing to execute in that isolated frame. + +The iframe also follows [`gateway.controlUi.embedSandbox`](/web/control-ui#hosted-embeds). The default `scripts` tier supports interactive widgets while preserving origin isolation. + +Canvas retains at most 32 widgets per session (or per agent when no session is available). Creating another widget removes the oldest document in that scope. + +## Related + +- [Control UI hosted embeds](/web/control-ui#hosted-embeds) +- [Canvas plugin](/plugins/reference/canvas) +- [Gateway protocol client capabilities](/gateway/protocol#client-capabilities) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 99f5705003b..aaade71d038 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -328,6 +328,8 @@ Web Push is independent of the iOS APNS relay path (see [Configuration](/gateway Assistant messages can render hosted web content inline with the `[embed ...]` shortcode. The iframe sandbox policy is controlled by `gateway.controlUi.embedSandbox`: +The bundled Canvas plugin also provides [`show_widget`](/tools/show-widget) to render self-contained SVG or HTML directly from a tool call. The browser advertises the `inline-widgets` Gateway capability, and the resulting Canvas document remains available when chat history reloads. Channel-originated runs do not receive this tool. + Disables script execution inside hosted embeds. diff --git a/extensions/canvas/index.ts b/extensions/canvas/index.ts index bcbbfe06fc4..6e085255b4c 100644 --- a/extensions/canvas/index.ts +++ b/extensions/canvas/index.ts @@ -9,7 +9,11 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; import { canvasConfigSchema, isCanvasHostEnabled } from "./src/config.js"; import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "./src/host/a2ui-shared.js"; -import { CanvasToolSchema } from "./src/tool-schema.js"; +import { + CanvasToolSchema, + SHOW_WIDGET_REQUIRED_CLIENT_CAPS, + ShowWidgetToolSchema, +} from "./src/tool-schema.js"; const CANVAS_NODE_COMMANDS = [ "canvas.present", @@ -45,6 +49,26 @@ function createLazyCanvasTool(params: { }; } +function createLazyShowWidgetTool(params: { + config?: OpenClawConfig; + sessionId?: string; + agentId?: string; +}): AnyAgentTool { + const loadTool = createLazyRuntimeModule(() => + import("./src/widget-tool.js").then(({ createShowWidgetTool }) => createShowWidgetTool(params)), + ); + return { + label: "Show Widget", + name: "show_widget", + description: + "Render self-contained SVG or HTML inline in web chat. Use for visual or interactive results; external resources are blocked, so inline all required code and data.", + parameters: ShowWidgetToolSchema, + requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS, + execute: async (...args: Parameters) => + await (await loadTool()).execute(...args), + }; +} + export default definePluginEntry({ id: "canvas", name: "Canvas", @@ -127,6 +151,17 @@ export default definePluginEntry({ workspaceDir: ctx.workspaceDir, }), ); + api.registerTool( + (ctx) => + isCanvasHostEnabled(ctx.runtimeConfig ?? ctx.config) + ? createLazyShowWidgetTool({ + config: ctx.runtimeConfig ?? ctx.config, + sessionId: ctx.sessionId, + agentId: ctx.agentId, + }) + : null, + { name: "show_widget" }, + ); api.registerNodeCliFeature( async ({ program }) => { const { createDefaultCanvasCliDependencies, registerNodesCanvasCommands } = diff --git a/extensions/canvas/openclaw.plugin.json b/extensions/canvas/openclaw.plugin.json index 72b3abe1b5a..40d095ce342 100644 --- a/extensions/canvas/openclaw.plugin.json +++ b/extensions/canvas/openclaw.plugin.json @@ -8,7 +8,7 @@ "description": "Experimental Canvas control and A2UI rendering surfaces for paired nodes.", "skills": ["./skills"], "contracts": { - "tools": ["canvas"] + "tools": ["canvas", "show_widget"] }, "configContracts": { "compatibilityMigrationPaths": ["canvasHost"] diff --git a/extensions/canvas/src/documents.ts b/extensions/canvas/src/documents.ts index ef396e616f5..7fbb48280f9 100644 --- a/extensions/canvas/src/documents.ts +++ b/extensions/canvas/src/documents.ts @@ -31,6 +31,9 @@ type CanvasDocumentCreateInput = { entrypoint?: CanvasDocumentEntrypoint; assets?: CanvasDocumentAsset[]; surface?: "assistant_message" | "tool_card" | "sidebar"; + retentionScope?: string; + /** Serve the document with a CSP sandbox header so direct opens get an opaque origin. */ + cspSandbox?: "scripts"; }; type CanvasDocumentManifest = { @@ -43,6 +46,8 @@ type CanvasDocumentManifest = { localEntrypoint?: string; externalUrl?: string; surface?: "assistant_message" | "tool_card" | "sidebar"; + retentionScope?: string; + cspSandbox?: "scripts"; assets: Array<{ logicalPath: string; contentType?: string; @@ -126,6 +131,51 @@ function resolveCanvasDocumentsDir(rootDir?: string, stateDir = resolveStateDir( return path.join(resolveCanvasRootDir(rootDir, stateDir), CANVAS_DOCUMENTS_DIR_NAME); } +async function pruneCanvasDocumentsForScope(params: { + documentsDir: string; + retentionScope: string; + maxDocuments: number; +}): Promise { + const entries = await fs.readdir(params.documentsDir, { withFileTypes: true }); + const scopedDocuments = ( + await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + try { + const manifest = JSON.parse( + await fs.readFile( + path.join(params.documentsDir, entry.name, "manifest.json"), + "utf8", + ), + ) as { createdAt?: unknown; retentionScope?: unknown }; + if ( + manifest.retentionScope !== params.retentionScope || + typeof manifest.createdAt !== "string" + ) { + return null; + } + return { id: entry.name, createdAt: manifest.createdAt }; + } catch { + return null; + } + }), + ) + ).filter((entry): entry is { id: string; createdAt: string } => entry !== null); + const deleteCount = Math.max(0, scopedDocuments.length - params.maxDocuments); + const oldest = scopedDocuments + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(0, deleteCount); + await Promise.all( + oldest.map((entry) => + fs.rm(path.join(params.documentsDir, entry.id), { recursive: true, force: true }), + ), + ); +} + /** Resolves the on-disk directory for one Canvas document id. */ export function resolveCanvasDocumentDir( documentId: string, @@ -299,7 +349,12 @@ async function materializeEntrypoint( /** Creates a Canvas document directory, copies assets, and writes its manifest. */ export async function createCanvasDocument( input: CanvasDocumentCreateInput, - options?: { stateDir?: string; workspaceDir?: string; canvasRootDir?: string }, + options?: { + stateDir?: string; + workspaceDir?: string; + canvasRootDir?: string; + maxDocumentsPerScope?: number; + }, ): Promise { const workspaceDir = options?.workspaceDir ?? process.cwd(); const id = input.id?.trim() ? normalizeCanvasDocumentId(input.id) : canvasDocumentId(); @@ -320,6 +375,8 @@ export async function createCanvasDocument( ? { preferredHeight: input.preferredHeight } : {}), ...(input.surface ? { surface: input.surface } : {}), + ...(input.retentionScope ? { retentionScope: input.retentionScope } : {}), + ...(input.cspSandbox ? { cspSandbox: input.cspSandbox } : {}), createdAt: new Date().toISOString(), entryUrl: entry.entryUrl, ...(entry.localEntrypoint ? { localEntrypoint: entry.localEntrypoint } : {}), @@ -327,6 +384,14 @@ export async function createCanvasDocument( assets, }; await writeManifest(root, manifest); + if (input.retentionScope && options?.maxDocumentsPerScope) { + // Bounded transcript widgets cannot grow managed Canvas storage without limit. + await pruneCanvasDocumentsForScope({ + documentsDir: resolveCanvasDocumentsDir(options.canvasRootDir, options.stateDir), + retentionScope: input.retentionScope, + maxDocuments: options.maxDocumentsPerScope, + }); + } return manifest; } diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index 4bbf5124340..3c9738e95bb 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -229,6 +229,52 @@ describe("canvas host", () => { } }); + it("serves sandbox-marked documents with a CSP sandbox header and no live reload", async () => { + const dir = await createCaseDir(); + const docDir = path.join(dir, "documents", "widget-1"); + await fs.mkdir(docDir, { recursive: true }); + await fs.writeFile( + path.join(docDir, "manifest.json"), + JSON.stringify({ id: "widget-1", cspSandbox: "scripts" }), + "utf8", + ); + await fs.writeFile(path.join(docDir, "index.html"), "widget", "utf8"); + const plainDir = path.join(dir, "documents", "plain-1"); + await fs.mkdir(plainDir, { recursive: true }); + await fs.writeFile( + path.join(plainDir, "manifest.json"), + JSON.stringify({ id: "plain-1" }), + "utf8", + ); + await fs.writeFile( + path.join(plainDir, "index.html"), + "plain", + "utf8", + ); + const handler = await createTestCanvasHostHandler(dir); + + try { + const widget = await captureHandlerResponse( + handler, + `${CANVAS_HOST_PATH}/documents/widget-1/index.html`, + ); + expect(widget.status).toBe(200); + // Opaque origin on direct navigation: widget script must not run as the app origin. + expect(widget.headers["content-security-policy"]).toBe("sandbox allow-scripts"); + expect(widget.body).toContain("widget"); + expect(widget.body).not.toContain(CANVAS_WS_PATH); + + const plain = await captureHandlerResponse( + handler, + `${CANVAS_HOST_PATH}/documents/plain-1/index.html`, + ); + expect(plain.status).toBe(200); + expect(plain.headers["content-security-policy"]).toBeUndefined(); + } finally { + await handler.close(); + } + }); + it("caps live reload WebSocket inbound payloads", async () => { const dir = await createCaseDir(); const constructorOptions: unknown[] = []; diff --git a/extensions/canvas/src/host/server.ts b/extensions/canvas/src/host/server.ts index 75789660e02..9e0f73b45b1 100644 --- a/extensions/canvas/src/host/server.ts +++ b/extensions/canvas/src/host/server.ts @@ -223,6 +223,28 @@ async function prepareCanvasRoot(rootDir: string) { return rootReal; } +/** Reads the owning document manifest to decide whether HTML gets a CSP sandbox header. */ +async function resolveDocumentCspSandbox( + rootReal: string, + realPath: string, +): Promise<"scripts" | undefined> { + const relative = path.relative(rootReal, realPath); + const segments = relative.split(path.sep); + if (segments[0] !== "documents" || segments.length < 3) { + return undefined; + } + try { + const manifestRaw = await fs.readFile( + path.join(rootReal, segments[0], segments[1] ?? "", "manifest.json"), + "utf8", + ); + const manifest = JSON.parse(manifestRaw) as { cspSandbox?: unknown }; + return manifest.cspSandbox === "scripts" ? "scripts" : undefined; + } catch { + return undefined; + } +} + function resolveDefaultCanvasRoot(): string { const candidates = [path.join(resolveStateDir(), "canvas")]; const existing = candidates.find((dir) => { @@ -430,6 +452,16 @@ export async function createCanvasHostHandler( if (mime === "text/html") { const html = data.toString("utf8"); res.setHeader("Content-Type", "text/html; charset=utf-8"); + // Sandbox-marked documents (agent-authored widgets) must get an opaque + // origin even when navigated to directly; the iframe sandbox attribute + // only protects embedded views. Skips live reload: its bridge script is + // useless without same-origin access. + const cspSandbox = await resolveDocumentCspSandbox(rootReal, realPath); + if (cspSandbox) { + res.setHeader("Content-Security-Policy", "sandbox allow-scripts"); + res.end(html); + return true; + } res.end(liveReload ? injectCanvasLiveReload(html) : html); return true; } diff --git a/extensions/canvas/src/tool-schema.ts b/extensions/canvas/src/tool-schema.ts index caacc7ab367..4937154f899 100644 --- a/extensions/canvas/src/tool-schema.ts +++ b/extensions/canvas/src/tool-schema.ts @@ -23,6 +23,15 @@ const CANVAS_ACTIONS = [ /** Snapshot formats accepted by the Canvas tool. */ const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; +/** Gateway capability required for inline transcript widgets. */ +export const SHOW_WIDGET_REQUIRED_CLIENT_CAPS = ["inline-widgets"]; + +/** TypeBox schema for inline web chat widgets. */ +export const ShowWidgetToolSchema = Type.Object({ + title: Type.String(), + widget_code: Type.String(), +}); + /** TypeBox schema for the model-facing Canvas tool arguments. */ export const CanvasToolSchema = Type.Object({ action: stringEnum(CANVAS_ACTIONS), diff --git a/extensions/canvas/src/widget-tool.test.ts b/extensions/canvas/src/widget-tool.test.ts new file mode 100644 index 00000000000..41d7adc67e0 --- /dev/null +++ b/extensions/canvas/src/widget-tool.test.ts @@ -0,0 +1,142 @@ +// Covers inline widget validation, materialization, preview extraction, and retention. +import { access, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveCanvasDocumentDir } from "./documents.js"; +import { + createShowWidgetTool, + WIDGET_CODE_MAX_CHARS, + WIDGET_MAX_PER_SCOPE, +} from "./widget-tool.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + vi.useRealTimers(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createStateDir(): Promise { + const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-widget-tool-")); + tempDirs.push(stateDir); + return stateDir; +} + +async function executeWidget(params: { + stateDir: string; + sessionId?: string; + title?: string; + widgetCode: string; +}) { + const tool = createShowWidgetTool({ + stateDir: params.stateDir, + sessionId: params.sessionId ?? "widget-session", + agentId: "main", + }); + const result = await tool.execute("widget-call", { + title: params.title ?? "Widget title", + widget_code: params.widgetCode, + }); + const text = result.content.find((item) => item.type === "text")?.text; + if (!text) { + throw new Error("expected widget tool text result"); + } + // Parse the canvas-handle JSON directly; the web chat's extraction of this + // shape is covered by ui/src/pages/chat/components/chat-tool-cards.node.test.ts. + const parsed = JSON.parse(text) as { + kind?: string; + presentation?: { target?: string; title?: string; sandbox?: string }; + view?: { id?: string; url?: string }; + }; + const viewId = parsed.view?.id; + const url = parsed.view?.url; + if (parsed.kind !== "canvas" || !viewId || !url) { + throw new Error("expected canvas preview handle"); + } + return { viewId, url, sandbox: parsed.presentation?.sandbox, text }; +} + +describe("show_widget", () => { + it("rejects empty and oversized widget code", async () => { + const stateDir = await createStateDir(); + const tool = createShowWidgetTool({ stateDir, sessionId: "validation" }); + + await expect(tool.execute("empty", { title: "Empty", widget_code: " " })).rejects.toThrow( + "widget_code required", + ); + await expect( + tool.execute("oversized", { + title: "Too large", + widget_code: "x".repeat(WIDGET_CODE_MAX_CHARS + 1), + }), + ).rejects.toThrow(`widget_code exceeds maximum size (${WIDGET_CODE_MAX_CHARS} characters)`); + }); + + it("wraps SVG widgets with the sandbox CSP and SVG layout", async () => { + const stateDir = await createStateDir(); + const { viewId, url, sandbox, text } = await executeWidget({ + stateDir, + title: "", + widgetCode: ' ', + }); + + expect(viewId).toMatch(/^cv_[a-f0-9]{32}$/); + expect(url).toBe(`/__openclaw__/canvas/documents/${viewId}/index.html`); + expect(JSON.parse(text)).toMatchObject({ + kind: "canvas", + presentation: { target: "assistant_message", title: "", sandbox: "scripts" }, + text: `Widget hosted at ${url}`, + }); + expect(sandbox).toBe("scripts"); + const html = await readFile( + path.join(resolveCanvasDocumentDir(viewId, { stateDir }), "index.html"), + "utf8", + ); + expect(html).toContain( + `Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;`, + ); + expect(html).toContain("<Status>"); + expect(html).toContain(' { + const stateDir = await createStateDir(); + const { viewId } = await executeWidget({ + stateDir, + widgetCode: "
", + }); + const html = await readFile( + path.join(resolveCanvasDocumentDir(viewId, { stateDir }), "index.html"), + "utf8", + ); + + expect(html).toContain("
"; + return ` +${escapeHtml(title)}${widgetCode}${sizeReporter}`; +} + +function resolveRetentionScope(options: ShowWidgetToolOptions): string { + const scope = options.sessionId + ? `session:${options.sessionId}` + : `agent:${options.agentId ?? "default"}`; + return createHash("sha256").update(scope).digest("hex"); +} + +/** Creates a self-contained widget hosted by the Canvas plugin. */ +export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAgentTool { + return { + label: "Show Widget", + name: "show_widget", + description: + "Render self-contained SVG or HTML inline in web chat. Use for visual or interactive results; external resources are blocked, so inline all required code and data.", + parameters: ShowWidgetToolSchema, + requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS, + execute: async (_toolCallId, args) => { + const params = args as Record; + const title = readStringParam(params, "title", { required: true }); + const rawWidgetCode = readStringParam(params, "widget_code", { + required: true, + trim: false, + }); + if (!rawWidgetCode.trim()) { + throw new WidgetToolInputError("widget_code required"); + } + if (rawWidgetCode.length > WIDGET_CODE_MAX_CHARS) { + throw new WidgetToolInputError( + `widget_code exceeds maximum size (${WIDGET_CODE_MAX_CHARS} characters)`, + ); + } + const widgetCode = rawWidgetCode.trim(); + const canvasRootDir = resolveCanvasHostConfig({ config: options.config }).root; + const document = await createCanvasDocument( + { + kind: "html_bundle", + title, + entrypoint: { type: "html", value: buildWidgetDocument(title, widgetCode) }, + surface: "assistant_message", + retentionScope: resolveRetentionScope(options), + // Direct navigation to the hosted URL must not run widget script as the + // Control UI origin; the host serves this doc with a CSP sandbox header. + cspSandbox: "scripts", + }, + { + stateDir: options.stateDir, + canvasRootDir, + maxDocumentsPerScope: WIDGET_MAX_PER_SCOPE, + }, + ); + return jsonResult({ + kind: "canvas", + presentation: { target: "assistant_message", title, sandbox: "scripts" }, + view: { id: document.id, url: document.entryUrl }, + text: `Widget hosted at ${document.entryUrl}`, + }); + }, + }; +} diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 9ecc349c22f..5a9f3ba57b8 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -274,6 +274,24 @@ describe("Codex app-server dynamic tool build", () => { expect(webSearchAllowed).toBe(true); }); + it("forwards the originating client caps into coding tool assembly", async () => { + // Regression: capability-gated tools (requiredClientCaps) vanished on the + // Codex app-server path because this harness dropped params.clientCaps. + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); + params.disableTools = false; + params.clientCaps = ["tool-events", "inline-widgets"]; + let receivedClientCaps: string[] | undefined; + setOpenClawCodingToolsFactoryForTests((options) => { + receivedClientCaps = (options as { clientCaps?: string[] }).clientCaps; + return [createRuntimeDynamicTool("message")]; + }); + + await buildDynamicToolsForTest(params, workspaceDir); + + expect(receivedClientCaps).toEqual(["tool-events", "inline-widgets"]); + }); + it("reports hosted search denied when effective tool policy removes web_search", async () => { const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 78fb214a3ec..39f8b3fa98e 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -242,6 +242,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { sandbox: input.sandbox, messageProvider: resolveCodexMessageToolProvider(params), toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel, + // Capability-gated tools (requiredClientCaps) need the originating client's + // declared caps in this sibling harness too, not only the embedded runner. + clientCaps: params.clientCaps, agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index 15e81c99595..a687aecfb7d 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -73,6 +73,7 @@ export type GatewayClientInfo = { /** Capability flags a client may advertise during the gateway handshake. */ export const GATEWAY_CLIENT_CAPS = { + INLINE_WIDGETS: "inline-widgets", TOOL_EVENTS: "tool-events", } as const; diff --git a/src/agents/agent-tools.cron-scope.test.ts b/src/agents/agent-tools.cron-scope.test.ts index 28465991454..3b1a0434d03 100644 --- a/src/agents/agent-tools.cron-scope.test.ts +++ b/src/agents/agent-tools.cron-scope.test.ts @@ -23,12 +23,16 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock("./openclaw-tools.js", () => ({ - createOpenClawTools: (options: unknown) => { - mocks.createOpenClawToolsOptions(options); - return [mocks.stubTool("cron")]; - }, -})); +vi.mock("./openclaw-tools.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + createOpenClawTools: (options: unknown) => { + mocks.createOpenClawToolsOptions(options); + return [mocks.stubTool("cron")]; + }, + filterToolsByClientCaps: actual.filterToolsByClientCaps, + }; +}); import "./test-helpers/fast-bash-tools.js"; import "./test-helpers/fast-coding-tools.js"; diff --git a/src/agents/agent-tools.safe-bins.test.ts b/src/agents/agent-tools.safe-bins.test.ts index 44c74186f6c..8dc3195343d 100644 --- a/src/agents/agent-tools.safe-bins.test.ts +++ b/src/agents/agent-tools.safe-bins.test.ts @@ -117,9 +117,13 @@ vi.mock("./channel-tools.js", () => ({ listChannelAgentTools: () => [], })); -vi.mock("./openclaw-tools.js", () => ({ - createOpenClawTools: () => [], -})); +vi.mock("./openclaw-tools.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + createOpenClawTools: () => [], + filterToolsByClientCaps: actual.filterToolsByClientCaps, + }; +}); vi.mock("./bash-tools.exec-host-shared.js", async () => { const mod = await vi.importActual( diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index edb36055784..cc5a9332258 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -71,7 +71,7 @@ import { } from "./local-model-lean.js"; import type { ModelAuthMode } from "./model-auth.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; +import { createOpenClawTools, filterToolsByClientCaps } from "./openclaw-tools.js"; import type { SandboxContext } from "./sandbox.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js"; import { resolveReadOnlyWorkspaceSkillMounts } from "./sandbox/workspace-mounts.js"; @@ -388,6 +388,8 @@ export function createOpenClawCodingTools(options?: { messageProvider?: string; /** Canonical transport channel when tool-policy provider differs from delivery channel. */ messageChannel?: string; + /** Capabilities declared by the gateway client that originated this run. */ + clientCaps?: string[]; /** Normalized conversation kind when the caller already has channel metadata. */ chatType?: ChatType; /** Specific ingress provider used only for transport tool availability. */ @@ -903,7 +905,9 @@ export function createOpenClawCodingTools(options?: { const shouldCaptureCronCreatorToolAllowlist = toolPolicyInheritanceSources.some( (policy) => hasRestrictiveAllowPolicy(policy) || hasExplicitDenyPolicy(policy), ); - const pluginToolsOnly = + // Plugin-only plans bypass createOpenClawTools, so the capability gate must + // apply here too or narrow allowlists leak gated tools onto capless surfaces. + const pluginToolsOnly = filterToolsByClientCaps( includeOpenClawTools || !includePluginTools ? [] : resolveOpenClawPluginToolsForOptions({ @@ -940,7 +944,9 @@ export function createOpenClawCodingTools(options?: { authProfileStore: options?.authProfileStore, }, resolvedConfig: options?.config, - }); + }), + options?.clientCaps, + ); const toolSearchTools = toolSearchControlsEnabled ? createToolSearchTools({ config: options?.config, @@ -1015,6 +1021,7 @@ export function createOpenClawCodingTools(options?: { : runtimeRoot, sandboxed: Boolean(sandbox), config: options?.config, + clientCaps: options?.clientCaps, pluginToolAllowlist, pluginToolDenylist, cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index d8629356e07..9c19c7c9b94 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -935,6 +935,7 @@ async function compactEmbeddedAgentSessionDirectOnce( }, sandbox, messageProvider: resolvedMessageProvider, + clientCaps: params.clientCaps, chatType: params.chatType, agentAccountId: params.agentAccountId, sessionKey: sandboxSessionKey, diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index 6a9a3c17134..eee8c04ad7d 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -1,3 +1,4 @@ +import type { Model } from "openclaw/plugin-sdk/llm"; /** * Shared parameter and metric types for embedded-agent compaction. */ @@ -6,7 +7,6 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js"; -import type { Model } from "openclaw/plugin-sdk/llm"; import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js"; import type { SkillSnapshot } from "../../skills/types.js"; import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js"; @@ -25,6 +25,8 @@ export type CompactEmbeddedAgentSessionParams = { sandboxSessionKey?: string; messageChannel?: string; messageProvider?: string; + /** Capabilities declared by the gateway client that originated this run. */ + clientCaps?: string[]; chatType?: ChatType; agentAccountId?: string; currentChannelId?: string; diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index d71c353a5fc..3bf00d90a0d 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -27,6 +27,7 @@ type EmbeddedCompactionRuntimeContext = { sessionKey?: string; messageChannel?: string; messageProvider?: string; + clientCaps?: string[]; chatType?: ChatType; agentAccountId?: string; currentChannelId?: string; @@ -243,6 +244,7 @@ export function buildEmbeddedCompactionRuntimeContext(params: { sessionKey?: string | null; messageChannel?: string | null; messageProvider?: string | null; + clientCaps?: string[]; chatType?: ChatType | null; agentAccountId?: string | null; currentChannelId?: string | null; @@ -286,6 +288,7 @@ export function buildEmbeddedCompactionRuntimeContext(params: { sessionKey: params.sessionKey ?? undefined, messageChannel: params.messageChannel ?? undefined, messageProvider: params.messageProvider ?? undefined, + clientCaps: params.clientCaps, chatType: params.chatType ?? undefined, agentAccountId: params.agentAccountId ?? undefined, currentChannelId: params.currentChannelId ?? undefined, diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index d755290d6f1..8a03e1e4c39 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -2128,6 +2128,7 @@ async function runEmbeddedAgentInternal( memoryFlushWritePath: params.memoryFlushWritePath, messageChannel: params.messageChannel, messageProvider: params.messageProvider, + clientCaps: params.clientCaps, chatType: params.chatType, agentAccountId: params.agentAccountId, messageTo: params.messageTo, @@ -2557,6 +2558,7 @@ async function runEmbeddedAgentInternal( sessionKey: params.sessionKey, messageChannel: params.messageChannel, messageProvider: params.messageProvider, + clientCaps: params.clientCaps, chatType: params.chatType, agentAccountId: params.agentAccountId, currentChannelId: params.currentChannelId, @@ -2766,6 +2768,7 @@ async function runEmbeddedAgentInternal( sessionKey: params.sessionKey, messageChannel: params.messageChannel, messageProvider: params.messageProvider, + clientCaps: params.clientCaps, chatType: params.chatType, agentAccountId: params.agentAccountId, currentChannelId: params.currentChannelId, diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 6dba2e3ea0c..eb209e07c55 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1342,6 +1342,7 @@ export async function runEmbeddedAttempt( ...(params.crestodianTool ? { crestodianTool: params.crestodianTool } : {}), ...buildEmbeddedAttemptToolRunContext({ ...params, trace: runTrace }), messageChannel: params.messageChannel, + clientCaps: params.clientCaps, chatType: params.chatType, exec: { ...params.execOverrides, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index a2ca6aa9202..0c0487ccffd 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -71,6 +71,8 @@ export type RunEmbeddedAgentParams = { agentId?: string; messageChannel?: string; messageProvider?: string; + /** Capabilities declared by the gateway client that originated this run. */ + clientCaps?: string[]; chatType?: ChatType; agentAccountId?: string; /** What initiated this agent run: "user", "heartbeat", "cron", "memory", "overflow", or "manual". */ diff --git a/src/agents/openclaw-tools.client-caps.test.ts b/src/agents/openclaw-tools.client-caps.test.ts new file mode 100644 index 00000000000..5e61118651a --- /dev/null +++ b/src/agents/openclaw-tools.client-caps.test.ts @@ -0,0 +1,81 @@ +// Verifies gateway client capabilities are hard availability requirements for tools. +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./openclaw-plugin-tools.js", () => ({ + resolveOpenClawPluginToolsForOptions: () => [ + { + name: "show_widget", + label: "Show widget", + description: "Test capability-gated tool", + parameters: { type: "object", properties: {} }, + requiredClientCaps: ["inline-widgets"], + execute: async () => ({ content: [], details: {} }), + }, + ], +})); + +import { createOpenClawCodingTools } from "./agent-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +function hasWidget(tools: readonly { name: string }[]): boolean { + return tools.some((tool) => tool.name === "show_widget"); +} + +describe("gateway client capability tool filtering", () => { + it("excludes capability-gated tools when no gateway client caps exist", () => { + expect(hasWidget(createOpenClawTools())).toBe(false); + }); + + it("excludes capability-gated tools when a required cap is absent", () => { + expect(hasWidget(createOpenClawTools({ clientCaps: ["tool-events"] }))).toBe(false); + }); + + it("includes capability-gated tools when the client caps are a superset", () => { + expect(hasWidget(createOpenClawTools({ clientCaps: ["tool-events", "inline-widgets"] }))).toBe( + true, + ); + }); + + it("does not let tools.allow resurrect a gated tool for a channel run", () => { + const tools = createOpenClawCodingTools({ + messageProvider: "telegram", + config: { tools: { allow: ["show_widget"] } }, + toolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: true, + includePluginTools: true, + }, + }); + + expect(hasWidget(tools)).toBe(false); + }); + + it("filters gated tools on plugin-only construction plans", () => { + // Regression: plugin-only plans bypass createOpenClawTools, which used to + // skip the capability gate entirely for narrow allowlists. + const plan = { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: true, + }; + + expect( + hasWidget( + createOpenClawCodingTools({ messageProvider: "telegram", toolConstructionPlan: plan }), + ), + ).toBe(false); + expect( + hasWidget( + createOpenClawCodingTools({ + messageProvider: "webchat", + clientCaps: ["inline-widgets"], + toolConstructionPlan: plan, + }), + ), + ).toBe(true); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 72dbcf1b98c..3a63417dde7 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -85,6 +85,22 @@ const defaultOpenClawToolsDeps: OpenClawToolsDeps = { let openClawToolsDeps: OpenClawToolsDeps = defaultOpenClawToolsDeps; +/** + * Drops tools whose requiredClientCaps the originating gateway client did not + * declare. Capability availability is a hard fact, not policy: every tool + * assembly path (core, plugin-only plans) must apply it or gated tools leak + * onto surfaces that cannot render them. + */ +export function filterToolsByClientCaps( + tools: AnyAgentTool[], + declaredClientCaps: string[] | undefined, +): AnyAgentTool[] { + const clientCaps = new Set(declaredClientCaps ?? []); + return tools.filter( + (tool) => !tool.requiredClientCaps?.some((requiredCap) => !clientCaps.has(requiredCap)), + ); +} + export function createOpenClawTools( options?: { sandboxBrowserBridgeUrl?: string; @@ -110,6 +126,8 @@ export function createOpenClawTools( fsPolicy?: ToolFsPolicy; sandboxed?: boolean; config?: OpenClawConfig; + /** Capabilities declared by the gateway client that originated this run. */ + clientCaps?: string[]; pluginToolAllowlist?: string[]; pluginToolDenylist?: string[]; /** Effective caller tool surface to persist on isolated cron agentTurn jobs. */ @@ -603,6 +621,9 @@ export function createOpenClawTools( options?.recordToolPrepStage?.("openclaw-tools:plugin-tools"); } + allTools = filterToolsByClientCaps(allTools, options?.clientCaps); + options?.recordToolPrepStage?.("openclaw-tools:client-capabilities"); + const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId; const gatewayCallerIdentity = hookAgentId && options?.agentSessionKey?.trim() diff --git a/src/agents/test-helpers/fast-openclaw-tools.ts b/src/agents/test-helpers/fast-openclaw-tools.ts index fae438be6a2..68c814924c2 100644 --- a/src/agents/test-helpers/fast-openclaw-tools.ts +++ b/src/agents/test-helpers/fast-openclaw-tools.ts @@ -63,10 +63,15 @@ const createOpenClawToolsMock = vi.fn( ); // Preserve action enums for tools whose tests assert schema/inventory behavior without paying the -// cost of constructing the real tool bundle. -vi.mock("../openclaw-tools.js", () => ({ - createOpenClawTools: createOpenClawToolsMock, - testing: { - setDepsForTest: () => {}, - }, -})); +// cost of constructing the real tool bundle. The real capability filter stays +// in place so client-caps gating behaves like production in these suites. +vi.mock("../openclaw-tools.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + createOpenClawTools: createOpenClawToolsMock, + filterToolsByClientCaps: actual.filterToolsByClientCaps, + testing: { + setDepsForTest: () => {}, + }, + }; +}); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index a754c62f0f3..6b71ef5e762 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -30,6 +30,8 @@ export type AgentToolWithMeta = AgentTool< TResult > & { displaySummary?: string; + /** Gateway client capabilities required before this tool can be assembled. */ + requiredClientCaps?: string[]; prepareBeforeToolCallParams?: ( params: unknown, ctx: { toolCallId?: string; hookContext?: unknown; signal?: AbortSignal }, @@ -50,6 +52,8 @@ type ErasedAgentToolExecute = { export type AnyAgentTool = Omit & ErasedAgentToolExecute & { displaySummary?: string; + /** Gateway client capabilities required before this tool can be assembled. */ + requiredClientCaps?: string[]; prepareBeforeToolCallParams?: AgentToolWithMeta< TSchema, unknown diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 1dff7bb7e44..17ecf43b269 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -314,9 +314,11 @@ vi.mock("../model-auth.js", () => ({ }, })); -vi.mock("../openclaw-tools.js", async () => { +vi.mock("../openclaw-tools.js", async (importOriginal) => { + const actual = await importOriginal(); const { createImageTool: createImageToolLocal } = await import("./image-tool.js"); return { + filterToolsByClientCaps: actual.filterToolsByClientCaps, createOpenClawTools: vi.fn((options?: MockOpenClawToolsOptions) => { const imageTool = createImageToolLocal({ config: options?.config, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index acc58527e6b..d824fe14eef 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -957,6 +957,7 @@ export async function runPreflightCompactionIfNeeded(params: { sandboxSessionKey: params.runtimePolicySessionKey, allowGatewaySubagentBinding: true, messageChannel: params.followupRun.run.messageProvider, + clientCaps: params.followupRun.run.clientCaps, groupId: entry.groupId ?? params.followupRun.run.groupId, groupChannel: entry.groupChannel ?? params.followupRun.run.groupChannel, groupSpace: entry.space ?? params.followupRun.run.groupSpace, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 632344fddd3..7fb19cd47a9 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -101,6 +101,7 @@ export function buildEmbeddedRunBaseParams(params: { allowEmptyAssistantReplyAsSilent: params.run.allowEmptyAssistantReplyAsSilent, silentReplyPromptMode: params.run.silentReplyPromptMode, sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode, + clientCaps: params.run.clientCaps, provider: params.provider, model: params.model, modelFallbacksOverride, diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index b223aed68bf..6c4242180cf 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -238,6 +238,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { sessionKey: params.sessionKey, allowGatewaySubagentBinding: true, messageChannel: params.command.channel, + clientCaps: params.ctx.GatewayClientCaps, groupId: targetSessionEntry.groupId, groupChannel: targetSessionEntry.groupChannel, groupSpace: targetSessionEntry.space, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 571597e9b07..bc5f47487a6 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -689,6 +689,39 @@ describe("createFollowupRunner reply-lane admission", () => { expect(context.text).not.toContain("Active goal:"); }); + it("keeps the originating client caps on queued embedded runs", async () => { + // Regression: the queued path built runEmbeddedAgent params inline and + // dropped run.clientCaps, so capability-gated tools vanished after drain. + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); + const storePath = "/tmp/openclaw-followup-client-caps.json"; + const sessionEntry: SessionEntry = { sessionId: "session-client-caps", updatedAt: 1 }; + registerFollowupTestSessionStore(storePath, { main: sessionEntry }); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionEntry, + sessionStore: { main: sessionEntry }, + sessionKey: "main", + storePath, + defaultModel: "anthropic/claude", + }); + + await runner( + createQueuedRun({ + run: { + sessionId: "session-client-caps", + sessionKey: "main", + provider: "anthropic", + model: "claude", + clientCaps: ["tool-events", "inline-widgets"], + }, + }), + ); + + const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); + expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]); + }); + it("notifies queued owners after admission and before model execution", async () => { const events: string[] = []; runEmbeddedAgentMock.mockImplementationOnce(async () => { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 57755767ee6..b51688ccb3c 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1263,6 +1263,9 @@ export function createFollowupRunner(params: { trigger: "user", messageChannel: queued.originatingChannel ?? undefined, messageProvider: run.messageProvider, + // Queued turns must keep the originating client's declared caps or + // capability-gated tools vanish between the live turn and its drain. + clientCaps: run.clientCaps, chatType: run.chatType, agentAccountId: run.agentAccountId, messageTo: queued.originatingTo, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index dbac85fbb78..9e143f23d4c 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1479,6 +1479,7 @@ export async function runPreparedReply( sessionKey, runtimePolicySessionKey, messageProvider, + clientCaps: ctx.GatewayClientCaps, chatType: replyRoute.chatType, agentAccountId: replyRoute.accountId, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, diff --git a/src/auto-reply/reply/queue/drain.client-caps.test.ts b/src/auto-reply/reply/queue/drain.client-caps.test.ts new file mode 100644 index 00000000000..fcaf4f66aaa --- /dev/null +++ b/src/auto-reply/reply/queue/drain.client-caps.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createQueueTestRun } from "../queue.test-helpers.js"; +import { resolveFollowupDeliveryContextKey } from "./drain.js"; + +describe("followup delivery context client capabilities", () => { + it("separates runs with different gateway client capabilities", () => { + const withoutCaps = createQueueTestRun({ prompt: "without caps" }); + const withInlineWidgets = createQueueTestRun({ prompt: "with inline widgets" }); + withInlineWidgets.run.clientCaps = ["inline-widgets"]; + + expect(resolveFollowupDeliveryContextKey(withoutCaps)).not.toBe( + resolveFollowupDeliveryContextKey(withInlineWidgets), + ); + }); + + it("normalizes capability order and duplicates", () => { + const first = createQueueTestRun({ prompt: "first" }); + first.run.clientCaps = ["tool-events", "inline-widgets"]; + const second = createQueueTestRun({ prompt: "second" }); + second.run.clientCaps = ["inline-widgets", "tool-events", "inline-widgets"]; + + expect(resolveFollowupDeliveryContextKey(first)).toBe( + resolveFollowupDeliveryContextKey(second), + ); + }); +}); diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index dfb0f2bb1e7..c6bc7d974ab 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -145,6 +145,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string { run.queuedLifecycle?.ownerKey ?? "", normalizeOptionalString(execution.runtimePolicySessionKey ?? execution.sessionKey) ?? "", execution.messageProvider ?? "", + JSON.stringify([...new Set(execution.clientCaps ?? [])].toSorted()), execution.chatType ?? "", execution.agentAccountId ?? "", execution.groupId ?? "", diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 51186a64d55..68ffafcb512 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -104,6 +104,7 @@ export type FollowupRun = { sessionKey?: string; runtimePolicySessionKey?: string; messageProvider?: string; + clientCaps?: string[]; chatType?: ChatType; agentAccountId?: string; groupId?: string; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index fe392ec30c9..931a7512cdd 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -300,6 +300,8 @@ export type MsgContext = { AcpDispatchTailAfterReset?: boolean; /** Gateway client scopes when the message originates from the gateway. */ GatewayClientScopes?: string[]; + /** Gateway client capabilities when the message originates from the gateway. */ + GatewayClientCaps?: string[]; /** Gateway device id allowed to review approvals initiated by this turn. */ ApprovalReviewerDeviceId?: string; /** Thread identifier (Telegram topic id or Matrix thread event id). */ diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index 1c3fca50951..04915dd3623 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -7,6 +7,7 @@ import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js"; // Extracts assistant-message canvas previews from tool JSON or markdown embed // shortcodes. The returned text strips consumed shortcodes for channel delivery. type CanvasSurface = "assistant_message"; +type CanvasSandbox = "strict" | "scripts"; type CanvasPreview = { kind: "canvas"; @@ -18,6 +19,7 @@ type CanvasPreview = { viewId?: string; className?: string; style?: string; + sandbox?: CanvasSandbox; }; function getRecordStringField( @@ -48,6 +50,10 @@ function normalizeSurface(value: string | undefined): CanvasSurface | undefined return value === "assistant_message" ? value : undefined; } +function normalizeSandbox(value: string | undefined): CanvasSandbox | undefined { + return value === "strict" || value === "scripts" ? value : undefined; +} + function normalizePreferredHeight(value: number | undefined): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 160 ? Math.min(Math.trunc(value), 1200) @@ -84,6 +90,7 @@ function coerceCanvasPreview( getRecordStringField(presentation, "class_name") ?? getRecordStringField(presentation, "className"); const style = getRecordStringField(presentation, "style"); + const sandbox = normalizeSandbox(getRecordStringField(presentation, "sandbox")); const viewUrl = getRecordStringField(view, "url") ?? getRecordStringField(view, "entryUrl"); const viewId = getRecordStringField(view, "id") ?? getRecordStringField(view, "docId"); if (viewUrl) { @@ -97,6 +104,7 @@ function coerceCanvasPreview( ...(preferredHeight ? { preferredHeight } : {}), ...(className ? { className } : {}), ...(style ? { style } : {}), + ...(sandbox ? { sandbox } : {}), }; } const sourceType = getRecordStringField(source, "type")?.trim().toLowerCase(); @@ -114,6 +122,7 @@ function coerceCanvasPreview( ...(preferredHeight ? { preferredHeight } : {}), ...(className ? { className } : {}), ...(style ? { style } : {}), + ...(sandbox ? { sandbox } : {}), }; } return undefined; diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 9463f006a30..ce376c6564e 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -660,11 +660,13 @@ function createScopedCliClient( displayName: string; version: string; }> = {}, + caps?: string[], ) { const id = client.id ?? "openclaw-cli"; return { connect: { scopes, + caps, client: { id, mode: client.mode ?? "cli", @@ -4461,6 +4463,26 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(mockState.lastDispatchCtx?.CommandBody).toBe("/scopecheck"); }); + it("forwards gateway client capabilities into the dispatch context", async () => { + await createTranscriptFixture("openclaw-chat-send-gateway-client-caps-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-gateway-client-caps", + message: "show a widget", + client: createScopedCliClient([], {}, [GATEWAY_CLIENT_CAPS.INLINE_WIDGETS]), + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx?.GatewayClientCaps).toEqual([ + GATEWAY_CLIENT_CAPS.INLINE_WIDGETS, + ]); + }); + it("normalizes missing gateway caller scopes to an empty array before dispatch", async () => { await createTranscriptFixture("openclaw-chat-send-missing-gateway-client-scopes-"); mockState.finalText = "ok"; @@ -4477,6 +4499,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); expect(mockState.lastDispatchCtx?.GatewayClientScopes).toStrictEqual([]); + expect(mockState.lastDispatchCtx?.GatewayClientCaps).toStrictEqual([]); expect(mockState.lastDispatchCtx?.CommandBody).toBe("/scopecheck"); }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 22d6c3eb47c..5454492b6f4 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -4542,6 +4542,7 @@ export const chatHandlers: GatewayRequestHandlers = { } : {}), GatewayClientScopes: client?.connect?.scopes ?? [], + GatewayClientCaps: client?.connect?.caps ?? [], }; const isInternalTextSlashCommandTurn = ctx.Provider === INTERNAL_MESSAGE_CHANNEL && ctx.CommandSource === "text"; diff --git a/src/gateway/server-methods/tools-invoke.ts b/src/gateway/server-methods/tools-invoke.ts index 7926300b86d..a5f30f1f590 100644 --- a/src/gateway/server-methods/tools-invoke.ts +++ b/src/gateway/server-methods/tools-invoke.ts @@ -62,6 +62,7 @@ export const toolsInvokeHandlers: GatewayRequestHandlers = { cfg: context.getRuntimeConfig(), input: params, senderIsOwner: client?.connect?.scopes?.includes("operator.admin"), + clientCaps: client?.connect?.caps, toolCallIdPrefix: "rpc", approvalMode: params.confirm === true ? "request" : "report", }); diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 7b16e01709c..9d569be0f01 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -629,6 +629,7 @@ describe("scheduleRestartSentinelWake", () => { CommandBody: "", CommandAuthorized: true, GatewayClientScopes: ["operator.admin"], + GatewayClientCaps: [], InputProvenance: { kind: "internal_system", sourceChannel: "whatsapp", @@ -931,6 +932,7 @@ describe("scheduleRestartSentinelWake", () => { Body: "continue in topic", CommandAuthorized: true, GatewayClientScopes: ["operator.admin"], + GatewayClientCaps: [], InputProvenance: { kind: "internal_system", sourceChannel: "telegram", diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index b207b2fc0bf..46e8975b7ed 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -301,6 +301,7 @@ async function deliverQueuedSessionDelivery(params: { ChatType: route.chatType, CommandAuthorized: true, GatewayClientScopes: ["operator.admin"], + GatewayClientCaps: [], ReplyToId: route.replyToId, OriginatingChannel: route.channel, OriginatingTo: route.to, diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index e57a7f35ebe..76ac636f2a7 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -52,6 +52,7 @@ export function resolveGatewayScopedTools(params: { currentThreadTs?: string; currentMessageId?: string | number; currentInboundAudio?: boolean; + clientCaps?: string[]; accountId?: string; inboundEventKind?: InboundEventKind; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; @@ -190,6 +191,7 @@ export function resolveGatewayScopedTools(params: { disablePluginTools: params.disablePluginTools, wrapBeforeToolCallHook: false, config: params.cfg, + clientCaps: params.clientCaps, workspaceDir, pluginToolAllowlist: collectExplicitAllowlist([ profilePolicy, diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index 541b7fa61b4..c5083dbd749 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -155,6 +155,7 @@ export async function invokeGatewayTool(params: { agentTo?: string; agentThreadId?: string; senderIsOwner?: boolean; + clientCaps?: string[]; toolCallIdPrefix: string; approvalMode?: "request" | "report"; }): Promise { @@ -205,6 +206,7 @@ export async function invokeGatewayTool(params: { agentTo: params.agentTo, agentThreadId: params.agentThreadId, senderIsOwner: params.senderIsOwner, + clientCaps: params.clientCaps, allowGatewaySubagentBinding: true, allowMediaInvokeCommands: true, surface: "http", diff --git a/src/plugins/tool-descriptor-cache.test.ts b/src/plugins/tool-descriptor-cache.test.ts index af740d70358..cb8f6b42552 100644 --- a/src/plugins/tool-descriptor-cache.test.ts +++ b/src/plugins/tool-descriptor-cache.test.ts @@ -17,6 +17,7 @@ vi.mock("../config/runtime-snapshot.js", () => ({ import { buildPluginToolDescriptorCacheKey, + capturePluginToolDescriptor, createPluginToolDescriptorConfigCacheKeyMemo, resetPluginToolDescriptorCache, } from "./tool-descriptor-cache.js"; @@ -60,6 +61,23 @@ describe("plugin tool descriptor cache keys", () => { expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(1); }); + it("preserves required gateway client capabilities in cached descriptors", () => { + const cached = capturePluginToolDescriptor({ + pluginId: "demo", + optional: false, + tool: { + name: "inline_demo", + label: "Inline demo", + description: "Render a demo", + parameters: { type: "object", properties: {} }, + requiredClientCaps: ["inline-widgets"], + execute: async () => ({ content: [], details: {} }), + }, + }); + + expect(cached.requiredClientCaps).toEqual(["inline-widgets"]); + }); + it("keeps distinct config objects distinct within the memo", () => { const firstConfig = { id: "first" } as never; const secondConfig = { id: "second" } as never; diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts index 864bac702f9..4d2ced0530d 100644 --- a/src/plugins/tool-descriptor-cache.ts +++ b/src/plugins/tool-descriptor-cache.ts @@ -13,6 +13,7 @@ const PLUGIN_TOOL_DESCRIPTOR_CACHE_LIMIT = 256; export type CachedPluginToolDescriptor = { descriptor: ToolDescriptor; displaySummary?: string; + requiredClientCaps?: string[]; optional: boolean; }; @@ -154,6 +155,9 @@ export function capturePluginToolDescriptor(params: { const title = typeof label === "string" && label.trim() ? label.trim() : undefined; return { ...(params.tool.displaySummary ? { displaySummary: params.tool.displaySummary } : {}), + ...(params.tool.requiredClientCaps + ? { requiredClientCaps: [...params.tool.requiredClientCaps] } + : {}), optional: params.optional, descriptor: { name: params.tool.name, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 05f15668042..ec3cea1ca48 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1810,6 +1810,33 @@ describe("resolvePluginTools optional tools", () => { expectSingleDiagnosticMessage(registry.diagnostics, "plugin id conflicts with core tool name"); }); + it("allows a plugin to register a second tool when one tool shares the plugin id", () => { + // Regression: the canvas plugin registers a `canvas` tool (same name as its + // plugin id) plus `show_widget`; the id-conflict guard must not treat the + // plugin's own earlier tool as a shadowing core name. + const registry = setRegistry([ + { + pluginId: "canvas", + optional: false, + source: "/tmp/canvas.js", + names: ["canvas"], + factory: () => makeTool("canvas"), + }, + { + pluginId: "canvas", + optional: false, + source: "/tmp/canvas.js", + names: ["show_widget"], + factory: () => makeTool("show_widget"), + }, + ]); + + const tools = resolvePluginTools(createResolveToolsParams({})); + + expectResolvedToolNames(tools, ["canvas", "show_widget"]); + expect(registry.diagnostics).toHaveLength(0); + }); + it.each([ { name: "skips conflicting tool names but keeps other tools", diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 599f05927bc..df897cc49b3 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -687,6 +687,9 @@ function createCachedDescriptorPluginTool(params: { label: descriptor.title ?? descriptor.name, description: descriptor.description, parameters: descriptor.inputSchema as never, + ...(params.descriptor.requiredClientCaps + ? { requiredClientCaps: [...params.descriptor.requiredClientCaps] } + : {}), async execute(toolCallId, executeParams, signal, onUpdate) { const loadOptions = buildPluginRuntimeLoadOptions(params.loadContext, { activate: false, @@ -778,6 +781,7 @@ function resolveCachedPluginTools(params: { onlyPluginIds: readonly string[]; existing: Set; existingNormalized: Set; + pluginToolOwnersByName: Map; ctx: OpenClawPluginToolContext; loadContext: ReturnType; runtimeOptions: PluginLoadOptions["runtimeOptions"]; @@ -903,6 +907,7 @@ function resolveCachedPluginTools(params: { for (const pluginTool of pluginTools) { params.existing.add(pluginTool.name); params.existingNormalized.add(normalizeToolName(pluginTool.name)); + params.pluginToolOwnersByName.set(normalizeToolName(pluginTool.name), plugin.id); tools.push(pluginTool); } handledPluginIds.add(plugin.id); @@ -1087,6 +1092,10 @@ export function resolvePluginTools(params: { const tools: AnyAgentTool[] = []; const existing = params.existingToolNames ?? new Set(); const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); + // Tracks which plugin registered each tool name so the plugin-id conflict + // guard below cannot fire against the plugin's own tools (a plugin may + // register several tools, one of which shares the plugin id, e.g. canvas). + const pluginToolOwnersByName = new Map(); const allowlist = normalizeAllowlist(params.toolAllowlist); const denylist = normalizeDenylist(params.toolDenylist); const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); @@ -1110,6 +1119,7 @@ export function resolvePluginTools(params: { onlyPluginIds, existing, existingNormalized, + pluginToolOwnersByName, ctx: params.context, loadContext: context, runtimeOptions, @@ -1200,7 +1210,13 @@ export function resolvePluginTools(params: { continue; } const pluginIdKey = normalizeToolName(entry.pluginId); - if (existingNormalized.has(pluginIdKey)) { + // A name owned by this same plugin (e.g. the canvas plugin's own `canvas` + // tool registered by an earlier entry) is not a conflict; only core names + // and other plugins' tools shadow the plugin id. + if ( + existingNormalized.has(pluginIdKey) && + pluginToolOwnersByName.get(pluginIdKey) !== entry.pluginId + ) { const message = `plugin id conflicts with core tool name (${entry.pluginId})`; if (!params.suppressNameConflicts) { context.logger.error(message); @@ -1377,6 +1393,7 @@ export function resolvePluginTools(params: { normalizedNameSet.add(normalizedToolName); existing.add(tool.name); existingNormalized.add(normalizedToolName); + pluginToolOwnersByName.set(normalizedToolName, entry.pluginId); const optional = isPluginToolOptional({ entry, manifestPlugin, diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index da43b837d92..05ff13655be 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -141,6 +141,7 @@ type ConnectFrame = { maxProtocol?: number; minProtocol?: number; scopes?: string[]; + caps?: string[]; }; }; @@ -413,6 +414,7 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.params?.minProtocol).toBe(MIN_CLIENT_PROTOCOL_VERSION); expect(connectFrame.params?.maxProtocol).toBe(PROTOCOL_VERSION); expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); + expect(connectFrame.params?.caps).toEqual(["tool-events", "inline-widgets"]); }); it("adds the current Control UI protocol to bare protocol mismatch errors", () => { diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 0860a5d8858..eeb0ae5dbdf 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -1,5 +1,6 @@ // Control UI module implements gateway behavior. import { + GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, type GatewayClientMode, @@ -783,7 +784,7 @@ export class GatewayBrowserClient { role: plan.role, scopes: plan.scopes, device: plan.device, - caps: ["tool-events"], + caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS, GATEWAY_CLIENT_CAPS.INLINE_WIDGETS], auth: plan.auth, userAgent: navigator.userAgent, locale: navigator.language, diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index f4b35d47706..2fd99d2b575 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -148,5 +148,6 @@ export type ToolCard = { viewId?: string; className?: string; style?: string; + sandbox?: "strict" | "scripts"; }; }; diff --git a/ui/src/lib/chat/message-normalizer.test.ts b/ui/src/lib/chat/message-normalizer.test.ts index d2aa14ddd3d..88b8bb3372e 100644 --- a/ui/src/lib/chat/message-normalizer.test.ts +++ b/ui/src/lib/chat/message-normalizer.test.ts @@ -252,6 +252,29 @@ describe("message-normalizer", () => { ]); }); + it("preserves a canvas preview sandbox ceiling from history", () => { + const result = normalizeMessage({ + role: "assistant", + content: [ + { + type: "canvas", + preview: { + kind: "canvas", + surface: "assistant_message", + render: "url", + url: "/__openclaw__/canvas/documents/cv_widget/index.html", + sandbox: "scripts", + }, + }, + ], + }); + + expect(result.content[0]).toMatchObject({ + type: "canvas", + preview: { sandbox: "scripts" }, + }); + }); + it("ignores [embed] shortcodes inside fenced code blocks", () => { const result = normalizeMessage({ role: "assistant", diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index c5dd56acd1b..869a0f395d4 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -96,6 +96,9 @@ function coerceCanvasPreview( ...(typeof preview.viewId === "string" ? { viewId: preview.viewId } : {}), ...(typeof preview.className === "string" ? { className: preview.className } : {}), ...(typeof preview.style === "string" ? { style: preview.style } : {}), + ...(preview.sandbox === "strict" || preview.sandbox === "scripts" + ? { sandbox: preview.sandbox } + : {}), }; } diff --git a/ui/src/lib/chat/tool-display.node.test.ts b/ui/src/lib/chat/tool-display.node.test.ts new file mode 100644 index 00000000000..1facfa3b9b2 --- /dev/null +++ b/ui/src/lib/chat/tool-display.node.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { resolveEmbedSandbox } from "./tool-display.ts"; + +describe("resolveEmbedSandbox", () => { + it("caps a trusted global sandbox at scripts-only for isolated previews", () => { + expect(resolveEmbedSandbox("trusted", "scripts")).toBe("allow-scripts"); + expect(resolveEmbedSandbox("scripts", "scripts")).toBe("allow-scripts"); + expect(resolveEmbedSandbox("strict", "scripts")).toBe(""); + }); + + it("preserves existing behavior when a preview has no sandbox ceiling", () => { + expect(resolveEmbedSandbox("trusted")).toBe("allow-scripts allow-same-origin"); + }); +}); diff --git a/ui/src/lib/chat/tool-display.ts b/ui/src/lib/chat/tool-display.ts index c6855685627..9cfa062a8e2 100644 --- a/ui/src/lib/chat/tool-display.ts +++ b/ui/src/lib/chat/tool-display.ts @@ -219,7 +219,16 @@ export function resolveCanvasIframeUrl( } } -export function resolveEmbedSandbox(mode: EmbedSandboxMode | null | undefined): string { +export function resolveEmbedSandbox( + mode: EmbedSandboxMode | null | undefined, + ceiling?: "strict" | "scripts", +): string { + if (ceiling === "strict" || (ceiling === "scripts" && mode === "strict")) { + return ""; + } + if (ceiling === "scripts") { + return "allow-scripts"; + } switch (mode) { case "strict": return ""; diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index 60ce75fc19a..59e090cfcf3 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -49,6 +49,8 @@ type CanvasSidebarContent = { title?: string; entryUrl: string; preferredHeight?: number; + /** Per-preview sandbox ceiling; keeps widget iframes below the global embed mode. */ + sandbox?: "strict" | "scripts"; rawText?: string | null; fullMessageRequest?: SidebarFullMessageRequest; unavailableReason?: DetailUnavailableReason | null; @@ -433,7 +435,9 @@ function resolveSidebarCanvasSandbox( content: SidebarContent, embedSandboxMode: EmbedSandboxMode, ): string { - return content.kind === "canvas" ? resolveEmbedSandbox(embedSandboxMode) : "allow-scripts"; + return content.kind === "canvas" + ? resolveEmbedSandbox(embedSandboxMode, content.sandbox) + : "allow-scripts"; } type MarkdownSidebarProps = { diff --git a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts index dfce24bf94f..de84147a597 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { extractToolCards } from "../../../lib/chat/tool-cards.ts"; -import { buildToolCardSidebarContent } from "./chat-tool-cards.ts"; +import { buildPreviewSidebarContent, buildToolCardSidebarContent } from "./chat-tool-cards.ts"; vi.mock("../../../components/icons.ts", () => ({ icons: {}, @@ -380,6 +380,7 @@ with Example Deck target: "assistant_message", title: "Inline demo", preferred_height: 420, + sandbox: "scripts", }, }), }, @@ -393,6 +394,27 @@ with Example Deck expect(card?.preview?.url).toBe("/__openclaw__/canvas/documents/cv_inline/index.html"); expect(card?.preview?.title).toBe("Inline demo"); expect(card?.preview?.preferredHeight).toBe(420); + expect(card?.preview?.sandbox).toBe("scripts"); + }); + + it("carries the preview sandbox ceiling into sidebar canvas content", () => { + const sidebar = buildPreviewSidebarContent( + { + kind: "canvas", + surface: "assistant_message", + render: "url", + viewId: "cv_widget", + url: "/__openclaw__/canvas/documents/cv_widget/index.html", + title: "Widget", + sandbox: "scripts", + }, + null, + ); + + // Dropping the ceiling here would re-grant allow-same-origin to widget + // script whenever the global embed mode is "trusted". + expect(sidebar?.kind).toBe("canvas"); + expect(sidebar && "sandbox" in sidebar ? sidebar.sandbox : undefined).toBe("scripts"); }); it("uses transcript metadata ids for history-backed tool messages", () => { diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index 408c2de952d..6839c01b347 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -123,14 +123,87 @@ function handleRawDetailsToggle(event: Event) { body.hidden = expanded; } +// Sandboxed widget documents report their content height via postMessage so the +// preview iframe can fit short/tall widgets. The event source must be one of our +// preview frames and the height is clamped, so widget code can only resize its +// own frame within the same bounds the preview contract allows. +const WIDGET_SIZE_MESSAGE_TYPE = "openclaw:widget-size"; +const WIDGET_FRAME_MIN_HEIGHT = 160; +const WIDGET_FRAME_MAX_HEIGHT = 1200; +// Preview frames render inside lit shadow roots, so a document query cannot +// find them; frames register themselves on load and are dropped once detached. +const widgetFrameRegistry = new Set(); +// Reported heights keyed by frame src: lit re-renders re-apply the style +// binding, so the template must read the reported height back or it resets. +const widgetFrameHeightsBySrc = new Map(); +const WIDGET_FRAME_HEIGHTS_MAX_ENTRIES = 100; +let widgetSizeListenerInstalled = false; + +function rememberWidgetFrameHeight(src: string, height: number) { + if ( + !widgetFrameHeightsBySrc.has(src) && + widgetFrameHeightsBySrc.size >= WIDGET_FRAME_HEIGHTS_MAX_ENTRIES + ) { + const oldest = widgetFrameHeightsBySrc.keys().next().value; + if (oldest !== undefined) { + widgetFrameHeightsBySrc.delete(oldest); + } + } + widgetFrameHeightsBySrc.set(src, height); +} + +function registerWidgetFrame(event: Event) { + const frame = event.currentTarget; + if (frame instanceof HTMLIFrameElement) { + widgetFrameRegistry.add(frame); + } +} + +function installWidgetSizeListener() { + if (widgetSizeListenerInstalled || typeof window === "undefined") { + return; + } + widgetSizeListenerInstalled = true; + window.addEventListener("message", (event: MessageEvent) => { + const data = event.data as { type?: unknown; height?: unknown } | null; + if (!data || data.type !== WIDGET_SIZE_MESSAGE_TYPE || typeof data.height !== "number") { + return; + } + for (const frame of widgetFrameRegistry) { + if (!frame.isConnected) { + widgetFrameRegistry.delete(frame); + continue; + } + if (frame.contentWindow === event.source) { + const height = Math.min( + Math.max(Math.trunc(data.height), WIDGET_FRAME_MIN_HEIGHT), + WIDGET_FRAME_MAX_HEIGHT, + ); + // The stylesheet floors the frame at min-height 420px; reported sizes + // must override both properties to fit short widgets. + frame.style.height = `${height}px`; + frame.style.minHeight = `${height}px`; + const src = frame.getAttribute("src"); + if (src) { + rememberWidgetFrameHeight(src, height); + } + return; + } + } + }); +} + function renderPreviewFrame(params: { title: string; src?: string; height?: number; sandbox?: string; }) { + installWidgetSizeListener(); const sandbox = params.sandbox ?? ""; const src = params.src ?? ""; + const reportedHeight = src ? widgetFrameHeightsBySrc.get(src) : undefined; + const height = reportedHeight ?? params.height; return keyed( `${sandbox}\u0000${src}\u0000${params.height ?? ""}`, html` @@ -139,7 +212,8 @@ function renderPreviewFrame(params: { title=${params.title} sandbox=${sandbox} src=${src || nothing} - style=${params.height ? `height:${params.height}px` : ""} + style=${height ? `height:${height}px;min-height:${height}px` : ""} + @load=${registerWidgetFrame} > `, ); @@ -179,7 +253,7 @@ export function renderToolPreview( options?.allowExternalEmbedUrls ?? false, ), height: preview.preferredHeight, - sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts"), + sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts", preview.sandbox), })} @@ -201,7 +275,7 @@ function buildSidebarContent( }; } -function buildPreviewSidebarContent( +export function buildPreviewSidebarContent( preview: ToolPreview, rawText?: string | null, options?: { fullMessageRequest?: FullMessageRequest }, @@ -215,6 +289,9 @@ function buildPreviewSidebarContent( entryUrl: preview.url, ...(preview.title ? { title: preview.title } : {}), ...(preview.preferredHeight ? { preferredHeight: preview.preferredHeight } : {}), + // The per-preview sandbox ceiling must survive the sidebar conversion, or a + // trusted global embed mode would re-grant same-origin to widget script. + ...(preview.sandbox ? { sandbox: preview.sandbox } : {}), ...(rawText ? { rawText } : {}), ...(options?.fullMessageRequest ? { fullMessageRequest: options.fullMessageRequest } : {}), };