Skip to content

dor iframe: transparent proxy for the iframe surface#142

Merged
nedtwigg merged 11 commits into
mainfrom
iframe-shim
Jun 17, 2026
Merged

dor iframe: transparent proxy for the iframe surface#142
nedtwigg merged 11 commits into
mainfrom
iframe-shim

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

dor iframe: transparent proxy for the iframe surface

Implements the "Transparent Proxy (Instrumented Iframe)" section of docs/specs/dor-iframe.md. dor iframe <url> previously pointed a raw <iframe> at the target — a separate browsing context Dormouse couldn't observe or control, which made it unusable for real interaction (focus left Dormouse, blocked pages showed a blank pane, no error signals). This fronts the target with a host-owned loopback proxy so Dormouse serves the bytes, and from that one capability the surface gains a keyboard side-channel, an accurate focus model, and real error pages. It's now usable for loopback dev servers.

How it works

Instead of framing the target, the panel asks the host for a proxy URL (createIframeProxyUrl) and frames that. The proxy (vscode-ext/src/iframe-proxy-host.ts) fetches the upstream and serves it back on loopback, with policy by target:

Target Behavior
Loopback http Full instrument: strip X-Frame-Options, drop page CSP, inject the shim
Remote http, frameable Best-effort render with the shim
Remote http, refuses framing Served error page pointing at dor ab open <url>
Unreachable Served error page ("is the dev server running?")
https Deferred — panel shows a dor ab hint
  • Keyboard side-channel (limitation maybe: drop node-pty for a Rust backend #1): a fixed, Dormouse-owned inline shim forwards only the reserved leader chord (dual-tap ⌘/⇧) via postMessage; every other keystroke flows to the tool. The Wall validates event.origin against live proxy grants and re-enters the same dispatch (exitTerminalMode).
  • Focus model (Leaky-bucket mechanism for soft-TODOs #2/Fix terminal spawning #3): a focused iframe blurs the parent window without backgrounding the app, so we read document.hasFocus() (stays true while a descendant frame holds focus) instead of going inactive; the surface registers a focus handle so focusSession can focus it, and adopts "frame took focus" as entering the pane (clicking a cross-origin frame doesn't bubble mousedown).
  • Error signals (split a pane and keep the cwd #4): the proxy is the server, so it serves precise error pages from the proxy origin (themselves instrumented with the shim, so the leader chord works there too).
  • WebSocket passthrough for dev-server HMR; anti-framebust via sandbox without allow-top-navigation; SSRF guard refusing link-local/metadata ranges.
  • Frame-refusal detection parses CSP frame-ancestors per-directive and treats only a standalone * as permissive — a scoped source like https://*.example.com is correctly classed as refusing rather than slipping through to a blank frame.
  • URL fidelity: the framed proxy URL preserves the target's path, query, and fragment (hash-routed SPAs land on the right route); the fragment stays browser-only and is never sent upstream.
  • Cursor alignment: a cross-origin iframe is an out-of-process frame, and dockview's contain: layout root made Chromium map pointer events to the wrong origin (clicks landed offset). Fixed by giving the iframe's container its own identity compositing layer (transform: translateZ(0)).
  • CSP narrowed from frame-src http: https: to frame-src http://127.0.0.1:* http://localhost:*.

Design notes (deliberate deviations from the original spec sketch)

  • Per-grant dedicated loopback server, not a shared port. The grant's origin is the grant, so root-relative sub-resources proxy transparently with zero body rewriting, and a server bound to one upstream is inherently not an open forwarder.
  • No token in the URL. A path-prefix token landed in location.pathname and broke client-side routers (a React-Router/Remix dev server matched no route and rendered its own 404). The dedicated server is the isolation boundary instead.

The spec (docs/specs/dor-iframe.md) is updated to match: the proxy moves out of "Future Work," limitations #2#4 are marked resolved, and the open decisions become the decisions actually made. Path 1 (swappable render backend) and Path 2 (plugin system) remain future work.

Host coverage

Implemented for the VS Code host. Other hosts degrade gracefully — createIframeProxyUrl is optional; where it's absent the panel falls back to a raw, uninstrumented <iframe>.

Known gap: standalone (Tauri) does not yet implement createIframeProxyUrl, so dor iframe there falls back to the raw frame (no shim/error pages/header handling) — the same host-parity gap the agent-browser stream relay already has. Bringing it to parity means running the proxy from the Tauri sidecar and extracting the host-agnostic proxy helpers (incl. the frame-ancestors parsing) into a shared, DOM-free module. Tracked as a follow-up.

Testing

  • Runtime smoke test of the proxy (loopback instrumentation, header stripping, shim injection, root-relative routing, path preservation, unreachable/scheme/SSRF handling, WebSocket upgrade).
  • Lib tsc -b clean; full lib suite green (592 tests); extension bundles clean.
  • Manually verified in VS Code against a React-Router dev server: page loads, leader chord round-trips out of the frame, cursor aligned.

Known v1 gaps

  • https upstreams deferred.
  • Absolute-origin sub-resources (e.g. Vite's ws://localhost:5173 HMR) bypass the proxy — harmless for loopback, but uninstrumented.
  • HTML responses are buffered before shim injection (latency for streamed SSR).
  • No teardown-on-kill hook yet — a killed surface's proxy server is reaped by the idle sweep (the shared hook is tracked under Path 2).
  • hasRestrictiveFrameAncestors is not unit-tested (vscode-ext has no test harness); covered when the helpers move to the shared lib/ module.

🤖 Generated with Claude Code

nedtwigg and others added 9 commits June 16, 2026 20:14
Implements the "Transparent Proxy (Instrumented Iframe)" section of
docs/specs/dor-iframe.md. Instead of pointing the <iframe> at the target,
Dormouse stands up a loopback proxy that serves the bytes — which lets it
inject a shim, see the upstream result, and resolve limitations #1#4.

Proxy host (vscode-ext/src/iframe-proxy-host.ts): a loopback-only HTTP
proxy modeled on the agent-browser stream relay, but it parses/rewrites
responses and passes WebSocket upgrades through. Loopback http upstreams
are fully instrumented (strip X-Frame-Options, drop the page CSP, inject
the fixed leader shim); a remote that refuses framing or an unreachable
server is diagnosed and served as a Dormouse error page; https/link-local
targets are refused. Each grant gets its own ephemeral server bound to one
upstream, so it is inherently not an open forwarder and root-relative
sub-resources proxy transparently with no body rewriting — and no token in
the URL, which would land in location.pathname and break client routers.

Wiring: createIframeProxyUrl on PlatformAdapter + IframeProxyResult, the
VSCodeAdapter impl, the iframe:createProxyUrl/iframe:proxyUrl messages and
router handler, and the webview frame-src CSP narrowed to the loopback
proxy origin.

Keyboard side-channel + focus model: the injected shim forwards only the
reserved leader chord (dual-tap ⌘/⇧) via postMessage; use-wall-keyboard
validates the origin against live proxy grants and re-enters the same
dispatch (#1). use-window-focused and Wall's blur handler read
document.hasFocus() so a focused iframe no longer reads as backgrounded
(#2). IframePanel registers a focus handle (focusSession can focus the
frame, #3) and adopts frame-focus as entering the pane (clicks into a
cross-origin frame don't bubble a mousedown). The proxy's served error
pages replace the blind stall hint (#4). The frame is sandboxed without
allow-top-navigation for anti-framebust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cross-origin iframe is an out-of-process frame, so Chromium maps pointer
events to it relative to its nearest compositing/containing ancestor.
Dockview's root (.dv-dockview) sets `contain: layout`, which made that
far-away root the frame's coordinate origin — clicks landed offset by the
pane's distance from it (~340px for a split pane).

Give the iframe's immediate container its own identity compositing layer
(transform: translateZ(0)), co-located with the frame, so it becomes the
nearest reference and the offset collapses to ~0. translateZ(0) is
identity, so getBoundingClientRect (overlay measurement) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves "The Transparent Proxy" out of Future Work into the body as the
implemented substrate, with pointers to what shipped. Documents the two
deliberate deviations from the original sketch — per-grant dedicated
loopback server (not a shared port) and no token in the URL (a path token
breaks client-side routers) — and the new cross-origin-frame cursor-offset
fix. Marks limitations #2/#3/#4 resolved and reframes #1 as mitigated.
Adds Known v1 gaps (https deferred, absolute-origin sub-resources bypass,
buffered streaming SSR, no teardown-on-kill hook) and turns the Open
Decisions into the decisions actually made. Path 1 / Path 2 / render
backends remain future work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both the `end` and `error` stream handlers decoded the buffered body with
the same expression; hoist it to a single `complete` callback. No behavior
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the ternary-in-dependency-array for the proxy-origin registration
effect with a named derived value used by both the effect body and its
dependency array. Same behavior, reads as a plain value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2d0ee43
Status: ✅  Deploy successful!
Preview URL: https://d40838a8.mouseterm.pages.dev
Branch Preview URL: https://iframe-shim.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, carefully-reasoned implementation — the focus model, the faithful shim port of handle-dual-tap, and the per-grant single-upstream isolation all hang together well. One latent edge case worth a look (inline).

Comment thread vscode-ext/src/iframe-proxy-host.ts
@nedtwigg

Copy link
Copy Markdown
Member Author

@dormouse-bot if we hit truncation, I definitely do not want to serve the truncated HTML. I want to serve a page that says "Error, exceeded HTML_BODY_LIMIT of XXX". Push a commit to make it that way.

When an upstream HTML response exceeds HTML_BODY_LIMIT, collectBody
previously destroyed the stream and handed back the partial bytes, which
were then instrumented and served as a truncated document. Serve a clear
"exceeded HTML_BODY_LIMIT of N MB" error page instead, so we never ship
half a page (where the shim may have landed past the cutoff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dormouse-bot

dormouse-bot commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Done in 72d13a4 (pushed to iframe-shim).

collectBody now tracks whether it hit HTML_BODY_LIMIT and reports it to the caller. When truncated, handleRequest serves a bodyTooLargePage error page ("…exceeded HTML_BODY_LIMIT of 32 MB…") from the proxy origin instead of instrumenting and serving the partial bytes. The error page is itself shim-instrumented (so the leader chord still works there) and points at dor ab open <url> as the fallback.

Extension bundles clean via esbuild.

stream.destroy() (no error arg) emits 'close'/'aborted' but neither
'end' nor 'error', so the listeners that call complete() never fired on
the truncation path — the over-limit branch in collectBody's callback was
unreachable and the downstream response hung with nothing served. Call
complete() explicitly after destroy(), guarded by a 'settled' flag so the
normal end/error path can't double-serve.

Co-Authored-By: Claude <noreply@anthropic.com>
@nedtwigg nedtwigg merged commit bd4139b into main Jun 17, 2026
9 checks passed
@nedtwigg nedtwigg deleted the iframe-shim branch June 17, 2026 07:10
nedtwigg added a commit that referenced this pull request Jun 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants