feat: sandboxed JS runtime + HTML preview panel - #122
Open
scolastico wants to merge 47 commits into
Open
Conversation
|
Replaces the hardcoded javascript/html SandboxMode union with a SandboxRunner registry so later tasks can register additional interpreters (Python, Ruby, SQL, React, ...) without touching srcdoc generation. buildCsp and buildSrcdoc now take a runner plus origin so only vendor-enabled runners widen the CSP to the app origin.
worker-src blob: was emitted unconditionally, letting plain javascript/html notes spawn blob: Workers they couldn't before this refactor. Only declare worker-src when a runner sets vendorOrigin or scriptBlob, restoring the pre-refactor default-src 'none' fallback for everyone else. Also replaces a substring-based CSP assertion with a tokenized directive check: ORIGIN's own text contains "https:", so the previous assertion could not detect an allowNetwork/vendorOrigin flag conflation. Restores a dropped TOKEN-ordering assertion in the html-runner test.
Adds interpreter packages as devDependencies and a postinstall script that copies only the files each runner actually loads into public/vendor/ (a gitignored, regenerated-on-install directory), so multi-language sandbox runners can load them from the app's own origin instead of a CDN.
wasmoon's LuaFactory defaults customWasmUri to an unpkg.com URL, and pyodide's loadPyodide() unconditionally sets a jsdelivr.net package base URL. Both violate the zero-external-request mandate even though the sandbox CSP already blocks them at the network layer when off, since the user can enable network access for their own code. Add a post-copy sanitisation pass that rewrites any known CDN origin to the non-resolving vendored.invalid across all copied assets, plus a test enforcing it tree-wide so a future dependency bump can't reintroduce one silently. Also tighten five per-engine size budgets that were loose enough to hide a partial regression.
Replace the single-runner computed with an engine selector so a note's language can resolve to multiple runners: an engineId ref backed by a dropdown, an availableRunners list derived from runnersForLanguage, and a runner computed that falls back to the first available runner when no engine is explicitly selected. Heavy runners now default autoRun to off (on mount and on runner change) so switching to a slow wasm interpreter doesn't re-boot it on every keystroke. Also clarifies the run-warning dialog to mention that a language runtime may be fetched from the app's own server.
Add a 6px pointer-events resize handle on the panel's left edge. Pointer capture keeps the drag alive once the pointer leaves the narrow handle; width is clamped to 20-80% of the parent's width and applied via a CSS custom property so the panel stays full-width on mobile (the sm: breakpoint is what actually engages the variable width). The iframe gets pointer-events disabled while dragging so it doesn't swallow the pointermove stream. Width state is transient and intentionally not persisted.
Adds buildPopoutDocument(), which renders an app-origin host page whose own inline script validates event.source and the per-run token before turning console messages into text nodes, keeping the note's code inside a nested iframe with the same sandbox flags as the panel. Wires a Popout button into the sandbox panel that snapshots the current run's srcdoc and token into a new window.
…x panel - Bind lostpointercapture on the resize handle so capture lost without a pointerup/pointercancel (e.g. alt-tab mid-drag) no longer leaves resizing stuck true, which had been permanently disabling pointer events on the preview iframe. - Bail out of onResizeMove when the parent rect has zero width instead of letting the math produce NaN/Infinity, which corrupted the --sandbox-w custom property and collapsed the panel to width: auto. - Seed engineId from the first available runner in onMounted so the engine <select> shows the runner that actually executes on first mount, instead of rendering blank until the user touches it.
…uard) Post-review hardening for the sandbox popout, which runs in the same renderer process as the app (same-origin, live window.opener): - Cap rendered console lines at 500 (mirrors the panel's MAX_ENTRIES), evicting the oldest line so a note that logs in a tight loop can no longer grow the DOM until the shared renderer is killed. - Whitelist the console level against CONSOLE_LEVELS from lib/sandbox/protocol.ts before using it, closing the seam where an attacker-chosen level could probe object internals via COLORS[d.level] and preventing the popout's notion of "valid level" from drifting from the protocol's. Documents that any future %c-style formatting in the popout must route CSS through sanitizeConsoleCss first. - Guard against event.source === null matching a detached iframe's frame.contentWindow === null. Also replaces a near-vacuous escaping test with a real DOMParser round-trip across four adversarial srcdoc payloads, asserting byte-exact attribute round-trip and no injected <script>/<iframe>.
Boots Pyodide from vendored assets only (indexURL points at public/vendor/pyodide/), relays stdout/stderr to the console, and auto-loads wheels for imported packages from the local index — with a clear console warning when an import isn't vendored instead of a silent hang. Registered as the default engine for "python" and marked heavy so the panel doesn't auto-run on every keystroke.
Downloads numpy, micropip and packaging (plus their transitive dependencies) from the pyodide CDN into public/vendor/pyodide at build time only — never at runtime — verifying each wheel's sha256 against the vendored pyodide-lock.json. Soft-fails on any network or verification error so a network-less build still produces a working, stdlib-only Python. Wired into postinstall and the Dockerfile image build, right after copy-sandbox-vendor. Measured: 3/3 wheels fetched, public/vendor/pyodide grows to ~25 MB, still under its existing 30 MB budget.
Lua boots wasmoon from a vendored <script> tag and reads the UMD global
off window.wasmoon rather than dynamic import()'ing it as an ES module:
public/vendor/wasmoon/index.js has no `export` declarations, so
`await import(...)` resolves to an empty module namespace and
destructuring LuaFactory from it would silently be undefined at
runtime (verified by reading the vendored file). The <script src>
pattern used by every other console runner here sidesteps that
entirely. LuaFactory is constructed with the vendored glue.wasm path
so it never falls back to unpkg.com.
Ruby boots ruby.wasm via the side-effect-free UMD build
(browser.umd.js), fetching ruby+stdlib.wasm and compiling it with
WebAssembly.compile (not compileStreaming, so it's independent of the
Content-Type the static host serves .wasm with), then calling
window["ruby-wasm-wasi"].DefaultRubyVM explicitly. Deliberately does
NOT use <script type="text/ruby">: that mechanism lives in
browser.script.iife.js, which hardcodes a cdn.jsdelivr.net fetch on
load and is intentionally not vendored.
Both registered as the default engine for their language ("lua",
"ruby" — no collisions with existing runners); Ruby is marked heavy
since booting the interpreter is slow, Lua is light.
Adds two SQL engines: sql.js/SQLite (default) and PGlite/PostgreSQL (selectable, heavy). Registration order in runners/index.ts keeps sql.js as the default engine for "sql" notes.
php-tags.mjs's <script type="text/php"> tag-scanning mechanism only writes stdout/stderr into the DOM when the tag carries a data-stdout / data-stderr attribute; without one it silently discards all output. Uses the PhpWeb class directly instead (php-wasm's other documented entrypoint), wiring output/error events explicitly so echoed HTML renders in the preview pane and errors reach the console bootstrap. Vendor path/copy list updated to PhpWeb.mjs; php-tags.mjs is no longer copied since nothing loads it.
JSCPP matches the brief's assumed API exactly (global JSCPP, run(code,
input, { stdio: { write } })). picoc-js needed two corrections: its
dist/bundle.js is an ES module with top-level `import ... from 'path'`
(a Node builtin) that browsers cannot resolve, and its actual export
is `runC(cprog, consoleWrite)` — a single callback, not an
`{ output, error }` options object. Uses dist/bundle.umd.js as a
plain <script> tag instead (window.picocjs.runC), the same pattern
already used for wasmoon.
No maintained TinyCC wasm package exists on npm (tcc is native Node
bindings), so C uses the PicoC interpreter as noted in the brief.
Corrects the plan's brief: svelte/compiler/index.js is a UMD bundle (package.json's ESM entry for ./compiler is src/compiler/index.js, not the vendored prebuilt bundle), so it is loaded via a classic <script src> and the window.svelte UMD global rather than await import(). Also adds esm-env and clsx to the vendor pipeline and import map: they are svelte's own transitive runtime deps, reachable via real (non-JSDoc) bare imports from the internal/client module graph, and were missing from the brief's import map.
…x assets package.json's postinstall now runs scripts/copy-sandbox-vendor.mjs and scripts/fetch-pyodide-wheels.mjs, but the Dockerfile ran pnpm install before COPY . ., so those scripts didn't exist yet and pnpm install exited non-zero, breaking the Docker image build (used by the publish and nightly workflows). Moving COPY . . before pnpm install and dropping the now-redundant explicit vendor RUN fixes it, at the cost of losing dependency-install layer caching: any source change now re-runs pnpm install on the next build.
… false positives React runner now JSON-embeds the note and invokes Babel.transform explicitly instead of splicing raw source into a type="text/babel" tag, which broke on a literal <script> element inside JSX and on unbalanced <!--<script> HTML comments. Tightened jsx/mermaid detection patterns to stop matching mail-merge placeholders, plain DOM className assignment, and prose containing arrow-like text, and replaced coffeescript's unless/until pattern (shared with Ruby) with distinctive fat-arrow and for-of signals. Added a securityLevel assertion to the mermaid runner test.
…n load runner assets The Run/Preview panel executes note code in an <iframe sandbox="allow-scripts allow-modals"> with no allow-same-origin, so it has a permanently opaque origin and is cross-origin to our own server. Classic <script src> tags still load in no-cors mode, but fetch() and dynamic import() (used by the python, ruby, lua, sql.js, pglite, php and svelte runners to load .wasm/.mjs interpreter assets) are blocked without an Access-Control-Allow-Origin header, breaking about half of the 14 language runners in production. Add the header, scoped to /vendor only, in both the Docker entrypoint server and nuxt.config.ts routeRules (for dev/nitro-served builds). These are public, credential-free interpreter binaries with no cookies or user data reachable through /vendor, so a wildcard origin is safe.
The Run/Preview panel picks a runner straight from
detectLanguageFromContent's verdict, so a language misdetection no longer
just disables a button — it silently routes the note through the wrong
interpreter.
Two verified collisions:
- A plain `function greet(name) { ... }` declaration scored higher on lua
than on javascript, because lua.ts's generic function pattern
(`\bfunction\s+\w+[.:]?\w*\s*\(`) also matches brace-bodied, C-family
declarations. Lua function headers are never brace-terminated (blocks
close with `end`), so anchor the pattern on a header whose line ends in
nothing but the closing paren.
- Ordinary DOM/console JS (`document.querySelector(...)`,
`console.log(...)`) scored 0 against javascript.ts's four narrow patterns,
letting less.ts's generic `.method();` pattern win. Add JS-specific
signals (console.* calls, document./window. access, const/let
declarations, arrow functions, template literals with ${}, ===/!==) with
weights tuned to decisively win real JS/DOM code without outscoring
TypeScript, JSX, or CoffeeScript on their own samples.
Added tests/lib/monaco/runner-language-collisions.test.ts as a regression
suite covering both collisions plus real Lua, Python, Ruby, CoffeeScript,
React/JSX, Mermaid, Vue SFC and Svelte samples, so a future language
definition can't silently hijack another one's notes again.
less.ts, rust.ts and xml.ts are intentionally untouched: none of those
three have a sandbox runner, so a misdetection into them only disables the
Run button (pre-existing behaviour), unlike the lua/less collisions above
which actively executed JS in the wrong interpreter.
…svelte notes
xml.ts had two catch-all patterns at weight 2 — one matching any tag, one
matching any open/close pair — both uncapped beyond MAX_MATCHES_PER_PATTERN
(10). Any markup document scored ~20-40 purely from tag count, drowning out
html.ts (caps ~11), vue.ts and svelte.ts (~7-8) on their own, far more
specific patterns. Since html, vue and svelte all have sandbox runners
(html shipped in an earlier PR; vue/svelte are added by this branch),
ordinarily-decorated notes in any of those three languages lost their
auto-detected Run button entirely, forcing a manual language pick every
time — the same "generic pattern outscores a specific one" failure already
fixed for lua vs. javascript, just with three runners at stake instead of a
wrong interpreter.
Replaced the two catch-alls with genuinely XML-specific signals: the
<?xml …?> declaration/prolog, xmlns/xmlns: namespace declarations,
namespace-prefixed tags (<ns:tag>), CDATA sections, non-prolog processing
instructions, and DTD SYSTEM/PUBLIC references (narrower than a bare
doctype, since every ordinary HTML5 page's `<!DOCTYPE html>` was quietly
counting as an xml signal here too). Kept one deliberately tiny (weight
0.05) generic-tag fallback so bare, prolog-free XML snippets still beat
plaintext, but far too weak to ever outscore a real language's own pattern.
Accepted tradeoff: a namespace-free, prolog-free XML document with no DTD
reference may now detect as html instead of xml. Both are markup, xml has
no sandbox runner either way, and the misdetection costs nothing — unlike
the previous behaviour, which cost html/vue/svelte their auto-detection on
any realistically-decorated document.
Extended tests/lib/monaco/runner-language-collisions.test.ts with a
realistic (not trimmed-down) HTML page, a minimal HTML fragment, a
realistic Vue SFC (template + script setup + scoped style), a realistic
Svelte component ($state, {#if}, on:click, a style block), and real XML
with a prolog and xmlns declarations. less.ts and rust.ts remain untouched
and out of scope (still no runner; "prose with arrows" still resolves to
rust and a plaintext form-letter still resolves to xml when it lacks any of
the signals above — both pre-existing and low severity).
A small fragment like `<h1 style="...">Hello</h1>` followed by an inline `<script>console.log(...)</script>` tied at 2 points apiece between html (only its <script>...</script> pattern fired) and javascript (its console.log pattern from the previous commit), and javascript won the tie by array order — close to the HTML-preview example in the PR description, so this is a realistic paste, and running it through the JS runner produces a syntax error instead of a preview. Added two low-weight patterns for common text/block elements (h1-h6, p, span, ul/ol/li, table/tr/td/th, button, section) that html.ts previously had no signal for at all: one for the element carrying a real attribute, one for it wrapping actual text with a matching closing tag. Both require more than a bare tag-name mention, so a stray "table" in prose doesn't count. Verified against the full regression suite that this doesn't disturb the xml.ts fix from the previous commit: the realistic Vue SFC (dense with exactly these tags in its template) still resolves to vue by a wide margin (10 vs 5), the realistic Svelte component still resolves to svelte (8 vs 4), and real XML with a prolog is unaffected. Added the reported fragment to tests/lib/monaco/runner-language-collisions.test.ts as a pinned regression case.
| mimeTypes: ["text/x-vue"], | ||
| detectionPatterns: [ | ||
| { pattern: /^\s*<template>/m, weight: 3 }, | ||
| { pattern: /^\s*<script(\s+setup)?(\s+lang="ts")?\s*>/m, weight: 2 }, |
| // vendored.invalid uses the RFC 2606 reserved .invalid TLD, which can never | ||
| // resolve in DNS — any code path that still tries to fetch it fails loudly | ||
| // (offline) instead of silently reaching a real host. | ||
| const CDN_HOSTS = ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com", "esm.sh"]; |
| // vendored.invalid uses the RFC 2606 reserved .invalid TLD, which can never | ||
| // resolve in DNS — any code path that still tries to fetch it fails loudly | ||
| // (offline) instead of silently reaching a real host. | ||
| const CDN_HOSTS = ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com", "esm.sh"]; |
| // resolve in DNS — any code path that still tries to fetch it fails loudly | ||
| // (offline) instead of silently reaching a real host. | ||
| const CDN_HOSTS = ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com", "esm.sh"]; | ||
| const CDN_PATTERN = new RegExp(`https://(?:${CDN_HOSTS.map((h) => h.replace(/\./g, "\\.")).join("|")})`, "g"); |
Deployments served from a subpath (the PR previews live at /pr-preview/pr-<n>/) keep public/vendor behind that prefix, but buildSrcdoc composed vendor URLs from window.location.origin alone. Every vendor-backed runner therefore 404'd there, e.g. PGlite failing with "error loading dynamically imported module". buildSrcdoc now takes the app base path (runtimeConfig.public.uiBaseURL, i.e. NUXT_APP_BASE_URL) and composes it into the vendor prefix. The CSP is origin-based and so is deliberately left unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a CodePen-style Run / Preview panel executing 16 engines across 15 languages in a
locked-down sandbox — console + language-aware REPL for code, live preview for markup, a table
browser for SQL — without weakening !3's client-side-encryption model. Every interpreter is
self-hosted; the feature makes zero requests to any external host.
How it works
Running a note builds a complete HTML document in memory and hands it to an iframe via
srcdocwith
sandbox="allow-scripts allow-modals". Becauseallow-same-originis absent, the iframe getsan opaque origin: note code cannot touch the app's DOM,
localStorage, cookies, the decryptionkey, or the note plaintext held by the editor. Every document also carries a restrictive CSP
(
default-src 'none', noconnect-srcunless the user opts in) andreferrerpolicy="no-referrer".A bootstrap script injected first in
<head>patchesconsole.*,window.onerrorandunhandledrejection, and relays them to the panel overpostMessage. Each run mints a randomtoken; the panel ignores any message that does not carry the current token and come from that
exact iframe's
contentWindow.Security invariants
allow-same-originorallow-top-navigation— including theiframe inside the popout window.
action. The
/popoutpage runs nothing until its opener sends state.event.source === iframe.contentWindowand theper-run token matches. Opener ↔ popup messages additionally require
event.origin === window.location.originand the exact expectedevent.source.untrusted and reaches app-origin documents only through Vue text interpolation — never
innerHTML, never a string-built attribute or style.%cconsole CSS is filtered bysanitizeConsoleCssagainst a property allowlist.engine's own catalog inside the iframe before being quoted into a query; search terms are
always bound parameters. Covered by a test that sends
sqlite_master" --and expects a refusal.Supported languages
evalLanguages with two engines expose a selector in the panel toolbar; the first entry is the default.
Panel features
the live database, Python notes get a Python prompt sharing the note's interpreter globals, and
everything else keeps JavaScript
eval. A runner opts in by installingwindow.__not3Eval__.them: sortable columns, substring search, 50-row pages. Queries run inside the iframe against
the live database and only one page crosses the
postMessageboundary at a time, so a large dumpnever gets copied wholesale. Empty databases say "No tables found."
its own window and leaves the editor fullscreen. Edits keep streaming to the popup, so auto-run
still works there; closing the popup brings the panel back, and closing or navigating the editor
tab closes the popup.
no longer resets the checkbox. Heavy interpreters (wasm VMs) still default to manual runs.
smbreakpoint the editor and panel stack (editor on top, panel on the bottom half) and the panel
carries its own Close button, since the nav one is
hidden sm:flex.Self-hosting
scripts/copy-sandbox-vendor.mjs(postinstall) copies each interpreter out ofnode_modulesintopublic/vendor/— selectively, never whole packages — andscripts/fetch-pyodide-wheels.mjsfetches pinned Pyodide wheels at build time. A sanitisation pass rewrites hardcoded CDN references
found inside vendored packages (pyodide, wasmoon), and tests assert both the per-engine size budgets
and the absence of any
cdn.jsdelivr.net/unpkg.com/cdnjs.cloudflare.com/esm.shstring inthe vendored tree.
public/vendor/is gitignored and regenerated on install: the copy script reports20/20 engine entries and 90.1 MB across 15 directories, ~103 MB on disk once the Pyodide wheels are
added — against a 200 MB total budget asserted by the tests.
Notable fixes
/vendor, without which the opaque-origin iframe cannot load any interpreter./pr-preview/pr-<n>/) work.jsx/mermaid/coffeescript false positives).
worker-srcis onlydeclared when a runner actually needs it).
std::-qualified hello world wasa hard parse error. The runner now strips
std::qualifiers (what JSCPP expects for the subset itimplements) and says so in the console.
reactpreset leaves ES module syntax untouched, soexport default function App()died inevalwithUnexpected token 'export'. Now compiled withtransform-modules-commonjsand executed withrequire/module/exportsshims; the component isresolved from
exports.default,exports.App, or a top-levelApp.vue.global.prod.js, which strips every runtime warning, so brokennotes were completely silent. Now ships the dev build with explicit
warnHandler/errorHandler,and template warnings plus render/lifecycle errors reach the console.
navigator.locks.request, and the Web LocksAPI rejects in opaque-origin contexts — the first run always died as an unhandled rejection. A
promise-chain mutex now shadows
navigator.locksbefore the module loads (one iframe is one queue,so cross-tab locking was meaningless here anyway).
Testing
pnpm test— 161 unit tests in 25 files (Vitest + happy-dom): protocol validation and clamping,the console bootstrap (including
%cformatting and the REPL/table hooks), CSP construction,srcdoc assembly, per-runner document generation, the vendor pipeline, auto-run memory, and the
popout message validator.
pnpm test:runners— 20 tests in real Chromium (Playwright, new): every engine actuallyexecutes against the vendored assets in a genuinely sandboxed, opaque-origin iframe. This exists
because string-matching generated HTML cannot catch the bug class that produced most of the fixes
above — UMD-vs-ESM loading, wasm boot failures, a rejected Web Locks call, Babel leaving module
syntax alone, a build that silently swallows warnings. It also covers the REPL for Python and SQL,
the table/rows protocol end-to-end, and the SQL identifier-validation refusal. Not part of
pnpm test: it needs a browser andpublic/vendor/populated.Deviations & out of scope
@clickbound to a nonexistent method stays silent in Vue. This was expected to be fixed bythe dev build, but the real cause turned out to be different, and is a Vue design decision rather
than a sandbox bug: the SFC compiler turns
@click="asd"into a cached inline handler(
_cache[0] = () => _ctx.asd && _ctx.asd(...)), so the missing property is read on click ratherthan during render, and Vue's "accessed during render" warning is gated on there being a rendering
instance. Neither build warns, and
vue3-sfc-loaderexposes no way to disable handler caching.The note still renders and the click is simply inert; a Playwright test documents the behaviour.
The dev build is still a real improvement for everything that is render-time.
__not3Eval__extension point is in place.split, save/load against
../api's docker-compose) would need the built Nuxt app and the APIcontainer; it is a possible follow-up. The popout flow, mobile split and SQL table UI were
verified manually in Chromium against the dev server for this PR.
no editable table cells.
🤖 Generated with Claude Code