Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Source of truth: `createPlaywrightProvider` in `lib/src/host/playwright-host.ts`

`dor iframe <url>` frames the page's own DOM — zero-lag for human inspection, but agents cannot drive or read it. On hosts with `createIframeProxyUrl`, `IframePanel` frames a per-grant loopback proxy URL; without it, a raw uninstrumented iframe. The desktop playground's `createIframeProxyUrl` fronts only its own viewers (`docs/specs/tutorial.md` → Playground filesystem).

The proxy instruments any `http://` upstream, loopback and remote alike: headers are rewritten per the table below and the shim injected into HTML; HTTP and WebSocket traffic passes through. A site's "do not embed" is overridden, not obeyed (rationale); the sandbox neutralizes JS framebusting. **Must connect a grant, over HTTP and WebSocket alike, only to the addresses its upstream resolved to when minted.** **Must refuse (`scheme`) an upstream resolving to any link-local / cloud-metadata address**, in any spelling; one that does not resolve is `unreachable`.
The proxy instruments any `http://` upstream it fronts, loopback and remote alike — a remote one only once allowed ([Iframe External Origins](#iframe-external-origins)): headers are rewritten per the table below and the shim injected into HTML; HTTP and WebSocket traffic passes through. A site's "do not embed" is overridden, not obeyed (rationale); the sandbox neutralizes JS framebusting. **Must connect a grant, over HTTP and WebSocket alike, only to the addresses its upstream resolved to when minted.** **Must refuse (`scheme`) an upstream resolving to any link-local / cloud-metadata address**, in any spelling; one that does not resolve is `unreachable`.

**Must refuse `https://`, regardless of scheme casing, at every entry to the iframe renderer on a host with the proxy** (a host without it frames https raw):

Expand All @@ -276,7 +276,7 @@ Header rewriting beyond `Origin`, `Cookie`, `Set-Cookie`, and the framing header
| request | `Accept-Encoding` | deleted on a document load (`Sec-Fetch-Dest` `document`, `iframe`, `frame`, `embed`, `object`, or none sent), so its HTML comes back identity; kept on every other request |
| response | `X-Dormouse-Preserve-CSP: 1` | consumed; preserves upstream CSP headers and meta policies |
| response | hop-by-hop (RFC 7230 §6.1) | dropped |
| response | `Location` | an exact upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy |
| response | `Location` | an exact upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy; any other origin on a frame load is [Iframe External Origins](#iframe-external-origins) |
| response | `Vary` | `Sec-Fetch-Dest` appended, since the `Accept-Encoding` sent upstream depends on it |
| response | `Clear-Site-Data` | `"cache", "storage"` on a freshly minted grant's first frame load only: its port may have fronted another upstream; never on a tunneled grant, whose page is not at that port |
| response body | `<meta http-equiv="content-security-policy">` | removed unless the response opts into CSP preservation |
Expand All @@ -291,6 +291,19 @@ Current limits: absolute-origin subresources (`http://localhost:5173/...`, `ws:/

Source of truth: `lib/src/components/wall/IframePanel.tsx`, `lib/src/host/iframe-proxy.ts`, `lib/src/host/iframe-proxy-rewrite.ts` (`instrumentHtml`, `isBlockedAddress`), `iframeRefusal` in `lib/src/components/wall/browser-url.ts`.

### Iframe External Origins

An upstream is local when every address it resolved to at mint is on this machine (`docs/specs/remote-api.md` -> "Frames"); its name never counts. This section governs a local pane's grants: a tunneled grant is on this machine or refused, whatever `allowExternal` says, and its redirects pass as sent.

- A non-local upstream is refused (`external`) unless the request carries `allowExternal`, sent only for an origin on the panel's allowlist (`docs/specs/security-local.md` -> "Browser panes").
- The allowlist holds exact origins (scheme, host, port) in webview `localStorage`, one per host install, each removable in Settings → General. **Must re-ask the host when the pane's origin is allowed or removed**, so a removal closes an open preview.
- The `external` warning names the origin and offers Open in browser, Allow this origin, and Cancel (back one entry, else close the pane).
- An allowed origin gets a preview only: cookies stay stripped and `https://` stays refused.

Both webview CSPs admit only loopback frames (`docs/specs/security-local.md` -> "Network policy"), so a form, script, or link off the proxy to a remote origin is refused. A pane's frame load (`Sec-Fetch-Dest` `document` or `iframe`) redirected to another origin, given an embedder chain, gets a Dormouse page in place of the `Location`. In the pane's frame it posts `navigate` with the target and its own URL; the panel takes it as a URL-bar entry, replacing the redirecting history entry (rationale). A nested frame, outside the webview CSP, follows the redirect itself.

Source of truth: `mintGrant` and `offOriginRedirect` in `lib/src/host/iframe-proxy.ts`, `errorPageHtml` in `lib/src/host/iframe-proxy-rewrite.ts`, `lib/src/lib/iframe-allowlist.ts`, `lib/src/components/wall/IframePanel.tsx`.

### Iframe Proxy Leases

**Must take every mounted `IframePanel`'s grant under a lease** it mints, held under an owner the host transport names (VS Code router, standalone window label), never the webview. A remote session's frame holds its own lease under that session, which no window's end releases, whose upstream is on this machine, and whose pages carry a socket shim (`docs/specs/remote-api.md` -> "Frames").
Expand All @@ -305,7 +318,9 @@ Source of truth: `createIframeProxyUrl` and `releaseIframeProxyLease` in `lib/sr

**Must send only fixed shim message kinds to the app** — `leader`, `pointerdown`, `location`, `open-window`, `theme-request`; `location` carries `loaded: true` only on the document's own `pageshow`/`DOMContentLoaded` report. **Must relay only `leader`, `pointerdown`, and `open-window` from nested documents.** `open-window` intercepts nonempty anchor targets other than `_self`, except links with `download`, plus `window.open`. Theme delivery is `docs/specs/theme.md` → Tool iframe themes; `theme-request` stays in the outer document.

**Must let only `http:` and `https:` reach a browser Surface, re-checked at the sink**: `open-window` and the control socket's `surface.iframe` go through `browserSurfaceUrl`, and `IframePanel` checks `params.url` again before framing it, since the header's URL editor writes there too (rationale).
The proxy's redirect page also posts `navigate` ([Iframe External Origins](#iframe-external-origins)), which any proxied document can forge.

**Must let only `http:` and `https:` reach a browser Surface, re-checked at the sink**: `open-window`, `navigate`, and the control socket's `surface.iframe` go through `browserSurfaceUrl`, and `IframePanel` checks `params.url` again before framing it, since the header's URL editor writes there too (rationale).

Parent listeners check the message origin against live proxy grants (`docs/specs/security-local.md` -> "Browser panes"). A leader message exits passthrough like an in-document dual-tap; a `location` maps back to its upstream URL for chrome and history without reloading the frame; a new-tab request asks before opening an adjacent browser pane.

Expand All @@ -326,7 +341,7 @@ The optional `PlatformAdapter.createIframeProxyUrl`, the `IframeProxyResult` uni

**Must pass the webview's own ancestor chain with every request for a proxy URL** — `location.origin` plus `location.ancestorOrigins`, knowable only in the realm that has a `location` (rationale). The host validates it and uses it all-or-nothing: an unparseable or opaque (`"null"`) entry means no chain (rationale), and with no chain the proxy preserves framing headers and injects nothing.

The proxy binds loopback only, a mitigation and not the boundary; each grant fronts exactly one upstream; no user script is injected; and every user-supplied `http://` target but one resolving to a refused address ([Iframe Renderer](#iframe-renderer)) is trusted as the user's command, at the cost of the upstream's own XSS policy unless it opts into preservation. The `Host`, `Origin`, cookie, `frame-ancestors`, shim-targeting and grant-refresh rules are `docs/specs/security-local.md` → "Loopback Listeners" and "Browser panes".
The proxy binds loopback only, a mitigation and not the boundary; each grant fronts exactly one upstream; no user script is injected; and every user-supplied `http://` target on this machine, or allowed off it ([Iframe External Origins](#iframe-external-origins)), but none resolving to a refused address ([Iframe Renderer](#iframe-renderer)), is trusted as the user's command, at the cost of the upstream's own XSS policy unless it opts into preservation. The `Host`, `Origin`, cookie, `frame-ancestors`, shim-targeting and grant-refresh rules are `docs/specs/security-local.md` → "Loopback Listeners" and "Browser panes".

Source of truth: `lib/src/lib/platform/iframe-proxy-types.ts`, `embedderOrigins` in `lib/src/lib/embedder-origins.ts`, `normalizeEmbedderOrigins` in `lib/src/host/iframe-proxy-rewrite.ts`, `lib/src/host/loopback-guard.ts`. `lib/src/host/iframe-proxy.test.ts` pins the upgrade path as well as the request path.

Expand All @@ -341,6 +356,8 @@ A moved iframe Surface remounts at its saved URL after consent: `docs/specs/layo
- General per-surface teardown hook for future Dormouse-owned backend processes; agent-browser surfaces already dispose their controller on kill/swap, and iframe views release their leases.
- Process-backed targets are owned by the **dor-tools** scope (`docs/specs/dor-tool.md` `## Future`), which subsumes the plugin/backend target axis formerly staged here.
- Optional terminal-side "this port is viewed by surface:N" indicator.
- Following an off-proxy link click or form submission in place through the [Iframe External Origins](#iframe-external-origins) gate, where the webview CSP refuses it today.
- Cookie support for authenticated local tools, scoped from stories of real tools that fail; a jar must authorize its caller before attaching stored credentials.
### Daemon-owned crisp captures

Replace spawn-per-shot CLI screenshots only with a daemon-owned capture channel or an upstream answer exposing current viewport and DPR. **Must retain device-resolution output and external viewport/device changes without adding a second viewport writer.** The host cannot reconstruct an externally chosen DPR from screencast frames; a host-owned CDP metrics override is safe only while sync-to-pane owns the values. (rationale)
14 changes: 14 additions & 0 deletions docs/specs/dor-browser.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ The built-in local-file viewer supplies its own content boundary and permits the

**Why a grant gets its own origin instead of a path token.** A dedicated origin keeps root-relative resources and client-side routers working with no body URL rewriting; a path token would have to survive every link, redirect and `fetch` the page makes.

## Iframe External Origins

**Why the proxy changes what an external page is.** It strips the upstream's framing headers and, by default, its CSP, and serves every grant from the shared `127.0.0.1` hostname, so a remote page loses protections its author relied on; the warning names that, rather than claiming the page itself is hostile. Approval does not make signing in safe, which is why cookies stay stripped.

**Why local is the resolved address.** A hostname proves nothing: `localhost` is whatever the resolver answers, a public name can resolve to `127.0.0.1`, and a LAN or tailnet name is usually another machine. The addresses are the ones the grant is pinned to, so the one classified is the one connected to. Two non-loopback answers still reach this machine and are handed out routinely: `0.0.0.0`, which `python -m http.server` and other servers bound to every interface print, and an address one of this host's interfaces holds, which `listenerUrlsByPort` names for a server bound to that interface alone. A private-network address on any other host stays external.

**Why the grant is the only gate needed.** Both webview CSPs already limit `frame-src` to loopback, so the browser refuses any navigation off the proxy origin to a remote one before its request; only a minted grant can put a remote page in the pane, and the panel asks for one on every URL it frames. The one hop the browser never sees is a redirect, which the proxy follows on the frame's behalf, so the proxy answers it. Before this, a foreign `Location` was forwarded and the frame showed a blank pane (`docs/specs/vscode.md` → CSP policy).

**Why the redirect replaces the redirecting entry.** Pushing the target would leave the redirecting URL behind it, and Back would land there only to be redirected again. The redirect page carries no shim for the same reason: its `location` report would race the `navigate` and could put the redirecting URL back.

**Why a tunneled grant keeps its redirect.** `PocketBrowserFrame` handles no `navigate`, so the Dormouse page would be a dead end on a phone, and a target on the Burrow's `localhost` means nothing in the phone's own browser. What a frame should do instead is `docs/specs/remote-api.md` -> "1b. A frame redirected off its page".

**Why one list per install.** Every other Settings value persists per host install in `localStorage`; a per-window or per-session list would re-ask on every restore of a pane the user already allowed.

## Iframe Proxy Leases

**Why a grant's life is its view's, not its traffic's.** Reproduced against the proxy (2026-09-24, then again as the failing tests that pin this, 2026-10-03): a live Vite pane idle for five minutes lost its grant on the next grant created anywhere — its HMR socket survived, module fetches then failed with `ECONNREFUSED` — and 33 grant creations anywhere evicted a live pane under the 32-grant cap. Every Reload, Back or Forward minted a new port, which is a new origin, so the app's localStorage, IndexedDB and service worker went with it.
Expand Down
6 changes: 5 additions & 1 deletion docs/specs/remote-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ A Client shows an embedded page by framing it at an origin of its own, the *fram

- **Must open a frame only at the page its surface embeds when asked**, never at a URL the Client names; the Client's page then requests any path on that grant's one upstream. **Must refuse a `frame.open` whose `pageOrigin` is no longer that page's origin**: the frame origin holds the storage of the page the Client named, and another page must not inherit it (rationale).
- **Must open a frame only once the direct path carries both directions**, refusing `frame.open` before then and on a session that stays relayed (rationale). The switch is one-way, so a frame never outlives its path. A relayed session's directory still lists its pages, so a Client can say why they do not open.
- **Must frame only a page on this machine**: every address its grant pins is loopback, unspecified, or held by one of this host's interfaces, judged on those addresses and never on a name (rationale). A local pane keeps its remote pages; the session lists none of them to a Client.
- **Must frame only a page on this machine**: every address its grant pins is loopback, unspecified, or held by one of this host's interfaces, judged on those addresses and never on a name (rationale). A local pane may hold a remote page the user allowed (`docs/specs/dor-browser.md` -> "Iframe External Origins"); the session lists none of them to a Client.
- **Must hold every grant under a lease the session owns**, never a webview's, released by `frame.close`, by the session's disposal, and when the surface's page is gone or moves to another origin — re-checked on every directory invalidation and told as `frame.closed`. **Must ask the surface's owner, never judge by a listing**, which can predate the frame (rationale). **An owner's silence must not close a frame until `OWNER_SILENCE_LIMIT_MS`**, since a close costs the Client's copy of the page, unsaved edits included.
- **The tunnel stands in for the frame origin**: an `Origin` equal to it, and a `Referer` on it, reach the grant as the grant's own, so the proxy vouches for those alone; any other `Origin` passes as sent; a `Location` on the grant returns as a path. **Never forward `Cookie`, a `Sec-` field, or the connection's own fields**; `destination` stands in for `Sec-Fetch-Dest`, which a service worker never sees. A WebSocket opens as the grant's own origin.
- **Must answer every body identity-encoded**, decoding a compressed one, since a response a Client synthesizes is never decoded.
Expand Down Expand Up @@ -272,6 +272,10 @@ Fixed, phone-appropriate screencast parameters (JPEG, capped dimension and frame

A Client framing a remote `http://` page, whose framing and script protections the proxy strips, needs a consent the Burrow can read, which a webview's own allowlist is not.

### 1b. A frame redirected off its page

A frame load redirected to another origin passes as sent, and Pocket's `frame-src` admits only the frame domain. A pane answers the same redirect through `docs/specs/dor-browser.md` -> "Iframe External Origins"; a Client needs its own answer, given that a target on the Burrow's `localhost` means nothing in the phone's own browser.

### 2. In-flight command replay

A command still running — "is my build done?" — is the commonest reason to open a pane on the phone, and a resize repaint shows nothing for one quietly writing a log; agent TUIs, the primary workload, do repaint, which is what makes this deferrable. The Burrow retains the current command's output from its `commandStart` boundary (OSC 133/633, with the existing keystroke-heuristic fallback), tail-capped to a fixed byte budget, dropped at the next prompt; attach replays it via the reserved `inflight` field:
Expand Down
Loading
Loading