diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611d4cf44f75..e721a75e00a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,13 @@ jobs: - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + - name: Install Chromium for visualization security tests + working-directory: apps/web + run: vp exec playwright install chromium --only-shell --with-deps + + - name: Test HTML visualization sandbox + run: vp run --filter @t3tools/web test:html-visualization + # apps/server sets `fileParallelism: false`, so its 239 files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c6257221c737..2e4197090136 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -10,6 +10,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -872,6 +873,47 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + for (const resumeThreadId of [undefined, "saved-thread"]) { + it.effect( + `delivers runtime instructions while preserving custom instructions on ${resumeThreadId ? "resume" : "start"}`, + () => + Effect.gen(function* () { + const calls: Array<{ method: string; developerInstructions: string | null | undefined }> = + []; + const response = makeThreadOpenResponse("opened-thread"); + yield* openCodexThread({ + client: { + request: (method, payload) => { + calls.push({ method, developerInstructions: payload.developerInstructions }); + return Effect.succeed(response); + }, + raw: { + request: (method, payload) => { + calls.push({ method, developerInstructions: payload.developerInstructions }); + return Effect.succeed(response); + }, + }, + }, + configuredDeveloperInstructions: "Keep the user's existing coding conventions.", + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: undefined, + serviceTier: undefined, + resumeThreadId, + }); + NodeAssert.equal(calls.length, 1); + NodeAssert.equal(calls[0]?.method, resumeThreadId ? "thread/resume" : "thread/start"); + NodeAssert.equal( + calls[0]?.developerInstructions, + `Keep the user's existing coding conventions.\n\n${buildRuntimeInstructions({ harness: "Codex" })}`, + ); + NodeAssert.match(calls[0]?.developerInstructions ?? "", //); + NodeAssert.match(calls[0]?.developerInstructions ?? "", /t3-html/); + }), + ); + } + it.effect("resumes metadata when historical turns contain unknown error values", () => Effect.gen(function* () { const response = makeThreadOpenResponse("saved-thread"); @@ -927,6 +969,7 @@ describe("openCodexThread", () => { sandbox: "workspace-write", approvalsReviewer: "auto_review", excludeTurns: true, + developerInstructions: buildRuntimeInstructions({ harness: "Codex" }), }, }, ]); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a04db1405912..03a312aecc7b 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -43,6 +43,7 @@ import { buildCodexDeveloperInstructions, type T3CodeToolAvailability, } from "../CodexDeveloperInstructions.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -543,6 +544,7 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { } function buildThreadStartParams(input: { + readonly configuredDeveloperInstructions?: string | null | undefined; readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model: string | undefined; @@ -551,6 +553,13 @@ function buildThreadStartParams(input: { const config = runtimeModeToThreadConfig(input.runtimeMode); return { cwd: input.cwd, + // Collaboration-mode instructions can be persisted without reaching the model. + developerInstructions: [ + input.configuredDeveloperInstructions, + buildRuntimeInstructions({ harness: "Codex" }), + ] + .filter(Boolean) + .join("\n\n"), approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, @@ -717,6 +726,7 @@ interface CodexThreadOpenClient { } export const openCodexThread = (input: { + readonly configuredDeveloperInstructions?: string | null | undefined; readonly client: CodexThreadOpenClient; readonly threadId: ThreadId; readonly runtimeMode: RuntimeMode; @@ -727,6 +737,7 @@ export const openCodexThread = (input: { }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ + configuredDeveloperInstructions: input.configuredDeveloperInstructions, cwd: input.cwd, runtimeMode: input.runtimeMode, model: input.requestedModel, @@ -2368,8 +2379,13 @@ export const makeCodexSessionRuntime = ( yield* client.notify("initialized", undefined); const requestedModel = normalizeCodexModelSlug(options.model); + const { config } = yield* client.request("config/read", { + cwd: options.cwd, + includeLayers: false, + }); const opened = yield* openCodexThread({ + configuredDeveloperInstructions: config.developer_instructions, client, threadId: options.threadId, runtimeMode: options.runtimeMode, diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5e72586062e5..5b3f17d09e05 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -2,6 +2,10 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. `; +const HTML_VISUALIZATION_INSTRUCTIONS = ` +For a useful inline visualization, emit a complete top-level Markdown code fence with language t3-html and optional title="...". T3 Code web and desktop render these fences directly in the timeline after the assistant message finishes; mobile displays their source. Use self-contained HTML, inline CSS, and inline SVG. Native details/summary and CSS checkbox/radio toggles work. Scripts, event handlers, forms, navigation, external resources (including images and fonts), and access to the app are blocked. The visualization inherits the chat typography and live theme, and grows with its content. Use transparent backgrounds, currentColor, and the provided CSS variables: --foreground, --muted-foreground, --background, --card, --primary, --secondary, --accent, --border, --success, --warning, --destructive, --info, and --radius. Use these theme colors for graph strokes, fills, and labels instead of hardcoded palettes. Render the chart or diagram itself, without an outer card, page shell, duplicate title, or toolbar. Use responsive flow layout and width:100%; avoid fixed widths, viewport heights, nested scroll areas, and animations. Keep each visualization under 100,000 characters. Include a short plain-text explanation outside the fence so all clients can read the result. Ordinary html fences remain source code. +`; + /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ export function buildRuntimeInstructions(runtime: { readonly harness: string; @@ -13,7 +17,7 @@ export function buildRuntimeInstructions(runtime: { const effort = toSingleLine(runtime.reasoningEffort ?? ""); const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}\n\n${HTML_VISUALIZATION_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index fa567d75cf8a..3877b8c0a9a3 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -61,6 +61,10 @@ rl.on("line", (line) => { write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); return; } + if (method === "config/read") { + write({ id, result: { config: {}, origins: {} } }); + return; + } if (method === "skills/list" || method === "model/list") { write({ id, result: { data: [] } }); return; diff --git a/apps/web/package.json b/apps/web/package.json index 53dc91214dd8..c15bda1f2081 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "build": "vp build", "preview": "vp preview", "typecheck": "tsc --noEmit", - "test": "vp test run --passWithNoTests --project unit" + "test": "vp test run --passWithNoTests --project unit", + "test:html-visualization": "playwright test" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -34,6 +35,7 @@ "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", + "dompurify": "^3.4.15", "effect": "catalog:", "hast-util-to-html": "^9.0.5", "hast-util-to-jsx-runtime": "^2.3.6", @@ -56,6 +58,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@playwright/test": "^1.63.0", "@rolldown/plugin-babel": "^0.2.0", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 000000000000..17fbbcc30a21 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,12 @@ +import * as NodeOS from "node:os"; +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "html-visualization.spec.ts", + fullyParallel: false, + workers: 1, + outputDir: `${NodeOS.tmpdir()}/t3-html-visualization-${process.pid}`, + reporter: "list", + use: { browserName: "chromium", viewport: { width: 900, height: 700 } }, +}); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 4e22e68ca902..e33ca4fb27d3 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -72,6 +72,59 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("assistant HTML visualizations", () => { + it("keeps incomplete, streaming, ordinary, oversized, and non-assistant fences as source", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const complete = + '```t3-html title="Example"\n
MoreDetail
\n```'; + try { + for (const props of [ + { text: complete }, + { text: complete, allowHtmlVisualizations: true, isStreaming: true }, + { text: complete.slice(0, -3), allowHtmlVisualizations: true }, + { text: complete.slice(0, -3) + " ```", allowHtmlVisualizations: true }, + { text: complete.slice(0, -3) + "~~~", allowHtmlVisualizations: true }, + { text: complete.replace("t3-html", "html"), allowHtmlVisualizations: true }, + { text: "```t3-html\n" + "x".repeat(100_001) + "\n```", allowHtmlVisualizations: true }, + { + text: '
hello
', + allowHtmlVisualizations: true, + }, + ]) { + await act(async () => { + renderer?.unmount(); + renderer = create(); + }); + expect(renderer!.root.findAllByType("iframe")).toHaveLength(0); + expect(codeButton(renderer!, "Copy code").onClick).toBeTypeOf("function"); + } + await act(async () => { + renderer!.update(); + }); + expect(renderer!.root.findAllByType("iframe")).toHaveLength(1); + await act(async () => { + renderer!.root + .findAllByType(Button) + .find((button) => button.props.children === "Show source")! + .props.onClick(); + }); + expect(renderer!.root.findAllByType("iframe")).toHaveLength(0); + expect(codeButton(renderer!, "Copy code").onClick).toBeTypeOf("function"); + await act(async () => { + renderer!.root + .findAllByType(Button) + .find((button) => button.props.children === "Show visualization")! + .props.onClick(); + }); + expect(renderer!.root.findAllByType("iframe")).toHaveLength(1); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown context references", () => { it("renders text and image references through the chip renderer, with readable fallback", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b0209754b0d0..4a5411f756a6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -194,6 +194,8 @@ import { } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; +import { HtmlVisualization } from "./chat/HtmlVisualization"; +import { isHtmlVisualizationFence } from "../html-visualization"; interface ChatMarkdownProps { text: string; @@ -205,6 +207,8 @@ interface ChatMarkdownProps { environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; + /** Opt in only for assistant timeline messages. */ + allowHtmlVisualizations?: boolean; skills?: ReadonlyArray>; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ @@ -2220,6 +2224,7 @@ function useChatMarkdownState({ environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, + allowHtmlVisualizations = false, skills = EMPTY_MARKDOWN_SKILLS, onUseArtifactTemplate, imageBaseDir, @@ -2618,6 +2623,7 @@ function useChatMarkdownState({ const componentState = useMemo( () => ({ + allowHtmlVisualizations, cwd, diffThemeName, environmentId, @@ -2647,6 +2653,7 @@ function useChatMarkdownState({ updateThreadPullRequestLink, }), [ + allowHtmlVisualizations, cwd, diffThemeName, environmentId, @@ -3177,7 +3184,9 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); + const { resolvedTheme, diffThemeName, isStreaming, allowHtmlVisualizations, text } = use( + ChatMarkdownRendererContext, + ); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; @@ -3185,7 +3194,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); - return ( + const source = ( ); + const start = node?.position?.start.offset; + const end = node?.position?.end.offset; + if ( + allowHtmlVisualizations && + !isStreaming && + language === "t3-html" && + start !== undefined && + end !== undefined && + isHtmlVisualizationFence(text.slice(start, end), codeBlock.code) + ) { + return ( + + {source} + + ); + } + return source; }, } satisfies Components; diff --git a/apps/web/src/components/chat/HtmlVisualization.tsx b/apps/web/src/components/chat/HtmlVisualization.tsx new file mode 100644 index 000000000000..ccbfe9cb3127 --- /dev/null +++ b/apps/web/src/components/chat/HtmlVisualization.tsx @@ -0,0 +1,115 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { parseVisualizationHeight, visualizationDocument } from "../../html-visualization"; +import { Button } from "../ui/button"; + +const THEME_VARIABLES = [ + "background", + "foreground", + "card", + "card-foreground", + "primary", + "primary-foreground", + "secondary", + "secondary-foreground", + "muted", + "muted-foreground", + "accent", + "accent-foreground", + "border", + "ring", + "success", + "warning", + "destructive", + "info", + "radius", +] as const; + +export function HtmlVisualization({ + html, + title, + dark, + children, +}: { + html: string; + title: string; + dark: boolean; + children: ReactNode; +}) { + const [showSource, setShowSource] = useState(false); + const [generation, setGeneration] = useState(0); + const [height, setHeight] = useState(null); + const containerRef = useRef(null); + const frameRef = useRef(null); + // Theme changes travel through the bridge so open sections and checked controls survive. + const document = useMemo(() => visualizationDocument(html, false), [html]); + const syncTheme = useCallback(() => { + if (!containerRef.current || !frameRef.current?.contentWindow) return; + const styles = getComputedStyle(containerRef.current); + const css = [ + ...THEME_VARIABLES.map((name) => `--${name}:${styles.getPropertyValue(`--${name}`)}`), + `font-family:${styles.fontFamily}`, + `font-size:${styles.fontSize}`, + `line-height:${styles.lineHeight}`, + `color:${styles.color}`, + `color-scheme:${dark ? "dark" : "light"}`, + ].join(";"); + frameRef.current.contentWindow.postMessage({ type: "t3-visualization-theme", css }, "*"); + }, [dark]); + useLayoutEffect(() => { + if (!containerRef.current) return; + const receiveHeight = (event: MessageEvent) => { + if (event.source !== frameRef.current?.contentWindow) return; + const next = parseVisualizationHeight(event.data); + if (next !== null) setHeight(next); + }; + window.addEventListener("message", receiveHeight); + const observer = new MutationObserver(syncTheme); + observer.observe(window.document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + syncTheme(); + return () => { + window.removeEventListener("message", receiveHeight); + observer.disconnect(); + }; + }, [syncTheme]); + return ( +
+ {showSource ? ( + children + ) : ( + `; +} + +/** Only an explicitly closed, top-level visualization fence opts into rendering. */ +export function isHtmlVisualizationFence(source: string, html: string): boolean { + if (html.length > MAX_HTML_LENGTH) return false; + const lines = source.split(/\r?\n/); + const opening = /^ {0,3}(`{3,}|~{3,})t3-html(?:[ \t].*)?$/.exec(lines[0] ?? ""); + if (!opening) return false; + const fence = opening[1]!; + const closing = /^ {0,3}([`~]+)[ \t]*$/.exec(lines.at(-1) ?? "")?.[1]; + return ( + closing !== undefined && + closing.length >= fence.length && + [...closing].every((character) => character === fence[0]) + ); +} diff --git a/apps/web/tests/html-visualization.spec.ts b/apps/web/tests/html-visualization.spec.ts new file mode 100644 index 000000000000..19e9335e1a7d --- /dev/null +++ b/apps/web/tests/html-visualization.spec.ts @@ -0,0 +1,390 @@ +import { expect, test as base, type Page } from "@playwright/test"; +import { build } from "vite-plus"; +import type * as Visualization from "../src/html-visualization"; + +declare global { + interface Window { + T3Visualization: typeof Visualization; + visualizationMessages: unknown[]; + } +} + +const APP_URL = "https://visualization.test/"; +let bundle: string; + +const test = base.extend<{ unexpectedRequests: string[] }>({ + unexpectedRequests: [ + async ({ page }, use) => { + const requests: string[] = []; + await page.route("**/*", async (route) => { + if (route.request().url() === APP_URL && route.request().isNavigationRequest()) { + await route.fulfill({ + contentType: "text/html", + body: 'Visualization security tests

Parent document

', + }); + } else { + requests.push(route.request().url()); + await route.abort(); + } + }); + await page.goto(APP_URL); + await page.addScriptTag({ content: bundle }); + await page.evaluate(() => { + localStorage.setItem("visualization-test-secret", "parent-only"); + window.visualizationMessages = []; + addEventListener( + "message", + (event) => + window.T3Visualization.parseVisualizationHeight(event.data) === null && + window.visualizationMessages.push(event.data), + ); + }); + await use(requests); + expect(requests, "visualization must not initiate network requests").toEqual([]); + }, + { auto: true }, + ], +}); + +test.beforeAll(async () => { + const result = await build({ + configFile: false, + build: { + write: false, + lib: { entry: "src/html-visualization.ts", formats: ["iife"], name: "T3Visualization" }, + minify: false, + }, + }); + const output = Array.isArray(result) ? result[0] : result; + if (!output || !("output" in output)) throw new Error("Visualization bundle was not emitted"); + const chunk = output.output.find((entry) => entry.type === "chunk"); + if (!chunk) throw new Error("Visualization bundle contains no JavaScript"); + bundle = chunk.code; +}); + +async function mountVisualization(page: Page, html: string, themeCSS = "") { + await page.evaluate( + ({ source, theme }) => { + const frame = document.createElement("iframe"); + frame.id = "visualization"; + frame.title = "Visualization"; + frame.sandbox.value = "allow-scripts"; + frame.style.cssText = "display:block;width:480px;height:320px;border:0"; + frame.srcdoc = window.T3Visualization.visualizationDocument(source, false, theme); + addEventListener("message", (event) => { + if (event.source !== frame.contentWindow) return; + const height = window.T3Visualization.parseVisualizationHeight(event.data); + if (height !== null) frame.style.height = `${height}px`; + }); + document.body.append(frame); + }, + { source: html, theme: themeCSS }, + ); + const content = page.frameLocator("#visualization").frameLocator("iframe"); + await expect(content.locator("body")).toBeAttached(); + return content; +} + +test("preserves full-document styles and native interactions", async ({ + page, + unexpectedRequests, +}) => { + const content = await mountVisualization( + page, + ` + +
More information

Expanded content

+ +

Result

`, + ); + await expect(content.getByText("Expanded content")).toBeHidden(); + await content.getByText("More information").click(); + await expect(content.getByText("Expanded content")).toBeVisible(); + await content.getByLabel("Highlight result").check(); + await expect(content.locator(".result")).toHaveCSS("color", "rgb(12, 34, 56)"); + await expect(content.locator(".result")).toHaveCSS("font-weight", "700"); + await content.getByLabel("Highlight result").uncheck(); + await content.getByText("More information").click(); + await expect(content.getByText("Expanded content")).toBeHidden(); + expect(unexpectedRequests).toEqual([]); +}); + +test("removes scripts, hints, nested documents, and navigation attributes", async ({ + page, + unexpectedRequests, +}) => { + const content = await mountVisualization( + page, + ` + + + + + + + + + + Navigation probe + Popup probe +
`, + ); + await expect( + content.locator("body script, link, iframe, object, embed, base, body meta"), + ).toHaveCount(0); + await expect( + content.locator( + "[href], [src], [srcset], [action], [formaction], [ping], [autofocus], [onerror]", + ), + ).toHaveCount(0); + expect( + await content + .locator("template") + .evaluate((node) => (node as HTMLTemplateElement).content.childElementCount), + ).toBe(0); + await content.getByText("Navigation probe").click(); + await content.getByText("Popup probe").click(); + await content.getByRole("button", { name: "Submit probe" }).click(); + await expect(page.locator("#parent")).toHaveText("Parent document"); + await expect(page).toHaveURL(APP_URL); + expect(page.context().pages()).toHaveLength(1); + expect(await page.evaluate(() => window.visualizationMessages)).toEqual([]); + expect(unexpectedRequests).toEqual([]); +}); + +test("blocks CSS resource requests while preserving the visualization", async ({ + page, + unexpectedRequests, +}) => { + const content = await mountVisualization( + page, + `

CSS resource probe

`, + ); + await expect(content.getByText("CSS resource probe")).toHaveCSS("color", "rgb(1, 2, 3)"); + await content.locator("body").evaluate(async () => { + await document.fonts.ready; + }); + expect(unexpectedRequests).toEqual([]); +}); + +test("enforces sandbox and CSP even if active markup reaches the document", async ({ + page, + unexpectedRequests, +}) => { + const content = await mountVisualization(page, "

Runtime boundary probe

"); + const imageBlocked = page.waitForEvent("console", { + predicate: (message) => + message.text().includes("runtime-image.example.invalid") && + message.text().includes("Content Security Policy"), + }); + // Browser automation inserts this after sanitization to exercise the independent runtime boundary. + await content.locator("body").evaluate((body) => { + const script = document.createElement("script"); + script.textContent = "top.postMessage('runtime script escaped', '*')"; + body.append(script); + body.insertAdjacentHTML( + "beforeend", + ` + + Top navigation +
+ Self navigation`, + ); + const image = document.createElement("img"); + image.src = "https://runtime-image.example.invalid/image"; + body.append(image); + }); + await imageBlocked; + await content.getByRole("button", { name: "Handler probe" }).click(); + await content.getByText("Top navigation").click(); + await content.getByRole("button", { name: "Runtime submit" }).click(); + await expect(content.getByText("Runtime boundary probe")).toBeVisible(); + const navigationBlocked = page.waitForEvent("console", { + predicate: (message) => + message.text().includes("runtime-self.example.invalid") && + message.text().includes("frame-src"), + }); + await content.getByText("Self navigation").click(); + await navigationBlocked; + await expect(page).toHaveURL(APP_URL); + await expect(page.locator("#parent")).toHaveText("Parent document"); + expect(await page.evaluate(() => window.visualizationMessages)).toEqual([]); + expect(unexpectedRequests).toEqual([]); +}); + +test("sanitizes malformed SVG and MathML across browser reparsing", async ({ + page, + unexpectedRequests, +}) => { + const content = await mountVisualization( + page, + ` + + Safe SVG text +
+

Parser survived

`, + ); + await expect(content.getByText("Parser survived")).toBeVisible(); + await expect(content.locator("svg circle")).toHaveCount(1); + await expect( + content.locator("link, iframe, foreignObject, [href], [xlink\\:href], [src]"), + ).toHaveCount(0); + expect(unexpectedRequests).toEqual([]); +}); + +test("keeps parent DOM and storage inaccessible and oversized content clipped", async ({ + page, + unexpectedRequests, +}) => { + const content = await mountVisualization( + page, + `"> +
Tall content
Contained content
`, + ); + const isolation = await content.locator("body").evaluate(() => { + const denied = (read: () => unknown) => { + try { + read(); + return false; + } catch (error) { + return error instanceof DOMException && error.name === "SecurityError"; + } + }; + return { + parent: denied(() => parent.document.body), + top: denied(() => top!.document.body), + localStorage: denied(() => localStorage.getItem("visualization-test-secret")), + sessionStorage: denied(() => sessionStorage.length), + }; + }); + expect(isolation).toEqual({ parent: true, top: true, localStorage: true, sessionStorage: true }); + await expect(page.locator("body > iframe")).toHaveCount(1); + await expect(page.locator("#parent")).toHaveText("Parent document"); + await expect(page.locator("#visualization")).toHaveCSS("height", "10000px"); + expect(await page.locator("#visualization").boundingBox()).toMatchObject({ + width: 480, + height: 10000, + }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(900); + expect(unexpectedRequests).toEqual([]); +}); + +test("fits native interactions in both directions and preserves state across theme changes", async ({ + page, +}) => { + const content = await mountVisualization( + page, + ` + +
Graph
+
Breakdown
Details
+

Theme text

`, + "--foreground:rgb(12, 34, 56);font-family:monospace;font-size:16px;line-height:24px", + ); + const frame = page.locator("#visualization"); + await expect(content.getByText("Theme text")).toHaveCSS("color", "rgb(12, 34, 56)"); + await expect(content.locator("body")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(content.locator("body")).toHaveCSS("margin", "0px"); + await expect(content.locator("body")).toHaveCSS("padding", "0px"); + await expect(content.locator("body")).toHaveCSS("font-family", "monospace"); + await expect.poll(async () => (await frame.boundingBox())!.height).toBeLessThan(150); + const initialHeight = (await frame.boundingBox())!.height; + await content.getByLabel("Show graph").check(); + await expect.poll(async () => (await frame.boundingBox())!.height).toBe(initialHeight + 240); + await content.getByText("Breakdown").click(); + await expect.poll(async () => (await frame.boundingBox())!.height).toBe(initialHeight + 420); + await frame.evaluate((node) => + (node as HTMLIFrameElement).contentWindow!.postMessage( + { type: "t3-visualization-theme", css: "--foreground:rgb(65, 43, 21);color-scheme:dark" }, + "*", + ), + ); + await expect(content.getByText("Theme text")).toHaveCSS("color", "rgb(65, 43, 21)"); + await expect(content.getByLabel("Show graph")).toBeChecked(); + await expect(content.getByText("Details", { exact: true })).toBeVisible(); + await content.getByLabel("Show graph").uncheck(); + await content.getByText("Breakdown").click(); + await expect.poll(async () => (await frame.boundingBox())!.height).toBe(initialHeight); + await frame.evaluate((node) => { + (node as HTMLIFrameElement).style.width = "240px"; + }); + await expect(content.locator("body")).toHaveCSS("width", "240px"); + expect(await content.locator("body").evaluate(() => document.documentElement.scrollWidth)).toBe( + 240, + ); +}); + +test("rejects spoofed resize and theme messages and resists named DOM clobbering", async ({ + page, +}) => { + const content = await mountVisualization( + page, + `

Still isolated

`, + "--foreground:rgb(12, 34, 56)", + ); + const frame = page.locator("#visualization"); + await expect.poll(async () => (await frame.boundingBox())!.height).toBeLessThan(150); + const height = (await frame.boundingBox())!.height; + await page.evaluate(() => { + const target = document.querySelector("#visualization")!.contentWindow!; + window.postMessage({ type: "t3-visualization-height", height: 9000 }, "*"); + target.postMessage({ type: "t3-visualization-height", height: 9000 }, "*"); + // The top window is not the inner frame's direct parent. + target.frames[0]!.postMessage({ type: "t3-visualization-theme", css: "--foreground:red" }, "*"); + target.postMessage( + { type: "t3-visualization-theme", css: "--foreground:red;" + " ".repeat(16000) }, + "*", + ); + }); + await content + .locator("body") + .evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + expect((await frame.boundingBox())!.height).toBe(height); + await expect(content.getByText("Still isolated")).toHaveCSS("color", "rgb(12, 34, 56)"); + expect( + await page.evaluate(() => + [ + null, + {}, + { type: "t3-visualization-height", height: NaN }, + { type: "t3-visualization-height", height: Infinity }, + { type: "t3-visualization-height", height: 10001 }, + { type: "t3-visualization-height", height: 0 }, + { type: "t3-visualization-height", height: "200" }, + ].map(window.T3Visualization.parseVisualizationHeight), + ), + ).toEqual([null, null, null, null, null, null, null]); +}); + +test("embeds initial theme declarations without allowing style-element breakout", async ({ + page, +}) => { + const content = await mountVisualization( + page, + "

Theme boundary

", + '--foreground:rgb(12, 34, 56);--probe:""', + ); + await expect(content.getByText("Theme boundary")).toHaveCSS("color", "rgb(12, 34, 56)"); + await expect(content.locator("body script")).toHaveCount(0); + expect(await page.evaluate(() => window.visualizationMessages)).toEqual([]); +}); + +test("measures content outside normal document flow", async ({ page }) => { + const content = await mountVisualization( + page, + `
Absolute content
+
Fixed content
`, + ); + await expect(page.locator("#visualization")).toHaveCSS("height", "300px"); + await expect(content.getByText("Absolute content")).toBeVisible(); + await expect(content.getByText("Fixed content")).toBeVisible(); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a49328545a94..6d6fd2d570d5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -31,6 +31,8 @@ "vite.config.ts", "vercel.ts", "test", + "tests", + "playwright.config.ts", "scripts/warm-dep-cache.ts", "../../scripts/lib/public-config.ts", "../../scripts/lib/third-party-licenses.ts" diff --git a/docs/internals/html-visualizations.md b/docs/internals/html-visualizations.md new file mode 100644 index 000000000000..be1bb14aac6f --- /dev/null +++ b/docs/internals/html-visualizations.md @@ -0,0 +1,31 @@ +# HTML visualization isolation + +Assistant `t3-html` fences are untrusted documents. The renderer in +[HtmlVisualization.tsx](../../apps/web/src/components/chat/HtmlVisualization.tsx) +combines sanitization with two opaque sandboxed frames. Only fixed, CSP-hashed +application scripts may execute to measure content and synchronize the theme. +Neither frame grants `allow-same-origin`, and the bridge exposes no application +actions. Each hop validates the sending window; the client accepts only bounded, +finite heights. Theme updates preserve native control state. + +The outer document must remain entirely application-owned. Its CSP blocks the +inner frame's navigation; a single sandboxed frame can still navigate itself. +Both documents install their CSP before any generated content. Sanitizing the +content does not replace these browser-enforced boundaries. + +DOMPurify removes resource hints, nested documents, and URL attributes before +embedding. DNS-prefetch and preconnect hints can bypass CSP, so permitting `link` +or another generated `srcdoc` would reopen an outbound channel. Preserve this +restriction when changing sanitizer configuration. Styles are deliberately kept; +their resource loads are denied by CSP. + +Do not authorize generated scripts or interpolate content into the trusted bridge. +CSP is insufficient to disable every JavaScript network channel, including WebRTC, +so the hashes authorize only the fixed bridge code. Native HTML controls are the +supported interaction model. The source-size and height limits bound ordinary +rendering, but cannot guarantee a CPU or GPU budget for hostile HTML/CSS. + +The format travels as normal assistant text through shared provider instructions. +Only the assistant timeline opts into rendering. Other markdown surfaces and the +native mobile clients retain source; they must not reuse a more permissive file +preview as an inline visualization renderer. diff --git a/docs/user/composer.md b/docs/user/composer.md index f3be7a5f342d..83690567d8df 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,19 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +## Inline visualizations + +Ask any provider for an HTML visualization to see a chart, diagram, or comparison +inside its response on web and desktop. Providers use a `t3-html` Markdown fence +containing self-contained HTML, CSS, and optional inline SVG. Visualizations appear +after the message finishes, match your chat theme, and grow inline as you open +expandable sections or use checkbox toggles. Generated JavaScript, external +resources, and navigation are blocked. + +Use **Show source** to inspect or copy the HTML, and **Reset** to restore its initial +state. Mobile displays the source and accompanying explanation. Ordinary `html` +fences, unfinished fences, and visualizations over 100,000 characters stay as code. + ## Attach files Attach up to eight files per message. Images can be up to 10 MB; other files can diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e970b9ee61b5..99b4bb04c049 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,6 +633,9 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + dompurify: + specifier: ^3.4.15 + version: 3.4.15 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) @@ -694,6 +697,9 @@ importers: '@effect/vitest': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.2.5) @@ -4270,6 +4276,11 @@ packages: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -5468,6 +5479,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -6949,6 +6963,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.15: + resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -9481,6 +9498,16 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -14875,6 +14902,10 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@polka/url@1.0.0-next.29': {} '@preact/signals-core@1.14.2': {} @@ -16013,6 +16044,9 @@ snapshots: '@types/statuses@2.0.6': optional: true + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -17510,6 +17544,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.15: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -20757,6 +20795,12 @@ snapshots: playwright-core@1.60.0: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13