diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index 42cce392d199..0d879efd35d9 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -108,6 +108,10 @@ function normalizePreferredHeight(value: number | undefined): number | undefined : undefined; } +export function isCanvasBoardWidgetName(value: unknown): value is string { + return typeof value === "string" && /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(value); +} + function coerceCanvasPreview( record: Record | undefined, ): CanvasPreview | undefined { @@ -145,10 +149,9 @@ function coerceCanvasPreview( const viewUrl = getRecordStringField(view, "url") ?? getRecordStringField(view, "entryUrl"); const viewId = getRecordStringField(view, "id") ?? getRecordStringField(view, "docId"); const requestedBoardWidgetName = getRecordStringField(view, "boardWidgetName"); - const boardWidgetName = - requestedBoardWidgetName && /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(requestedBoardWidgetName) - ? requestedBoardWidgetName - : undefined; + const boardWidgetName = isCanvasBoardWidgetName(requestedBoardWidgetName) + ? requestedBoardWidgetName + : undefined; if (mcpAppViewId && viewId === mcpAppViewId) { return { kind: "canvas", diff --git a/ui/src/lib/chat/message-normalizer.test.ts b/ui/src/lib/chat/message-normalizer.test.ts index ed9ac113bbfa..e9ae2481b0be 100644 --- a/ui/src/lib/chat/message-normalizer.test.ts +++ b/ui/src/lib/chat/message-normalizer.test.ts @@ -347,7 +347,7 @@ describe("message-normalizer", () => { ]); }); - it("preserves a canvas preview sandbox ceiling from history", () => { + it("preserves canvas dashboard identity and sandbox ceiling from history", () => { const result = normalizeMessage({ role: "assistant", content: [ @@ -359,6 +359,7 @@ describe("message-normalizer", () => { render: "url", url: "/__openclaw__/canvas/documents/cv_widget/index.html", sandbox: "scripts", + boardWidgetName: "release-status", }, }, ], @@ -366,10 +367,31 @@ describe("message-normalizer", () => { expect(result.content[0]).toMatchObject({ type: "canvas", - preview: { sandbox: "scripts" }, + preview: { sandbox: "scripts", boardWidgetName: "release-status" }, }); }); + it("drops invalid canvas dashboard identity 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", + boardWidgetName: "Invalid widget name", + }, + }, + ], + }); + + expect(result.content[0]).toMatchObject({ type: "canvas" }); + expect(result.content[0]).not.toHaveProperty("preview.boardWidgetName"); + }); + it("ignores [embed] shortcodes inside fenced code blocks", () => { const result = normalizeMessage({ role: "assistant", diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 4034c2eef8b4..fbcd1338d401 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -5,7 +5,10 @@ import { mediaKindFromMime } from "@openclaw/media-core/constants"; import { asRecord as asMessageRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; -import { extractCanvasShortcodes } from "../../../../src/chat/canvas-render.js"; +import { + extractCanvasShortcodes, + isCanvasBoardWidgetName, +} from "../../../../src/chat/canvas-render.js"; import { isToolCallContentType, isToolResultContentType, @@ -111,6 +114,9 @@ function coerceCanvasPreview( return null; } const mcpApp = isRecord(preview.mcpApp) ? preview.mcpApp : undefined; + const boardWidgetName = isCanvasBoardWidgetName(preview.boardWidgetName) + ? preview.boardWidgetName + : undefined; return { kind: "canvas", surface: "assistant_message", @@ -126,6 +132,7 @@ function coerceCanvasPreview( ...(preview.sandbox === "strict" || preview.sandbox === "scripts" ? { sandbox: preview.sandbox } : {}), + ...(boardWidgetName ? { boardWidgetName } : {}), ...(typeof mcpApp?.viewId === "string" && mcpApp.viewId.trim() ? { mcpApp: {