From 087f33c38a1e6473d11ec2b0d31c5a0898330f4d Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sat, 5 Sep 2026 18:23:57 -0400 Subject: [PATCH 1/3] [claude] feat(markdown): agent-authored widgets, read-only (AGENT-WIDGETS-001 PR-1) Agents publish structured JSON in a `buzz-widget` fence; the client owns the rendering via allowlisted React components. Ships `table` and `metric`. No HTML path: raw HTML stays inert (no rehypeRaw, CSP forbids it), and payload strings render as escaped text through JSX. Widget type lives in the JSON, not the fence info string, because extractLanguage keeps only the first info-string token. Unknown types, bad JSON, and oversized payloads degrade to a plain code block so content stays readable on old clients and other surfaces. Tests: 24 new (schema, fence-text recovery, end-to-end render incl. XSS- as-data and prototype pollution). typecheck + biome clean. Pre-existing unrelated failure on product/main: useKnownAgentPubkeys provenance test (verified failing with this work stashed). --- desktop/src/shared/ui/markdown.tsx | 9 ++ .../shared/ui/markdown/widgets/WidgetView.tsx | 119 +++++++++++++++ .../ui/markdown/widgets/fenceText.test.mjs | 31 ++++ .../shared/ui/markdown/widgets/fenceText.ts | 35 +++++ .../ui/markdown/widgets/render.test.mjs | 98 +++++++++++++ .../ui/markdown/widgets/schema.test.mjs | 106 ++++++++++++++ .../src/shared/ui/markdown/widgets/schema.ts | 135 ++++++++++++++++++ 7 files changed, 533 insertions(+) create mode 100644 desktop/src/shared/ui/markdown/widgets/WidgetView.tsx create mode 100644 desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs create mode 100644 desktop/src/shared/ui/markdown/widgets/fenceText.ts create mode 100644 desktop/src/shared/ui/markdown/widgets/render.test.mjs create mode 100644 desktop/src/shared/ui/markdown/widgets/schema.test.mjs create mode 100644 desktop/src/shared/ui/markdown/widgets/schema.ts diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 747a5de6776..cbfc3a2eec7 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -52,6 +52,9 @@ import { SyntaxHighlightedCode, } from "./markdown/CodeBlock"; import { EntityLinkAnchor, useOpenEntityLink } from "./markdown/entityLinks"; +import { parseWidget, WIDGET_FENCE_LANGUAGE } from "./markdown/widgets/schema"; +import { widgetFenceText } from "./markdown/widgets/fenceText"; +import { WidgetView } from "./markdown/widgets/WidgetView"; import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor"; import { FileCard } from "./markdown/FileCard"; import { @@ -1554,6 +1557,12 @@ export function createMarkdownComponents( language = extractLanguage(child.props.className); } }); + if (language === WIDGET_FENCE_LANGUAGE) { + const parsed = parseWidget(widgetFenceText(children)); + // Invalid payloads fall through to a plain code block on purpose: a + // malformed or unknown widget must stay readable, never blank. + if (parsed.ok) return ; + } return ( {children} ); diff --git a/desktop/src/shared/ui/markdown/widgets/WidgetView.tsx b/desktop/src/shared/ui/markdown/widgets/WidgetView.tsx new file mode 100644 index 00000000000..b6a81c142f6 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/WidgetView.tsx @@ -0,0 +1,119 @@ +import * as React from "react"; + +import { useSmoothCorners } from "@/shared/ui/smoothCorners"; + +import type { MetricWidget, TableWidget, Widget } from "./schema"; + +/** + * Read-only widget renderers (AGENT-WIDGETS-001, PR-1). + * + * Every value below arrives from an agent and is rendered as text through JSX, + * so React escapes it. Nothing here uses `dangerouslySetInnerHTML`, and no + * value reaches an HTML parser. + */ + +function WidgetFrame({ + caption, + children, +}: { + caption?: string; + children: React.ReactNode; +}) { + const frameRef = React.useRef(null); + useSmoothCorners(frameRef); + + return ( +
+ {children} + {caption && ( +
+ {caption} +
+ )} +
+ ); +} + +function TableWidgetView({ widget }: { widget: TableWidget }) { + return ( + + + + + {widget.columns.map((column, i) => ( + + ))} + + + + {widget.rows.map((row, r) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity + + {row.map((cell, c) => ( + + ))} + + ))} + +
+ {column} +
+ {cell} +
+
+ ); +} + +function MetricWidgetView({ widget }: { widget: MetricWidget }) { + return ( + +
+ {widget.metrics.map((metric, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity +
+
+ {metric.label} +
+
+ + {metric.value} + + {metric.unit && ( + + {metric.unit} + + )} + {metric.delta && ( + + {metric.delta} + + )} +
+
+ ))} +
+
+ ); +} + +/** Render a validated widget. Unknown types are unreachable — `parseWidget` gates them. */ +export function WidgetView({ widget }: { widget: Widget }) { + switch (widget.type) { + case "table": + return ; + case "metric": + return ; + } +} diff --git a/desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs b/desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs new file mode 100644 index 00000000000..41b15424f67 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React from "react"; + +import { widgetFenceText } from "./fenceText.ts"; + +const code = (children) => React.createElement("code", {}, children); + +test("widgetFenceText: reads a single string child", () => { + assert.equal(widgetFenceText(code('{"v":1}')), '{"v":1}'); +}); + +test("widgetFenceText: rejoins a fragmented fence body", () => { + assert.equal( + widgetFenceText(code(['{"v":1,', '"type":', '"table"}'])), + '{"v":1,"type":"table"}', + ); +}); + +test("widgetFenceText: descends through nested syntax elements", () => { + const nested = code([React.createElement("span", {}, '{"v":'), "1}"]); + assert.equal(widgetFenceText(nested), '{"v":1}'); +}); + +test("widgetFenceText: ignores null and boolean leaves", () => { + assert.equal(widgetFenceText(code(["a", null, false, "b"])), "ab"); +}); + +test("widgetFenceText: preserves newlines and whitespace exactly", () => { + assert.equal(widgetFenceText(code(['{\n "v": 1\n}'])), '{\n "v": 1\n}'); +}); diff --git a/desktop/src/shared/ui/markdown/widgets/fenceText.ts b/desktop/src/shared/ui/markdown/widgets/fenceText.ts new file mode 100644 index 00000000000..9427b5283fe --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/fenceText.ts @@ -0,0 +1,35 @@ +import * as React from "react"; + +/** + * Recover the raw text of a fenced code block from its rendered children. + * + * `react-markdown` hands the `pre` handler a `` element whose children + * are the fence body, split into an arbitrary number of string nodes (syntax + * plugins may fragment it further). Widgets need the exact original text to + * parse as JSON, so this walks the tree and concatenates every string leaf. + * + * Non-string leaves are ignored rather than coerced: a fence containing real + * elements is not a valid payload, and `JSON.parse` will reject the remainder. + */ +export function widgetFenceText(children: React.ReactNode): string { + let out = ""; + const walk = (node: React.ReactNode): void => { + if (typeof node === "string") { + out += node; + return; + } + if (typeof node === "number") { + out += String(node); + return; + } + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + walk(node.props?.children); + } + }; + walk(children); + return out; +} diff --git a/desktop/src/shared/ui/markdown/widgets/render.test.mjs b/desktop/src/shared/ui/markdown/widgets/render.test.mjs new file mode 100644 index 00000000000..3cd77840f76 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/render.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; + +import { widgetFenceText } from "./fenceText.ts"; +import { parseWidget, WIDGET_FENCE_LANGUAGE } from "./schema.ts"; + +// Mirrors the `pre` handler in markdown.tsx: classify the fence, render a +// widget when the payload validates, otherwise fall back to a code block. +function renderDoc(markdown) { + return renderToStaticMarkup( + React.createElement( + ReactMarkdown, + { + components: { + pre: ({ children }) => { + let language = ""; + React.Children.forEach(children, (child) => { + if ( + React.isValidElement(child) && + typeof child.props?.className === "string" + ) { + const m = child.props.className.match(/language-(\S+)/); + language = m ? m[1] : ""; + } + }); + if (language === WIDGET_FENCE_LANGUAGE) { + const parsed = parseWidget(widgetFenceText(children)); + if (parsed.ok) { + return React.createElement( + "div", + { "data-widget-block": "", "data-type": parsed.widget.type }, + JSON.stringify(parsed.widget.rows ?? parsed.widget.metrics), + ); + } + } + return React.createElement("pre", {}, children); + }, + }, + }, + markdown, + ), + ); +} + +const fence = (body) => + `\u0060\u0060\u0060${WIDGET_FENCE_LANGUAGE}\n${body}\n\u0060\u0060\u0060`; + +test("e2e: a valid table payload renders as a widget, not a code block", () => { + const html = renderDoc( + fence('{"v":1,"type":"table","columns":["PR"],"rows":[["#15"]]}'), + ); + assert.match(html, /data-widget-block/); + assert.match(html, /data-type="table"/); + assert.doesNotMatch(html, /
/);
+});
+
+test("e2e: an unknown widget type degrades to a readable code block", () => {
+  const html = renderDoc(fence('{"v":1,"type":"kanban"}'));
+  assert.match(html, /
/);
+  assert.doesNotMatch(html, /data-widget-block/);
+  // The payload stays visible to the user rather than vanishing.
+  assert.match(html, /kanban/);
+});
+
+test("e2e: malformed JSON degrades to a code block", () => {
+  const html = renderDoc(fence("{not json"));
+  assert.match(html, /
/);
+});
+
+test("e2e: an ordinary code fence is untouched", () => {
+  const html = renderDoc("```js\nconst a = 1;\n```");
+  assert.match(html, /
/);
+  assert.doesNotMatch(html, /data-widget-block/);
+});
+
+test("e2e: hostile cell content is escaped in the rendered output", () => {
+  const html = renderDoc(
+    fence(
+      '{"v":1,"type":"table","columns":["c"],"rows":[[""]]}',
+    ),
+  );
+  assert.match(html, /data-widget-block/);
+  // No live tag reaches the DOM — it survives only as escaped text.
+  assert.doesNotMatch(html, / {
+  const html = renderDoc(
+    fence(
+      '{"v":1,"type":"table","columns":["c"],"rows":[[""]]}',
+    ),
+  );
+  assert.doesNotMatch(html, /