dor iframe: transparent proxy for the iframe surface (VS Code + standalone)#143
Merged
Conversation
Move the host-agnostic proxy into lib/src/host as one TypeScript source so
both hosts run the same code instead of reimplementing it:
- iframe-proxy-rewrite.ts — pure policy/rewriting (shim, instrumentHtml,
refusesFraming/hasRestrictiveFrameAncestors, loopback/SSRF checks, error
pages). Now unit-tested (17 cases), incl. the frame-ancestors logic.
- iframe-proxy.ts — the Node http/net server, logger injected so it depends
on neither host.
- IframeProxyResult moves to a dependency-free leaf (platform/
iframe-proxy-types.ts, re-exported) so the Node code doesn't pull the
browser type-graph into a Node compile.
VS Code: iframe-proxy-host.ts collapses to a thin wrapper that injects `log`.
Standalone: build-sidecar-proxy.mjs esbuilds the shared TS into
sidecar/iframe-proxy.cjs (wired into stage/build/tauri, gitignored); the
sidecar handles iframe:createProxyUrl over stdio; a Rust
iframe_create_proxy_url command bridges it; tauri-adapter implements
createIframeProxyUrl; and the Tauri CSP gains frame-src for the loopback
proxy. IframePanel is shared, so the webview needs no changes.
Verified: lib tsc + 609 tests, VS Code bundle, sidecar cjs builds and
runtime-proxies, standalone tsc. The Rust command needs a machine with the
Rust toolchain to compile/run (cargo absent here).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standalone's Vite aliased `dormouse-lib` to lib source but not `dor`, and leaned on tsconfig `paths` for it — which Vite doesn't apply to lib's own files (governed by lib's paths-less tsconfig), and `dor` has no package `exports` to fall back on. So any lib file importing `dor/*` (e.g. Wall.tsx → `dor/commands/shell-quote`) failed to resolve, and the standalone app's full UI never booted in dev. Add the `dor` → ../dor/src alias and an fs.allow entry, mirroring how `dormouse-lib` is already handled. Verified: standalone `vite build` transforms all modules with no resolution errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…webview Shaking out the iframe surface in the standalone (WKWebView) host surfaced several interaction bugs the Chromium webview hid: - Click now selects the pane. WebKit doesn't fire the iframe element's `focus` for a cross-origin click, and dockview's `api.isActive` is true per-group (so the old guard skipped adoption in a split). The shim — which runs inside the frame — posts `pointerdown`; the panel adopts it as entering the pane (select + passthrough). Navigation is keyboard-only, so it never triggers this. - Command-mode keys work after the leader. Exiting passthrough now blurs the frame *and* parks focus on the pane root (top document), since blurring a cross-origin frame doesn't reliably hand focus back on WebKit; without it the frame kept focus and arrow navigation never reached the Wall. - The frame no longer reloads when its pane is (de)activated. dockview's default `onlyWhenVisible` renderer detaches/reattaches panel DOM, which reloads an <iframe>; iframe panels now use `renderer: 'always'`. - Clicking no longer blanks the frame / traps focus. Adoption keys off the frame's own focus/pointer signals (rising-edge) rather than a window-`blur` heuristic that also fired on the transition away, and the focus handle never re-focuses an already-focused frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sions
The AppBar calls getCurrentWindow().isFocused() and toggleMaximize(); in
Tauri v2 the JS toggleMaximize() invokes the `internal_toggle_maximize`
command, which `allow-toggle-maximize` doesn't cover. Both were denied at
runtime ("not allowed" — core:window:allow-is-focused /
allow-internal-toggle-maximize). Add the two permissions to the default
capability. Validated by cargo check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proxy had no upstream timeout, so a hung dev server left the frame blank indefinitely with no feedback. Harden it: - 30s idle timeout on the upstream socket → a served, actionable error page. Idle-based, so streaming/SSR responses are never cut off. - Distinct pages: "isn't responding (may be optimizing) — try reloading" vs "couldn't connect — is the dev server running?". - Guard against a double writeHead crash if the upstream errors after we've started streaming the response. - Abort the in-flight upstream fetch when the frame navigates away/closes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inject the shim into the <head> as bytes arrive, then pipe the rest untouched, rather than buffering the entire response before serving. Cuts first-paint latency on large/streaming-SSR pages (e.g. a 200KB React Router document) and bounds memory to the <head> region. Subtleties handled: the insertion point (</head>, else <body>, else a cap) can split across chunk boundaries, so we accumulate until it appears; the head prefix is processed as latin1 (byte-preserving) so rewriting ASCII tags and re-encoding can't corrupt a multibyte char split mid-byte; after injection we hand the remainder to a raw pipe (backpressure + end for free), with a `handled` flag so the manual end path and the pipe don't both close the response. The response is now chunked (instrumentation changes length). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Node proxy server was esbuild-only (no tsc/unit coverage); add an end-to-end test that fronts a real loopback upstream with createIframeProxyUrl and fetches through the proxy. Covers the parts most likely to rot: - streaming shim injection when </head> and a multibyte char both split across chunk boundaries (the latin1 byte-preservation path), - loopback instrumentation (XFO/CSP stripped, shim before </head>, content and chunked encoding preserved), - non-HTML passthrough, Location rewrite onto the proxy origin, - scheme + SSRF admission rejects, and the unreachable error page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each proxied surface gets its own ephemeral loopback origin (a unique OS-assigned port per grant), so an origin is only ever held by one live surface — the reference count could never exceed 1. Drop the Map<string,number> (and its `?? 0`/`?? 1` asymmetry) for a plain Set. API and behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
9c68840
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a922fa14.mouseterm.pages.dev |
| Branch Preview URL: | https://iframe-transparent-proxy.mouseterm.pages.dev |
main landed the original iframe surface (#142 squash) plus two follow-ups that taught the monolithic vscode-ext/src/iframe-proxy-host.ts to serve an error page when collectBody truncated an over-limit HTML body. This branch refactored that file into a thin wrapper — the proxy now lives in lib/src/host/iframe-proxy.ts (shared with the Tauri sidecar) and streams the HTML instead of buffering it. With streaming there is no full-body buffer and thus no truncation failure mode, so main's truncation handling is superseded; the conflict is resolved by keeping the thin wrapper. Merged tree is otherwise identical to this branch's tip. Verified: lib tsc + 620 tests, VS Code bundle, standalone tsc, sidecar build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dormouse-bot
approved these changes
Jun 17, 2026
This was referenced Jun 18, 2026
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.
dor iframe: transparent proxy for the iframe surface (VS Code + standalone)Implements the "Transparent Proxy (Instrumented Iframe)" section of
docs/specs/dor-iframe.md.dor iframe <url>used to point a raw<iframe>at the target — a separate browsing context Dormouse couldn't observe or control (focus left Dormouse, blocked pages went blank, no error signals). It now fronts the target with a host-owned loopback proxy so Dormouse serves the bytes, gaining a keyboard side-channel, an accurate focus model, and real error pages. Works for loopback dev servers on both hosts.How it works
IframePanelasks the host for a proxy URL (createIframeProxyUrl) and frames that. The proxy fetches the upstream and serves it back on loopback:httpX-Frame-Options, drop page CSP, inject the shimhttp, frameablehttp, refuses framingdor ab open <url>(scoped CSPframe-ancestors *= frameable;https://*.x.com= restrictive)httpsdor abhint</head>, never user-supplied) posts only the reserved leader chord (dual-tap ⌘/⇧) and apointerdown(a cross-origin click reaches only the frame). The Wall validatesevent.originand re-enters the same dispatch / selects the pane.document.hasFocus()true, so the app doesn't read it as backgrounded;focusSessionfocuses the frame via a registered handle and parks focus back on the pane on exit.<head>as bytes arrive (latin1-preserving across chunk boundaries), then the rest is piped — no full-document buffering.Locationrewrite onto the proxy origin, abort-on-disconnect, WebSocket passthrough (HMR),sandboxwithoutallow-top-navigation(anti-framebust), SSRF guard on link-local/metadata.translateZ(0)on the frame container +renderer: 'always'(dockview'sonlyWhenVisibledetach reloads iframes) keep clicks aligned and the frame from reloading on (de)activation.Shared across both hosts
The host-agnostic proxy lives once in
lib/src/host/as TypeScript:iframe-proxy-rewrite.ts(pure, dependency-free policy/instrumentation, unit-tested) +iframe-proxy.ts(the Nodehttp/netserver, logger injected). Consumed by:build-sidecar-proxy.mjsesbuilds it into the sidecar; a Rustiframe_create_proxy_urlcommand bridges it; the Tauri adapter implementscreateIframeProxyUrl; the Tauri CSP narrows to the loopback origin.Because
IframePanelis shared, the webview needed no per-host changes. Deliberate design notes (in the spec): per-grant dedicated loopback server (the grant's origin is the grant → root-relative subresources proxy with zero rewriting), and no token in the URL (it would land inlocation.pathnameand break client routers).Also includes the standalone Vite
doralias fix and the Tauri window permissions (is-focused,internal-toggle-maximize) shaken out during testing.Testing
frame-ancestorsparsing); an integration test fronts a real loopback upstream and fetches through the proxy, covering streaming shim injection with</head>and a multibyte char split across chunk boundaries, non-HTML passthrough,Locationrewrite, scheme/SSRF rejects, and the unreachable error page; anIframePanelcomponent test covers raw-fallback click adoption.tsc -bclean, full lib suite green (618 tests), VS Code extension bundles, sidecar proxy builds + runtime-proxies, standalonetsc -bclean, and the Rust cratecargo checks.Known v1 gaps
httpsupstreams deferred; absolute-origin sub-resources (e.g. Vite'sws://localhost:5173HMR) bypass the proxy (harmless on loopback); a Vite optimize-deps504on cold start is a dev-server quirk (warm Vite avoids it); no teardown-on-kill hook yet (idle sweep reaps the proxy server).🤖 Generated with Claude Code