mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-28 22:11:11 +00:00
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
143 lines
5.3 KiB
TypeScript
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><Status></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);
|
|
});
|
|
});
|