Files
openclaw/extensions/canvas/src/widget-tool.test.ts
Peter Steinberger 3ccf2a0739 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
2026-07-09 12:29:50 +01:00

143 lines
5.3 KiB
TypeScript

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