Files
penguin-harness/changelog/unreleased/2026-07-22-web-app.md
T
Yaowei Zheng b18ae8f5f1 feat(server,web): serve Workspace HTML previews from a separate origin (#46)
Serve "open in new tab" HTML previews from a separate origin (the loopback counterpart, or PENGUIN_PREVIEW_ORIGIN) with a signed, host-bound token, so localStorage/cookies/third-party embeds work while Agent-generated pages stay off the app origin. The app is canonicalized onto localhost and the preview host (127.0.0.1) serves only /preview/* — its /api answers 401 and app routes 302 to the canonical host — so the preview origin can neither set nor honor a session cookie.
2026-07-23 23:11:04 +08:00

3.7 KiB

Web App: Workspace HTML previews open on a separate origin

"Open in new tab" for a Workspace html file used to serve the page from the app's own origin under a CSP sandbox without allow-same-origin. That kept Agent-generated markup away from the session cookie and the API, but it also put the document in an opaque origin, so localStorage and document.cookie threw SecurityError and any third-party embed on the page — analytics, chat widgets, maps — failed outright. The query-parameter URL had a second problem: a page's relative subresources resolved to /api/sessions/<id>/files/<name>, a route that does not exist, so multi-file generated apps could never load their own CSS or JS.

Previews now open on a separate origin with path-based URLs. The link points at GET /files/preview-redirect?path=, which authenticates the caller, mints a short-lived HMAC token bound to the Session, the preview host and an expiry, then 302s to /preview/<token>/<path>. Locally the app is canonicalized onto one loopback name (localhost) and previews are served from the other (127.0.0.1) — cookies are keyed by host and ignore port, so those are genuinely separate cookie jars, which a second port would not have given. Real deployments set PENGUIN_PREVIEW_ORIGIN; with neither, the redirect falls back to the old same-origin sandbox and previewIsolated on GET /api/me reports false, so the Files panel marks the link rather than letting the page break silently.

Because the origin is now the isolation boundary, the preview response no longer strips allow-same-origin: the page gets a normal origin, storage and cookies work, and embeds run. Several details keep that safe. The app and the preview origin are the same process on two hostnames, so the preview host is locked to serving /preview/* only: its /api answers 401 and every app route 302s to the canonical host, so a session cookie is never set or honored there — otherwise Agent HTML on the preview origin could call /api same-origin and act as the user. The token binds the preview host and /preview/... refuses to serve on the app origin, without which this would be a same-origin XSS with full API access. Responses carry Referrer-Policy: no-referrer, since the token-bearing URL would otherwise leak through Referer to exactly the third parties this change enables. And the path is still re-resolved against the Workspace server-side, so .. and symlink escapes are rejected as before; a bad token, an expired one, the wrong host and an out-of-bounds path all answer a bare 404.

The preview URL takes its port from the server's own binding rather than the browser's current origin. Those differ in development — the SPA runs on Vite's port and only /api is proxied — so deriving it from the request would point at a port that does not serve /preview at all, and the tab would fail to connect. In production the two coincide. Only a loopback-name bind (127.0.0.1 / localhost) offers a loopback preview; a wildcard bind (0.0.0.0 / ::) or a specific non-loopback address has no reachable counterpart — localhost may resolve to ::1, which a 0.0.0.0 bind never serves — so those fall back and must set PENGUIN_PREVIEW_ORIGIN, rather than pointing somewhere dead while /api/me claims isolation.

The link is a plain anchor with rel="noopener noreferrer" rather than a fetch followed by window.open: opening a tab after an await trips popup blockers, and a script-opened window keeps an opener handle back to the app — the exact reference the separate origin exists to deny. The server also binds the IPv6 loopback alongside IPv4, because localhost commonly resolves to ::1 first and every preview URL would otherwise refuse the connection.