From 656c14caf391fedaaa0e7c8d5de1542a0bc3ebe7 Mon Sep 17 00:00:00 2001 From: Athena Date: Sun, 9 Aug 2026 17:32:17 -0500 Subject: [PATCH 1/4] feat(preview): add sandboxed live code previews Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions. Co-authored-by: Cursor --- bin/check.sh | 10 + interface/web/package-lock.json | 19 + interface/web/package.json | 2 + interface/web/src/Markdown.jsx | 399 +++++++++- interface/web/src/Playbook.jsx | 2 +- interface/web/src/preview/jsx-transform.js | 686 ++++++++++++++++++ .../web/src/preview/jsx-transform.test.js | 251 +++++++ interface/web/src/preview/languages.js | 99 +++ interface/web/src/preview/runtime.js | 42 ++ synapse/chat.py | 244 ++++++- synapse/main.py | 65 +- synapse/tools.py | 671 +++++++++++++++++ tests/test_tools.py | 620 +++++++++++++++- 13 files changed, 3079 insertions(+), 31 deletions(-) create mode 100644 interface/web/src/preview/jsx-transform.js create mode 100644 interface/web/src/preview/jsx-transform.test.js create mode 100644 interface/web/src/preview/languages.js create mode 100644 interface/web/src/preview/runtime.js diff --git a/bin/check.sh b/bin/check.sh index 177d8ab..c060d53 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -25,6 +25,16 @@ else echo "-- skipped: interface/web/node_modules missing (npm install)" fi +echo "== frontend unit tests ==" +# The JSX/TSX transform behind the preview window is a pure module with a +# node --test suite. Nothing else in the frontend has tests, so this is cheap; +# without it the transform's silent-wrong cases go unguarded. +if [ -d interface/web/node_modules ]; then + (cd interface/web && npm test) || fail=1 +else + echo "-- skipped: interface/web/node_modules missing (npm install)" +fi + echo "== powershell parse ==" # The Windows installer has died at parse twice. Cheap to catch here if pwsh # happens to be installed on the Linux box; the ASCII guard in tests/ is the diff --git a/interface/web/package-lock.json b/interface/web/package-lock.json index 0d46442..3772f87 100644 --- a/interface/web/package-lock.json +++ b/interface/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "1.2.0", "dependencies": { + "preact": "^10.29.8", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -2236,6 +2237,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/interface/web/package.json b/interface/web/package.json index f8413a2..9845cdd 100644 --- a/interface/web/package.json +++ b/interface/web/package.json @@ -10,9 +10,11 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test src/preview/jsx-transform.test.js", "preview": "vite preview" }, "dependencies": { + "preact": "^10.29.8", "react": "^19.2.4", "react-dom": "^19.2.4" }, diff --git a/interface/web/src/Markdown.jsx b/interface/web/src/Markdown.jsx index 127f018..35370f5 100644 --- a/interface/web/src/Markdown.jsx +++ b/interface/web/src/Markdown.jsx @@ -1,4 +1,11 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; + +// Which languages get a live sandboxed preview (RenderBlock) instead of a plain +// syntax block (CodeBlock), and how each becomes a document body, lives in +// ./preview/languages.js. A language like `js` is deliberately absent — +// auto-executing bare script isn't this feature's job (see RenderBlock's doc +// comment for the sandboxing model). +import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js"; // Parse content into an array of {type, value, lang, streaming} blocks. // Handles: @@ -51,11 +58,13 @@ export function Markdown({ content }) { const blocks = parseBlocks(content); return (
- {blocks.map((block, i) => - block.type === "code" - ? - : - )} + {blocks.map((block, i) => { + if (block.type !== "code") return ; + const lang = (block.lang || "").toLowerCase(); + return RENDERABLE_LANGS.has(lang) + ? + : ; + })}
); } @@ -117,6 +126,384 @@ function CodeBlock({ lang, value, streaming }) { ); } +// Content-Security-Policy for the rendered preview. Together with the iframe's +// `sandbox` attribute below, this is the entire trust boundary for model- +// authored HTML/SVG, so it stays conservative rather than convenient: +// - script-src/style-src 'unsafe-inline' inline ` below is safe unescaped because this module is emitted as an +// external .js asset - it is never inlined into index.html, where the HTML +// parser would end the surrounding script tag early. +// +// postMessage is the one channel an opaque-origin sandboxed frame still has to +// the parent, and this is the entire protocol over it: one message shape, +// outbound only, carrying a content height and an error string. Nothing flows +// the other way. The parent treats both fields as untrusted data - the height +// is clamped and the message is rendered as text, never as markup - because +// they were produced by the same code the sandbox exists to contain. +// +// Without this the frame is silent: a preview whose script throws just renders +// blank, which is why the server-side validator in synapse/tools.py has to +// guess at runtime failures it can't observe. +const _PREVIEW_BOOTSTRAP = ``; + +// Substituted with the real line offset once the document is assembled and its +// shell can be measured. Sits on one line so replacing it can't shift any. +const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__"; + +/** + * Build the sandboxed document for a fence. Returns {doc, error}: a language + * whose source doesn't parse (JSX, today) has no document to show, and the + * caller renders the message instead of a frame. + * + * The shell - charset, CSP, bootstrap - is identical for every language; only + * the body differs, so only that part goes through the registry. Nothing about + * the sandboxing is per-language and shouldn't be: SVG can carry ` + + _PREVIEW_BOOTSTRAP + + ""; + + // Lines of shell above the user's own code: the document head, plus whatever + // the language puts in the body ahead of it (the Preact build, for JSX). + const offset = (head.match(/\n/g) || []).length + body.userOffset; + + return { + doc: (head + body.html + "").replace(_OFFSET_TOKEN, String(offset)), + error: "", + }; +} + +// Auto-height bounds. The frame is sized from content, and content sized in +// viewport/percentage units is therefore sized from the frame - a body with its +// own margin makes that loop grow by the margin on every pass. Measuring the +// body box rather than documentElement is what actually settles that loop; +// _MAX_PREVIEW_H then caps anything still climbing within a few iterations. +// +// _MAX_H_STEPS is only a last resort against a document that oscillates +// forever, so it is generous: an interactive component legitimately changes +// height on every click, and a tight budget would freeze the frame mid-session +// at whatever size it happened to reach. +const _MIN_PREVIEW_H = 160; +const _MAX_PREVIEW_H = 720; +const _MAX_H_STEPS = 60; + +// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle, +// rendered via a sandboxed