Q2 sandboxed preview - #676
Merged
Merged
Conversation
The sandboxed preview format previously mapped to ("html", None) so
ReactPreview fed it the raw parse-only AST — no highlight spans, no
chrome metadata, no theme fingerprint. Give it the same pipeline_kind
as q2-preview on both sides of the mirror (builtin_pseudo_format in
quarto-core, pipelineKindForFormat in preview-runtime), tests first.
Phase 0 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md
(bd-jgpz4hfq, epic bd-r9yr0hbe). Also adds the plan document.
Verification: cargo nextest run --workspace (39 pre-existing
environmental failures — pandoc not on PATH etc. — identical set with
change stashed); cargo xtask verify --skip-rust-tests fully green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the toy AstRenderer in quarto-hub-sandboxed-preview with the
actual @quarto/preview-renderer renderer, imported unmodified from
package source via a Vite alias (q2-preview itself is untouched):
- src/entry.tsx: adapted copy of the q2-preview iframe entry.
Differences are all cross-origin consequences: UPDATE_THEME carries
CSS *text* (parent blob URLs are origin-scoped and unreachable from
the sandbox) and the entry mints its own blob URL; the asset-proxy
service worker registers before IFRAME_READY; message handling uses
the promise-ordered dispatcher (copied as iframeMessageDispatch.ts)
instead of the original 50ms polling gate.
- src/stubs/preview-runtime.ts: the q2-preview barrel drags the
parent-side Q2PreviewIframe/assetWalker (WASM-coupled) into the
module graph; stub @quarto/preview-runtime so the sandboxed bundle
resolves without any WASM machinery.
- Build: React 18→19 (match preview-renderer), drop vite-plugin-
singlefile in favor of a normal multi-file dist (base './'), copied
to hub-client/public/q2-sandboxed-preview/ (now gitignored; the old
committed single-file artifacts are removed).
- Parent side (TDD, tests first): Q2SandboxedPreviewIframe now takes
currentFilePath + three-way themeFingerprint and posts
UPDATE_THEME {cssText}; ReactRenderer passes both.
- Deploy/serve: GH Pages workflow gains a root npm ci (renderer source
resolves deps from root node_modules) and path triggers for
preview-renderer/resources; q2-sandboxed-preview-server.mjs serves
the dist dir; docs updated.
Verified end-to-end: headless-chromium smoke against the served dist
posted UPDATE_AST + UPDATE_THEME and rendered <h1>Hello sandbox</h1>
with the posted theme color and an active service worker. hub-client:
typecheck, vitest (133 passed incl. 4 new red-first tests), and
npm run build:all all green.
Phase 1 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md
(bd-44ohor2w, epic bd-r9yr0hbe).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Phase 2) Assets now travel through a dedicated page-relative proxy namespace, __q2_vfs__/<resolved VFS path>, replacing extension-guessed interception and basename-stripped forwarding: - assetPolicy.ts (new, in quarto-hub-sandboxed-preview, imported by the SW, the page bridge, AND hub-client's parent responder): proxy-URL round-trip helpers, binary classification, MIME table, theme-CSS url() rewriter. Kills the skewed-extension-list TODO pair. - Parent ships buildProxyAssetManifest(astJson, currentFilePath) in UPDATE_AST: image targets resolved against currentFilePath with the exact resolution q2-preview's assetWalker uses, mapped to proxy URLs. Full paths ride in the URL, so same-named images in different directories no longer collide, and everything stays inside the SW scope under the /q2/ Pages path. - serviceWorker.ts: intercepts only __q2_vfs__ GETs (app assets, KaTeX fonts, the page itself pass through); one persistent message listener with a request-id map instead of a leaked listener per request; 10s timeout -> 504; VFS miss -> 404; text vs binary bodies. - registerServiceWorker.ts: id-correlated bridge with timeout and listener cleanup (concurrent fetches of the same path can no longer steal each other's responses). - entry.tsx: relative url() refs in posted theme CSS are rewritten into the proxy namespace against .quarto/project-artifacts, so theme fonts resolve — q2-preview's blob-based <link> loses them entirely. TDD: 19 new tests written first (proxy round-trip incl. basename- collision regression, manifest resolution, id correlation, CSS rewriting), red then green. End-to-end smoke in headless chromium: harness parent + renderer iframe on separate ports, AST image -> manifest proxy URL -> SW intercept -> id-correlated round trip -> 1x1 PNG decoded in the iframe. hub-client unit tier 1107 passed, integration + wasm tiers green, npm run build:all green. Phase 2 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md (bd-k9tfhsst, epic bd-r9yr0hbe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 3)
Q2PreviewIframe does editor<->preview sync by reaching into the
same-origin iframe's contentDocument (scrollIframeToLine, a
capture-phase pointerup listener, cross-realm rect reads). The
cross-origin sandboxed frame re-expresses all of it as messages:
- scrollClickBridge.ts (iframe side, new): imports findElementForLine /
isElementVisible / lineForClickTarget unmodified from scrollSyncDom
and runs them inside the frame. Window scrolls post
PREVIEW_SCROLLED {ratio}; capture-phase pointerup on a located block
posts CLICK_AT_LINE {line, iframeY} (frame-viewport coordinates —
the parent owns the host-page conversion). The entry installs the
bridge at module top and handles SCROLL_TO_LINE {line} by doing the
data-loc lookup in-frame.
- Q2SandboxedPreviewIframe grows to Q2PreviewIframe's full prop
surface: navigation, setAst, custom components, slides
(SET_SLIDE/SLIDE_CHANGED with the same echo-dedup), AST_RENDERED,
and a Q2PreviewIframeHandle whose scrollToLine posts SCROLL_TO_LINE
and whose getScrollRatio answers synchronously from the last
PREVIEW_SCROLLED ratio (null before first report; reset on
IFRAME_READY). CLICK_AT_LINE's hostY = iframeY +
iframe.getBoundingClientRect().top.
- ReactRenderer's sandboxed branch passes the full prop set, mirroring
the q2-preview branch. (The custom-components *generation* gate still
excludes the sandboxed format — Phase 4.)
TDD: 12 new tests written first (bridge: ratio on scroll, line + block
top on located pointerup, silent on unlocated/included-file clicks,
visibility-gated scrolling; parent: handle round trip, hostY math,
callback forwarding, SET_SLIDE echo guard, full payload), red then
green. End-to-end smoke in headless chromium (harness parent + iframe,
80-paragraph doc): SCROLL_TO_LINE 61 scrolled 0 -> 1865px,
PREVIEW_SCROLLED ratios flowed, CLICK_AT_LINE resolved {line: 11,
iframeY: 156}. hub-client unit 1119 / integration 118 / wasm 133 all
green; npm run build:all green.
Phase 3 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md
(bd-xqg0t494, epic bd-r9yr0hbe).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ReactRenderer's render-components transpile gate now includes q2-sandboxed-preview, so user TSX overrides reach the sandboxed iframe via LOAD_CUSTOM_COMPONENTS (test-first: new ReactRenderer integration test + a q2-sandboxed-preview routing test). Verified in headless chromium: a Para override delivered as a blob-URL module import rendered inside the allow-scripts frame. - allow="clipboard-write" delegated on the sandboxed iframe so the unmodified codeCopy.ts can call navigator.clipboard.writeText from the cross-origin frame (test-first). The rest of the Phase 4 checklist needed no code: richtext/SET_AST, slides SET_SLIDE/SLIDE_CHANGED, Cmd+S save forwarding, and comments/attribution were all wired by the Phase 3 full-surface props and are covered by its protocol tests. Noted in the plan: hub-client routes format:revealjs to Q2PreviewIframe, so the bundled RevealDeck has no sandboxed production route today. hub-client unit 1120 / integration 120 green; typecheck + tsc -b green. Phase 4 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md (bd-rhn8wi8r, epic bd-r9yr0hbe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f7efd8) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardening (TDD, negative test first): - Parent ignores messages whose event.source is not the sandboxed iframe's contentWindow — a forged SET_AST from any other window can no longer reach document state. All parent->iframe posts target the sandbox origin derived from the iframe URL instead of '*'. - The frame ignores messages whose event.source is not window.parent (entry listener AND the page bridge's url_response listener, where a forged response would inject attacker bytes as document assets), and pins the parent origin from the first accepted message for everything it posts up. '*' remains only on pre-contact IFRAME_READY and the SW-bridge url request (both go to window.parent, carry no content). End-to-end verification: - New Playwright spec e2e/q2-sandboxed-preview.spec.ts drives the real hub + WASM preview pipeline: a format: q2-sandboxed-preview document renders inside the sandboxed frame with the title block, a data-loc stamped heading, KaTeX markup, <link data-q2-theme>, and an image decoded through the __q2_vfs__ service-worker proxy (1 passed, 5.0s). - test:e2e script + hub-client-e2e workflow now build the sandboxed bundle and set VITE_Q2_SANDBOXED_PREVIEW_URL to the same-origin copy so CI tests this branch's renderer, not the last Pages deployment. - Harness smokes re-run against the hardened bundle: proxy + theme (incl. theme-CSS url() font round trip), scroll/click, custom components — all green. Docs: claude-notes/designs/q2-sandboxed-preview-separate-domain.md rewritten for the ported architecture (full protocol table, security posture, CSP/blob note). hub-client unit/integration/wasm tiers green; cargo xtask verify --skip-rust-tests green (Rust untouched since the fully-verified Phase 0 commit). Phase 5 of claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md (bd-5fwm3zju, epic bd-r9yr0hbe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
vezwork
had a problem deploying
to
github-pages
September 10, 2026 20:52 — with
GitHub Actions
Failure
# Conflicts: # hub-client/changelog.md # hub-client/public/q2-sandboxed-preview.html # hub-client/public/serviceWorker.js # hub-client/src/components/render/ReactRenderer.tsx
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.
This PR brings
format: q2-sandboxed-previewup to feature parity withformat: q2-preview.Sandboxing with full functionality
We want
q2-sandboxed-previewto have the same functionality asq2-previewbut while maintaining thatq2-sandboxed-previewserves a preview application from a separate domain, currently deployed to and served from github pages https://quarto-dev.github.io/q2/. The preview is placed into quarto-hub via an iframe.This should be effective sandboxing to prevent user code from accessing quarto-hub.com via http request or websocket. the q2-sandboxed-preview application sends specific data back and forth from/to a quarto-hub tab over
postMessage.VFS URL proxying
The q2-sandboxed-preview preview app runs a service worker that intercepts HTTP requests to relative URLs and retrieves (over postMessage) the data for that file url from the VFS (Virtual File System) of the quarto-hub app.
In other words: In the sandboxed preview, any relative url will get requested from the quarto-hub project's VFS rather than via an http request to a server.
Note that this poses security challenges that we have not yet grappled with. It means that any user-defined JS running in the preview could load any files it wants from the VFS, as long as it has their name (which it could fuzz). We should probably design a mitigation to this risk. Some ideas off-the-top: keep track of urls we expect to be requested based on the .qmd contents and only give access to those; detect and disable rapid requests.
Note that there is only risk that untrusted JS in the preview could access files in the VFS of the project, not of other projects you have on quarto-hub. This is because the postMessage API only permits certain actions.
For contrast, here's what that that image's DOM looks like in
q2-preview: