diff --git a/docs/docs_map.md b/docs/docs_map.md
index 3f3f775d52ec..da1f578f5d58 100644
--- a/docs/docs_map.md
+++ b/docs/docs_map.md
@@ -10059,7 +10059,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /tools/show-widget
- Headings:
+ - H2: How widgets work
- H2: Use the tool
+ - H2: Interactive widgets
- H2: Security and storage
- H2: Related
diff --git a/docs/tools/show-widget.md b/docs/tools/show-widget.md
index 1fa2459e9f2f..d3bf81ee5df1 100644
--- a/docs/tools/show-widget.md
+++ b/docs/tools/show-widget.md
@@ -4,11 +4,23 @@ title: "Show widget"
sidebarTitle: "Show widget"
read_when:
- You want an agent to render an interactive result inside web chat
+ - You want widget buttons to send follow-up prompts into the 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.
+## How widgets work
+
+When the agent calls `show_widget`, the Canvas plugin wraps `widget_code` in a minimal HTML document, stores it as a Canvas document, and returns a preview handle. Web chat renders that handle as a sandboxed iframe directly under the tool call and restores it after history reload.
+
+The wrapper document injects two small host bridges around the widget code:
+
+- A size reporter posts the rendered content height to the embedding chat, which clamps it and fits the iframe (160 to 1200 pixels).
+- A prompt bridge defines a global `sendPrompt(text)` function that widget scripts can call to submit a follow-up message into the chat. The bridge creates a private message channel and offers one endpoint to the chat before any widget code runs; the chat adopts only that first offer. See [Interactive widgets](#interactive-widgets).
+
+Everything else stays inside the frame: the document runs in an opaque origin with a strict Content Security Policy, so widget scripts cannot reach the Control UI, the Gateway, or the network.
+
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 covers embedded, Codex app-server, and CLI-backed model backends. Grant-authenticated MCP callers and direct HTTP tool-invoke callers remain fail closed because they do not declare client capabilities.
@@ -27,6 +39,25 @@ The agent supplies two required strings:
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.
+## Interactive widgets
+
+Widget scripts can drive the conversation. The wrapper document defines a global `sendPrompt(text)` function; calling it submits `text` to the chat as if the user had typed and sent the message. Wire it to buttons or other controls to build interactive flows such as pickers, quizzes, or drill-down dashboards:
+
+```html
+Failing tests
+```
+
+Every prompt is validated on both sides of the frame boundary:
+
+- `sendPrompt` requires [transient user activation](https://developer.mozilla.org/en-US/docs/Web/Security/User_activation) inside the widget: it only works in the few seconds after the user clicks or presses a key in the widget, so wire it to buttons and other click targets — calling it automatically on load does nothing. The bridge keeps the sending endpoint private to itself and fails closed in browsers that do not expose user activation, so widget code cannot bypass the check.
+- Prompt authority belongs to the original widget document only. The trusted bridge offers its channel endpoint to the chat before widget code can run or navigate the frame, the chat adopts only that first offer, and the channel dies with the document on navigation. Externally allowed embed URLs are never adopted.
+- The widget frame must be visible in the chat transcript and hold focus — an additional host-observed signal that the user is actually interacting with this widget.
+- The text must be non-empty after trimming and at most 4,000 characters.
+- Prompts starting with `/` are rejected, so widget code cannot trigger chat commands such as `/approve` or `/stop`.
+- Each widget document may send at most 10 prompts per rolling minute; excess prompts are dropped silently.
+
+Accepted prompts appear in the transcript as regular user messages and start a normal agent turn in the session that owns the widget. There is no feedback channel into the widget: a dropped prompt fails silently, and the widget cannot read the agent's reply.
+
## 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`.
diff --git a/extensions/canvas/src/widget-tool.test.ts b/extensions/canvas/src/widget-tool.test.ts
index 41d7adc67e0a..7cd47069c683 100644
--- a/extensions/canvas/src/widget-tool.test.ts
+++ b/extensions/canvas/src/widget-tool.test.ts
@@ -97,7 +97,8 @@ describe("show_widget", () => {
`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('');
// The embedding chat fits the iframe to the reported content height.
expect(html).toContain("openclaw:widget-size");
const manifest = JSON.parse(
@@ -121,8 +122,21 @@ describe("show_widget", () => {
"utf8",
);
- expect(html).toContain("Run ";
+ // The prompt bridge precedes widget code so inline handlers can reference
+ // sendPrompt() immediately. It creates the prompt channel itself and offers
+ // one endpoint to the embedding chat at parse time — before any widget code
+ // can run, steal the endpoint, or navigate the frame — so the chat's
+ // first-offer-wins adoption is always bound to this document. The send
+ // endpoint stays private to this closure, and sendPrompt requires transient
+ // user activation, so widget code cannot auto-send without a real user
+ // gesture; the chat additionally validates, requires a focused visible
+ // frame, and rate limits every prompt.
+ // Everything sendPrompt later touches is snapshotted here, before widget
+ // code exists, so prototype patches (MessagePort.postMessage, the
+ // userActivation getter) by widget code cannot leak the endpoint or fake a
+ // gesture. Fail closed: no observable transient user activation, no send.
+ const promptBridge =
+ "';
return `
-${escapeHtml(title)} ${widgetCode}${sizeReporter}`;
+${escapeHtml(title)} ${promptBridge}${widgetCode}${sizeReporter}`;
}
function resolveRetentionScope(options: ShowWidgetToolOptions): string {
@@ -52,7 +76,7 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
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.",
+ "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. A global sendPrompt(text) function submits text to the chat as if the user typed it — wire it to buttons or controls to build interactive widgets. It only works after the user clicks inside the widget (plain conversational text only; slash commands are rejected), so never call it automatically.",
parameters: ShowWidgetToolSchema,
requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS,
execute: async (_toolCallId, args) => {
diff --git a/ui/src/lib/chat/tool-display.ts b/ui/src/lib/chat/tool-display.ts
index 5c5a59e9ec20..77f40b0833d6 100644
--- a/ui/src/lib/chat/tool-display.ts
+++ b/ui/src/lib/chat/tool-display.ts
@@ -182,6 +182,16 @@ function sanitizeCanvasEntryUrl(
}
}
+/**
+ * True when the preview entry URL points at a hosted Canvas document rather
+ * than an externally allowed embed URL. Prompt authority (widget sendPrompt)
+ * is granted only to internal Canvas documents.
+ */
+export function isInternalCanvasEntryUrl(entryUrl: string | undefined): boolean {
+ const rawEntryUrl = entryUrl?.trim();
+ return Boolean(rawEntryUrl && sanitizeCanvasEntryUrl(rawEntryUrl, false));
+}
+
export function resolveCanvasIframeUrl(
entryUrl: string | undefined,
canvasPluginSurfaceUrl?: string | null,
diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts
index a4357ed3862e..013f0f641582 100644
--- a/ui/src/pages/chat/chat-pane.ts
+++ b/ui/src/pages/chat/chat-pane.ts
@@ -129,6 +129,7 @@ import {
type SidebarFullMessageRequest,
} from "./components/chat-sidebar.ts";
import { ChatTranscriptController } from "./components/chat-thread.ts";
+import { WIDGET_PROMPT_EVENT, type WidgetPromptEventDetail } from "./components/chat-tool-cards.ts";
import {
CHAT_COMPOSER_DRAFT_STORAGE_ERROR,
loadChatComposerSnapshot,
@@ -1553,6 +1554,18 @@ class ChatPane extends OpenClawLightDomElement {
chatState.addCleanup(() =>
window.removeEventListener(BROWSER_ANNOTATION_EVENT, handleBrowserAnnotation),
);
+ // Interactive widget prompts bubble from the widget iframe; a listener on
+ // the pane element keeps split-view routing correct — the prompt reaches
+ // only the pane that owns the frame.
+ const handleWidgetPrompt = (event: Event) => {
+ const detail = (event as CustomEvent>).detail;
+ const text = typeof detail?.text === "string" ? detail.text.trim() : "";
+ if (text) {
+ void this.state?.handleSendChat(text);
+ }
+ };
+ this.addEventListener(WIDGET_PROMPT_EVENT, handleWidgetPrompt);
+ chatState.addCleanup(() => this.removeEventListener(WIDGET_PROMPT_EVENT, handleWidgetPrompt));
chatState.addCleanup(
this.context.gateway.subscribe((snapshot) => {
this.applyGatewaySnapshot(snapshot);
diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts
index 029c48c6f391..285ca483a3a3 100644
--- a/ui/src/pages/chat/components/chat-tool-cards.ts
+++ b/ui/src/pages/chat/components/chat-tool-cards.ts
@@ -20,6 +20,7 @@ import {
} from "../../../lib/chat/tool-cards.ts";
import {
formatToolDetail,
+ isInternalCanvasEntryUrl,
resolveCanvasIframeUrl,
resolveEmbedSandbox,
resolveToolDisplay,
@@ -138,6 +139,14 @@ function handleRawDetailsToggle(event: Event) {
// 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_PROMPT_OFFER_MESSAGE_TYPE = "openclaw:widget-prompt-offer";
+const WIDGET_PROMPT_MESSAGE_TYPE = "openclaw:widget-prompt";
+/** Bubbling DOM event re-dispatched from the widget iframe once a prompt passes validation. */
+export const WIDGET_PROMPT_EVENT = "openclaw-widget-prompt";
+export type WidgetPromptEventDetail = { text: string };
+const WIDGET_PROMPT_MAX_CHARS = 4_000;
+const WIDGET_PROMPT_RATE_WINDOW_MS = 60_000;
+const WIDGET_PROMPT_RATE_MAX = 10;
const WIDGET_FRAME_MIN_HEIGHT = 160;
const WIDGET_FRAME_MAX_HEIGHT = 1200;
// Preview frames render inside lit shadow roots, so a document query cannot
@@ -147,7 +156,9 @@ const widgetFrameRegistry = new Set();
// 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;
+// Keyed by window, not a module boolean: non-isolated test workers swap the
+// global window between files while module state persists.
+const widgetSizeListenerWindows = new WeakSet();
function rememberWidgetFrameHeight(src: string, height: number) {
if (
@@ -169,11 +180,179 @@ function registerWidgetFrame(event: Event) {
}
}
-function installWidgetSizeListener() {
- if (widgetSizeListenerInstalled || typeof window === "undefined") {
+/**
+ * Widget prompts run the normal chat send path, which also interprets slash
+ * commands. Widget code is agent-authored, so accepting a command here would
+ * let a widget approve its own pending actions; plain conversational text only.
+ */
+function resolveWidgetPromptText(raw: unknown): string | null {
+ if (typeof raw !== "string") {
+ return null;
+ }
+ const text = raw.trim();
+ if (!text || text.length > WIDGET_PROMPT_MAX_CHARS || text.startsWith("/")) {
+ return null;
+ }
+ return text;
+}
+
+// Prompt budgets are keyed by frame src (stable per hosted widget document), so
+// a widget cannot reset its budget and the map stays bounded like the heights map.
+const widgetPromptTimestampsBySrc = new Map();
+
+function allowWidgetPrompt(src: string, nowMs: number): boolean {
+ const cutoff = nowMs - WIDGET_PROMPT_RATE_WINDOW_MS;
+ const timestamps = (widgetPromptTimestampsBySrc.get(src) ?? []).filter((ts) => ts > cutoff);
+ if (
+ !widgetPromptTimestampsBySrc.has(src) &&
+ widgetPromptTimestampsBySrc.size >= WIDGET_FRAME_HEIGHTS_MAX_ENTRIES
+ ) {
+ const oldest = widgetPromptTimestampsBySrc.keys().next().value;
+ if (oldest !== undefined) {
+ widgetPromptTimestampsBySrc.delete(oldest);
+ }
+ }
+ if (timestamps.length >= WIDGET_PROMPT_RATE_MAX) {
+ widgetPromptTimestampsBySrc.set(src, timestamps);
+ return false;
+ }
+ timestamps.push(nowMs);
+ widgetPromptTimestampsBySrc.set(src, timestamps);
+ return true;
+}
+
+/**
+ * A prompt must come from a widget the user can currently see and has clicked
+ * into. `isConnected` + visibility drops hidden or collapsed frames, and the
+ * focus requirement is the host-observed stand-in for user activation: the
+ * parent cannot see clicks inside a cross-origin frame, but a click focuses the
+ * iframe element, so a document that merely rendered (or restored from
+ * history) cannot auto-send prompts.
+ */
+function isWidgetFrameInteractable(frame: HTMLIFrameElement): boolean {
+ if (!frame.isConnected) {
+ return false;
+ }
+ const visible =
+ typeof frame.checkVisibility === "function"
+ ? frame.checkVisibility()
+ : frame.getClientRects().length > 0;
+ if (!visible) {
+ return false;
+ }
+ let active: Element | null = frame.ownerDocument.activeElement;
+ while (active?.shadowRoot?.activeElement) {
+ active = active.shadowRoot.activeElement;
+ }
+ return active === frame;
+}
+
+function handleWidgetPromptMessage(frame: HTMLIFrameElement, data: unknown) {
+ const payload = data as { type?: unknown; prompt?: unknown } | null;
+ if (!payload || payload.type !== WIDGET_PROMPT_MESSAGE_TYPE) {
return;
}
- widgetSizeListenerInstalled = true;
+ const text = resolveWidgetPromptText(payload.prompt);
+ if (!text || !isWidgetFrameInteractable(frame)) {
+ return;
+ }
+ if (!allowWidgetPrompt(frame.getAttribute("src") ?? "", Date.now())) {
+ return;
+ }
+ // Re-dispatch as a bubbling DOM event from the iframe so the owning chat
+ // pane — and only that pane — routes the prompt into its own send path.
+ frame.dispatchEvent(
+ new CustomEvent(WIDGET_PROMPT_EVENT, {
+ bubbles: true,
+ composed: true,
+ detail: { text },
+ }),
+ );
+}
+
+// Prompt authority is a MessagePort OFFERED by the trusted bridge script that
+// wraps every hosted widget document. The bridge posts its offer at document
+// parse time — before any widget code can run, steal the endpoint, or navigate
+// the frame — so buffering only the FIRST offer per content window and adopting
+// it once, at the frame's first load, binds the capability to the genuine
+// widget document. A document that navigates away closes its ports with it,
+// externally allowed embed URLs are never adopted, and later offers or loads
+// cannot re-arm a consumed frame.
+const pendingWidgetPromptPorts = new WeakMap();
+const offeredWidgetPromptSources = new WeakSet();
+const promptEligibleFrames = new WeakSet();
+const adoptedWidgetPromptFrames = new WeakSet();
+// Keyed by window, not a module boolean: non-isolated test workers swap the
+// global window between files while module state persists.
+const widgetPromptOfferListenerWindows = new WeakSet();
+
+function tryAdoptWidgetPromptPort(frame: HTMLIFrameElement) {
+ const source = frame.contentWindow as unknown as object | null;
+ if (adoptedWidgetPromptFrames.has(frame) || !promptEligibleFrames.has(frame) || !source) {
+ return;
+ }
+ const port = pendingWidgetPromptPorts.get(source);
+ if (!port) {
+ return;
+ }
+ adoptedWidgetPromptFrames.add(frame);
+ pendingWidgetPromptPorts.delete(source);
+ port.addEventListener("message", (message: MessageEvent) => {
+ handleWidgetPromptMessage(frame, message.data);
+ });
+ port.start();
+}
+
+function installWidgetPromptOfferListener() {
+ if (typeof window === "undefined" || widgetPromptOfferListenerWindows.has(window)) {
+ return;
+ }
+ widgetPromptOfferListenerWindows.add(window);
+ window.addEventListener("message", (event: MessageEvent) => {
+ const data = event.data as { type?: unknown } | null;
+ if (!data || data.type !== WIDGET_PROMPT_OFFER_MESSAGE_TYPE) {
+ return;
+ }
+ const source = event.source;
+ const port = event.ports[0];
+ // Hosted widget documents run in an opaque origin; anything else is not a
+ // Canvas widget bridge.
+ if (!source || !port || event.origin !== "null") {
+ return;
+ }
+ if (offeredWidgetPromptSources.has(source as unknown as object)) {
+ // Only the first offer per content window can win; a replacement
+ // document's offer must never displace the genuine bridge's.
+ port.close();
+ return;
+ }
+ offeredWidgetPromptSources.add(source as unknown as object);
+ pendingWidgetPromptPorts.set(source as unknown as object, port);
+ // Posted-message and iframe-load tasks have no guaranteed cross-source
+ // ordering, so the offer may arrive after the eligible frame's load;
+ // adopt for it now instead of stranding the widget without a channel.
+ for (const frame of widgetFrameRegistry) {
+ if (frame.contentWindow === source) {
+ tryAdoptWidgetPromptPort(frame);
+ return;
+ }
+ }
+ });
+}
+
+function adoptWidgetPromptPort(frame: HTMLIFrameElement) {
+ // Eligibility is granted at the frame's first prompt-capable load and the
+ // adoption itself is one-shot; first-offer-wins buffering ensures the port
+ // adopted here always belongs to the frame's original bridge document.
+ promptEligibleFrames.add(frame);
+ tryAdoptWidgetPromptPort(frame);
+}
+
+function installWidgetSizeListener() {
+ if (typeof window === "undefined" || widgetSizeListenerWindows.has(window)) {
+ return;
+ }
+ widgetSizeListenerWindows.add(window);
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") {
@@ -208,12 +387,22 @@ function renderPreviewFrame(params: {
src?: string;
height?: number;
sandbox?: string;
+ promptCapable?: boolean;
}) {
installWidgetSizeListener();
const sandbox = params.sandbox ?? "";
const src = params.src ?? "";
const reportedHeight = src ? widgetFrameHeightsBySrc.get(src) : undefined;
const height = reportedHeight ?? params.height;
+ if (params.promptCapable) {
+ installWidgetPromptOfferListener();
+ }
+ const handleLoad = (event: Event) => {
+ registerWidgetFrame(event);
+ if (params.promptCapable && event.currentTarget instanceof HTMLIFrameElement) {
+ adoptWidgetPromptPort(event.currentTarget);
+ }
+ };
return keyed(
`${sandbox}\u0000${src}\u0000${params.height ?? ""}`,
html`
@@ -223,7 +412,7 @@ function renderPreviewFrame(params: {
sandbox=${sandbox}
src=${src || nothing}
style=${height ? `height:${height}px;min-height:${height}px` : ""}
- @load=${registerWidgetFrame}
+ @load=${handleLoad}
>
`,
);
@@ -299,6 +488,9 @@ export function renderToolPreview(
),
height: preview.preferredHeight,
sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts", preview.sandbox),
+ // Only hosted Canvas documents may drive the chat; externally
+ // allowed embed URLs render but never get prompt authority.
+ promptCapable: isInternalCanvasEntryUrl(preview.url),
})}
diff --git a/ui/src/pages/chat/components/chat-tool-cards.widget-prompts.test.ts b/ui/src/pages/chat/components/chat-tool-cards.widget-prompts.test.ts
new file mode 100644
index 000000000000..7d42e2a79a4d
--- /dev/null
+++ b/ui/src/pages/chat/components/chat-tool-cards.widget-prompts.test.ts
@@ -0,0 +1,164 @@
+/* @vitest-environment jsdom */
+// Covers the interactive-widget prompt channel: offer adoption, text
+// validation, rate limiting, user-interaction gating, and external-embed
+// rejection — all through the real port + DOM event path.
+
+import { render } from "lit";
+import { describe, expect, it } from "vitest";
+import {
+ renderToolPreview,
+ WIDGET_PROMPT_EVENT,
+ type WidgetPromptEventDetail,
+} from "./chat-tool-cards.ts";
+
+function renderWidgetPreviewFrame(url: string, allowExternalEmbedUrls = false) {
+ const container = document.createElement("div");
+ document.body.append(container);
+ render(
+ renderToolPreview(
+ {
+ kind: "canvas",
+ surface: "assistant_message",
+ render: "url",
+ viewId: "cv_prompt",
+ url,
+ },
+ "chat_message",
+ allowExternalEmbedUrls ? { allowExternalEmbedUrls } : {},
+ ),
+ container,
+ );
+ const frame = container.querySelector("iframe");
+ expect(frame).not.toBeNull();
+ expect(frame!.contentWindow).not.toBeNull();
+ return { container, frame: frame! };
+}
+
+function offerPromptPort(frame: HTMLIFrameElement): MessagePort {
+ const channel = new MessageChannel();
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type: "openclaw:widget-prompt-offer" },
+ origin: "null",
+ source: frame.contentWindow,
+ ports: [channel.port2],
+ }),
+ );
+ return channel.port1;
+}
+
+function postPrompt(port: MessagePort, prompt: unknown) {
+ port.postMessage({ type: "openclaw:widget-prompt", prompt });
+}
+
+async function flushPorts() {
+ // Port delivery may take more than one macrotask on loaded CI workers.
+ for (let index = 0; index < 5; index += 1) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+ }
+}
+
+function emulateInteractableFrame(frame: HTMLIFrameElement) {
+ // jsdom has no layout and cannot focus iframes; emulate a visible frame the
+ // user clicked into, which is what the host checks require.
+ (frame as HTMLIFrameElement & { checkVisibility: () => boolean }).checkVisibility = () => true;
+ Object.defineProperty(document, "activeElement", { get: () => frame, configurable: true });
+}
+
+function collectPromptEvents(container: HTMLElement): string[] {
+ const received: string[] = [];
+ container.addEventListener(WIDGET_PROMPT_EVENT, (event) => {
+ received.push((event as CustomEvent).detail.text);
+ });
+ return received;
+}
+
+function restoreActiveElement() {
+ delete (document as unknown as Record).activeElement;
+}
+
+describe("widget prompts", () => {
+ it("adopts the bridge's prompt port offer and enforces the prompt contract", async () => {
+ const { container, frame } = renderWidgetPreviewFrame(
+ "/__openclaw__/canvas/documents/cv_prompt/index.html",
+ );
+ // The bridge posts its offer at parse time, before the frame's load event.
+ const port = offerPromptPort(frame);
+ frame.dispatchEvent(new Event("load"));
+ emulateInteractableFrame(frame);
+ const received = collectPromptEvents(container);
+ try {
+ postPrompt(port, " Show details ");
+ await flushPorts();
+ expect(received).toEqual(["Show details"]);
+ // Slash commands would run UI commands such as /approve on the widget's
+ // behalf; the send path must only ever receive conversational text.
+ postPrompt(port, "/approve");
+ postPrompt(port, " ");
+ postPrompt(port, 42);
+ postPrompt(port, "x".repeat(4_001));
+ await flushPorts();
+ expect(received).toEqual(["Show details"]);
+ // A replacement document's later offer must not displace or re-arm the
+ // adopted grant, even across another load event.
+ const lateOfferPort = offerPromptPort(frame);
+ frame.dispatchEvent(new Event("load"));
+ postPrompt(lateOfferPort, "From takeover");
+ await flushPorts();
+ expect(received).toEqual(["Show details"]);
+ // Without focus on the frame there is no user-activation signal; drop.
+ restoreActiveElement();
+ postPrompt(port, "Auto send");
+ await flushPorts();
+ expect(received).toEqual(["Show details"]);
+ // Rate limit: 10 accepted prompts per rolling minute per widget document.
+ emulateInteractableFrame(frame);
+ for (let index = 2; index <= 12; index += 1) {
+ postPrompt(port, `Prompt ${index}`);
+ }
+ await flushPorts();
+ expect(received).toHaveLength(10);
+ expect(received.at(-1)).toBe("Prompt 10");
+ } finally {
+ restoreActiveElement();
+ container.remove();
+ }
+ });
+
+ it("adopts a prompt offer that arrives after the frame's load event", async () => {
+ const { container, frame } = renderWidgetPreviewFrame(
+ "/__openclaw__/canvas/documents/cv_late_offer/index.html",
+ );
+ // Posted-message and load tasks have no guaranteed ordering; here load wins.
+ frame.dispatchEvent(new Event("load"));
+ const port = offerPromptPort(frame);
+ emulateInteractableFrame(frame);
+ const received = collectPromptEvents(container);
+ try {
+ postPrompt(port, "Late but valid");
+ await flushPorts();
+ expect(received).toEqual(["Late but valid"]);
+ } finally {
+ restoreActiveElement();
+ container.remove();
+ }
+ });
+
+ it("never adopts prompt offers from externally allowed embed URLs", async () => {
+ const { container, frame } = renderWidgetPreviewFrame("https://canvas.example/widget", true);
+ const port = offerPromptPort(frame);
+ frame.dispatchEvent(new Event("load"));
+ emulateInteractableFrame(frame);
+ const received = collectPromptEvents(container);
+ try {
+ postPrompt(port, "External send");
+ await flushPorts();
+ expect(received).toEqual([]);
+ } finally {
+ restoreActiveElement();
+ container.remove();
+ }
+ });
+});