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
This commit is contained in:
Peter Steinberger 2026-07-09 12:29:50 +01:00 committed by GitHub
parent de2af01c9c
commit 3ccf2a0739
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1038 additions and 32 deletions

View file

@ -1330,6 +1330,7 @@
"tools/btw",
"tools/code-execution",
"tools/diffs",
"tools/show-widget",
"tools/elevated",
"tools/permission-modes",
"tools/exec-approvals",

View file

@ -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

View file

@ -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

View file

@ -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

44
docs/tools/show-widget.md Normal file
View file

@ -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:
<ParamField path="title" type="string" required>
Short title shown with the inline preview and in the hosted document title.
</ParamField>
<ParamField path="widget_code" type="string" required>
Self-contained SVG or HTML fragment. Input beginning with `<svg` after trimming is rendered in SVG mode; all other input is treated as an HTML fragment. Maximum length: 262,144 characters.
</ParamField>
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)

View file

@ -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.
<Tabs>
<Tab title="strict">
Disables script execution inside hosted embeds.

View file

@ -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<AnyAgentTool["execute"]>) =>
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 } =

View file

@ -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"]

View file

@ -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<void> {
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<CanvasDocumentManifest> {
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;
}

View file

@ -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"), "<html><body>widget</body></html>", "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"),
"<html><body>plain</body></html>",
"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[] = [];

View file

@ -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;
}

View file

@ -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),

View file

@ -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<string> {
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: "<Status>",
widgetCode: ' <SvG viewBox="0 0 10 10"><circle r="4" /></SvG> ',
});
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: "<Status>", 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("<title>&lt;Status&gt;</title>");
expect(html).toContain('<body class="svg-widget"><SvG');
// The embedding chat fits the iframe to the reported content height.
expect(html).toContain("openclaw:widget-size");
const manifest = JSON.parse(
await readFile(
path.join(resolveCanvasDocumentDir(viewId, { stateDir }), "manifest.json"),
"utf8",
),
) as { cspSandbox?: string };
// The host keys the served CSP sandbox header off this manifest field.
expect(manifest.cspSandbox).toBe("scripts");
});
it("wraps HTML fragments without SVG layout", async () => {
const stateDir = await createStateDir();
const { viewId } = await executeWidget({
stateDir,
widgetCode: "<section><button>Run</button><script>document.title='ready'</script></section>",
});
const html = await readFile(
path.join(resolveCanvasDocumentDir(viewId, { stateDir }), "index.html"),
"utf8",
);
expect(html).toContain("<body><section><button>Run</button><script>");
expect(html).not.toContain('<body class="svg-widget">');
});
it("uses opaque ids and evicts the oldest widget within a session scope", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-07T00:00:00.000Z"));
const stateDir = await createStateDir();
const first = await executeWidget({ stateDir, widgetCode: "<p>0</p>" });
for (let index = 1; index <= WIDGET_MAX_PER_SCOPE; index += 1) {
vi.setSystemTime(new Date(`2026-07-07T00:00:${String(index).padStart(2, "0")}.000Z`));
await executeWidget({ stateDir, widgetCode: `<p>${index}</p>` });
}
await expect(access(resolveCanvasDocumentDir(first.viewId, { stateDir }))).rejects.toThrow();
const entries = await readdir(path.join(stateDir, "canvas", "documents"));
expect(entries).toHaveLength(WIDGET_MAX_PER_SCOPE);
});
});

View file

@ -0,0 +1,114 @@
/** Agent-facing inline web chat widget tool. */
import { createHash } from "node:crypto";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import { resolveCanvasHostConfig } from "./config.js";
import { createCanvasDocument } from "./documents.js";
import { SHOW_WIDGET_REQUIRED_CLIENT_CAPS, ShowWidgetToolSchema } from "./tool-schema.js";
export const WIDGET_CODE_MAX_CHARS = 262_144;
export const WIDGET_MAX_PER_SCOPE = 32;
type ShowWidgetToolOptions = {
config?: OpenClawConfig;
sessionId?: string;
agentId?: string;
stateDir?: string;
};
class WidgetToolInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolInputError";
}
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function buildWidgetDocument(title: string, widgetCode: string): string {
const isSvg = /^<svg/i.test(widgetCode);
const bodyClass = isSvg ? ' class="svg-widget"' : "";
// Inline scripts may drive the widget; CSP blocks resource loads, while preview metadata
// prevents the iframe from inheriting same-origin access to the parent application.
// The size reporter lets the embedding chat fit the iframe to the content; the
// parent clamps reported heights, so widget code cannot abuse the channel.
const sizeReporter =
"<script>(()=>{if(!window.parent||window.parent===window)return;" +
// documentElement.scrollHeight reports the viewport for short content, so
// measure the body box, which tracks the actual widget height.
"let last=0;const report=()=>{const b=document.body;if(!b)return;" +
"const h=Math.ceil(Math.max(b.scrollHeight,b.offsetHeight,b.getBoundingClientRect().height));" +
'if(h&&h!==last){last=h;window.parent.postMessage({type:"openclaw:widget-size",height:h},"*");}};' +
"addEventListener('load',report);new ResizeObserver(report).observe(document.body);" +
"setTimeout(report,50);setTimeout(report,500);})();</script>";
return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;"><title>${escapeHtml(title)}</title><style>:root{color-scheme:light dark}*{box-sizing:border-box}html,body{margin:0}body{font:14px system-ui,sans-serif}.svg-widget{display:grid;place-items:center}.svg-widget>svg{max-width:100%}</style></head><body${bodyClass}>${widgetCode}${sizeReporter}</body></html>`;
}
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<string, unknown>;
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}`,
});
},
};
}

View file

@ -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);

View file

@ -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,

View file

@ -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;

View file

@ -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<typeof import("./openclaw-tools.js")>();
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";

View file

@ -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<typeof import("./openclaw-tools.js")>();
return {
createOpenClawTools: () => [],
filterToolsByClientCaps: actual.filterToolsByClientCaps,
};
});
vi.mock("./bash-tools.exec-host-shared.js", async () => {
const mod = await vi.importActual<typeof import("./bash-tools.exec-host-shared.js")>(

View file

@ -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

View file

@ -935,6 +935,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
},
sandbox,
messageProvider: resolvedMessageProvider,
clientCaps: params.clientCaps,
chatType: params.chatType,
agentAccountId: params.agentAccountId,
sessionKey: sandboxSessionKey,

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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". */

View file

@ -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);
});
});

View file

@ -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()

View file

@ -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<typeof import("../openclaw-tools.js")>();
return {
createOpenClawTools: createOpenClawToolsMock,
filterToolsByClientCaps: actual.filterToolsByClientCaps,
testing: {
setDepsForTest: () => {},
},
};
});

View file

@ -30,6 +30,8 @@ export type AgentToolWithMeta<TParameters extends TSchema, TResult> = 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<AgentTool, "execute"> &
ErasedAgentToolExecute & {
displaySummary?: string;
/** Gateway client capabilities required before this tool can be assembled. */
requiredClientCaps?: string[];
prepareBeforeToolCallParams?: AgentToolWithMeta<
TSchema,
unknown

View file

@ -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<typeof import("../openclaw-tools.js")>();
const { createImageTool: createImageToolLocal } = await import("./image-tool.js");
return {
filterToolsByClientCaps: actual.filterToolsByClientCaps,
createOpenClawTools: vi.fn((options?: MockOpenClawToolsOptions) => {
const imageTool = createImageToolLocal({
config: options?.config,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 () => {

View file

@ -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,

View file

@ -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,

View file

@ -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),
);
});
});

View file

@ -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 ?? "",

View file

@ -104,6 +104,7 @@ export type FollowupRun = {
sessionKey?: string;
runtimePolicySessionKey?: string;
messageProvider?: string;
clientCaps?: string[];
chatType?: ChatType;
agentAccountId?: string;
groupId?: string;

View file

@ -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). */

View file

@ -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;

View file

@ -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");
});

View file

@ -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";

View file

@ -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",
});

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -155,6 +155,7 @@ export async function invokeGatewayTool(params: {
agentTo?: string;
agentThreadId?: string;
senderIsOwner?: boolean;
clientCaps?: string[];
toolCallIdPrefix: string;
approvalMode?: "request" | "report";
}): Promise<ToolsInvokeOutcome> {
@ -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",

View file

@ -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;

View file

@ -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,

View file

@ -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",

View file

@ -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<string>;
existingNormalized: Set<string>;
pluginToolOwnersByName: Map<string, string>;
ctx: OpenClawPluginToolContext;
loadContext: ReturnType<typeof resolvePluginRuntimeLoadContext>;
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<string>();
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<string, string>();
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,

View file

@ -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", () => {

View file

@ -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,

View file

@ -148,5 +148,6 @@ export type ToolCard = {
viewId?: string;
className?: string;
style?: string;
sandbox?: "strict" | "scripts";
};
};

View file

@ -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",

View file

@ -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 }
: {}),
};
}

View file

@ -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");
});
});

View file

@ -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 "";

View file

@ -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 = {

View file

@ -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", () => {

View file

@ -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<HTMLIFrameElement>();
// 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<string, number>();
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}
></iframe>
`,
);
@ -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),
})}
</div>
</div>
@ -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 } : {}),
};