diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 5f146474c..8a433713a 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -22,9 +22,8 @@ handle (`docs/specs/dor-cli.md` → Browser Open Target Resolution): - `dor ab ...` / `dor agent-browser ...` forwards to the user's own `agent-browser` binary and binds that session to a browser pane. - `dor pw ...` / `dor playwright ...` binds the user-installed Playwright CLI. -- `dor iframe ` uses the iframe renderer. The CLI accepts `https://`, but - the proxy instruments `http://` upstreams only, so the pane reports an - unproxyable scheme. +- `dor iframe ` uses the iframe renderer, for `http://` pages + ([Iframe Renderer](#iframe-renderer)). Source of truth: `lib/src/components/wall/BrowserPanel.tsx`, `lib/src/components/wall/browser-surface.ts` (`resolveRenderMode`, @@ -40,8 +39,9 @@ Invariants on the flat persisted `BrowserPanelParams`: live agent-browser. **Only params may omit it** — a live `ScreenSnapshot` always carries one, so nothing defaults it a second time. - **`url` is the canonical target** across render swaps and relaunches. - Agent-browser mirrors the newest non-blank active tab URL into it; iframe - persists only navigations initiated by Dormouse chrome. + Agent-browser mirrors the newest http(s) active tab URL into it — the host + relaunches at nothing else; iframe persists only navigations initiated by + Dormouse chrome. - **Must keep automation state flat** (`session`, `wsPort`, `binaryPath`, `syncEngaged`, `key`, plus Playwright `cwd`/`nativeIdentity`), never nested. Pop-out is not a param — it derives from `renderMode` once, at controller construction. @@ -117,7 +117,8 @@ Header contract: persisted title keeps the query. - **Must open a pre-selected `InlineEditInput` from the URL.** Blur discards; `normalizeNavUrl` follows CLI scheme selection plus bare loopback → `http://` - and bare remote → `https://` (rationale). + and bare remote → `https://` (rationale). **Must refuse any other scheme with + a warning under the field**, since no renderer opens one. - **Must keep back/forward/reload enabled.** Agent-browser uses native commands; iframe uses parent history and re-resolves its proxy. - **Must show non-default managed `--key` as a badge, never a title prefix.** @@ -171,10 +172,14 @@ robot rides each provider’s screencast parent, each nested resolution row carr its presentation glyph. **Must offer only the render modes the Surface's screen controller declares** (`renderModes`), never the host's global capabilities: a provider its host can launch, or the running one, which relaunches; that provider's popout where the host can also pop out; always `iframe`; for a Tool, only its declarable renders (`docs/specs/dor-tool.md` → Declaring tools). **`setRenderMode` refuses any other mode.** +The iframe option lists that the embed keeps no logins or cookies (for +`https://`, see [Iframe Renderer](#iframe-renderer)). Resolution controls apply to both screencast providers, as GUI wrappers around native commands: **Resize with pane** is Dormouse-owned sync issuing -`set viewport ` on resize; **Fixed** issues +`set viewport ` once a pane resize settles (200ms); +**only a DPR change re-syncs at once**, off a `(resolution: dppx)` media +query `change` event. **Fixed** issues `set viewport ` or `set device ` from the modal's registry. **Only `syncEngaged` persists** — device/custom viewport state lives in @@ -306,12 +311,16 @@ refreshes only when the driving command completes** until `tabs` refreshes, even at the same URL. **Two-stage paint.** A changed stream JPEG paints at once as a CSS-resolution -**provisional frame** — the first image, and 250ms after pointer input -(continuous movement extends the window) — then a crisp device-resolution +**provisional frame** — the first image, 250ms after any input (pointer, keys, +pasted text, editing chords; continuous input extends the window), and while a +capture is **overdue** — then a crisp device-resolution `agentBrowserScreenshot` replaces it (rationale): - **Both paths are latest-only.** - **No capture may start inside the provisional window** (rationale). +- **A capture is overdue past twice the average round trip, at least 400ms. It + is never re-issued while its host call is unresolved, and a paint made only + because it is overdue does not supersede it** (rationale). - **Must leave the loop dirty when capture or bitmap decode becomes stale** (rationale). Pinned by `agent-browser-screenshot-loop.test.ts`. - **Any canvas writer but the crisp loop must bump the draw generation** in its @@ -352,8 +361,8 @@ shared by the stream and `tab list --json`). `ab-popout` relaunches the same session headed, because Chrome fixes headed/headless at daemon launch. The pane becomes a stub with Pop back in; while the window is still opening (a relaunch in flight, or an eager pane -without its session) the stub offers nothing. **State carried in v1 is only the active -non-blank URL**: other tabs, DOM state, scroll, form inputs, session storage, +without its session) the stub offers nothing. **State carried in v1 is only the last +http(s) active URL**: other tabs, DOM state, scroll, form inputs, session storage, cookies/logins do not survive. Host sequence: run `close`, **then terminate the daemon by its pid file and wait @@ -387,17 +396,23 @@ sidecar/Rust adapter. | Method | Contract | | --- | --- | -| `agentBrowserCommand` | Allowlisted CLI subcommands (`AGENT_BROWSER_ALLOWED_SUBCOMMANDS` in `lib/src/lib/platform/types.ts`); host-side `get` limited to `get cdp-url`. | -| `agentBrowserScreenshot` | One device-resolution JPEG/PNG frame. VS Code structured-clones the bytes; standalone passes Rust the capture's temp-file **path** over the sidecar stdio, for Rust to read (rationale). | +| `agentBrowserCommand` | Navigation, tab, viewport/device, `get cdp-url` and `close` commands, one shape per verb. | +| `agentBrowserScreenshot` | One device-resolution JPEG/PNG frame. VS Code structured-clones the bytes; standalone passes Rust the capture's temp-file **path** over the sidecar stdio, for Rust to read (rationale). **One capture per session and format in flight**: a request made meanwhile joins it — never one from before the session's close or relaunch — and the capture's spawn is killed past 30s. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `agentBrowserEdit` | select-all/copy/cut via fixed host-owned JS plus an OS clipboard write. | | `getAgentBrowserStreamUrl` | Direct stream URL, or the VS Code relay URL. | | `agentBrowserOpen` | Spawn a GUI-owned session for iframe -> agent-browser; resolves when the daemon is up, not when the page loads ([Pop-Out](#pop-out)). | | `agentBrowserPopOut` / `agentBrowserPopIn` | Headed/headless relaunch. | -**Host-side validation is the security boundary:** every `agentBrowserCommand` -implementation must enforce the shared allowlist; the CLI is not trusted to -pre-filter arguments. +**Every adapter must wait past agent-browser's 25s action timeout for a +command, edit or capture reply** (30s on each host), so the webview never +re-asks while the host still works. + +**Host-side validation is the security boundary: both provider hosts run only +what the shared `parseWebviewCommand` accepts, rebuilt from its parsed value, +and refuse an option- or path-shaped session name and a non-http(s) launch URL +on every entry point** (rationale). Pinned by +`lib/src/host/agent-browser-host.test.ts`. **`binaryPath` crosses from the webview realm, so it is checked at the spawn** (rationale) — the gate is `runWithBinaryFallback`, the one call every entry point @@ -417,8 +432,10 @@ single-use, short-TTL token bound to one stream port and strips the Origin header; standalone connects directly. Source of truth: `lib/src/host/agent-browser-host.ts` (`runWithBinaryFallback`), +`lib/src/host/browser-host-shared.ts` (`parseWebviewCommand`, +`isAgentBrowserSession`, `isPlaywrightSession`), `dor-lib-common/src/agent-browser.ts` (`isAllowedAgentBrowserBinary`), -`lib/src/host/private-capture-dir.ts`, `lib/src/host/browser-host-shared.ts`, `lib/src/host/browser-stream-guard.ts`, +`lib/src/host/private-capture-dir.ts`, `lib/src/host/browser-stream-guard.ts`, `vscode-ext/src/agent-browser-host.ts`, `vscode-ext/src/webview-html.ts`, `standalone/src/tauri-adapter.ts`, `standalone/src-tauri/src/lib.rs`, `standalone/sidecar/main.js`. @@ -433,7 +450,7 @@ Source of truth: `lib/src/host/agent-browser-host.ts` (`runWithBinaryFallback`), **Must discover the native session in its CLI project scope and connect using that installation's matching Playwright client.** Accept only a unique registry entry matching session, workspace and library, with a local pipe endpoint and Chromium engine. Never load modules from the registry's library path. The host derives the client from the validated CLI installation. Raw sessions reuse Surfaces by that native identity, including callers in different subdirectories of one project. Native CLI tabs and the pane share the selected tab; the host polls tab selection and metadata every 750ms while viewed, broadcasting only changes, and the current state to each connecting viewer. Screenshots reuse tab state for up to 750ms; explicit host controls refresh immediately. A native launch updates headed shutdown ownership and the pane's display mode. **Must apply that host-reported mode in the controller before the new viewer port**, so sync never sizes a headed window; ignore it mid-relaunch, and until params show the controller's own last mode write. -**Must expose only fixed host operations.** Navigation, tabs, viewport/device, screenshots, editing and close are validated host-side; arbitrary CLI arguments, JavaScript and CDP methods are unavailable through the webview channel. The trusted `dor pw` process retains native passthrough. Executable hints use the same filename/exact-host-override boundary as agent-browser, with `playwright-cli` as the accepted name; `dor pw` applies it to the executable a binding returns (`docs/specs/dor-cli.md` → Playwright Surface Addressing). +**Must expose only fixed host operations.** Navigation, tabs, viewport/device, screenshots, editing and close are validated host-side (commands by the shared parser above); arbitrary CLI arguments, JavaScript and CDP methods are unavailable through the webview channel. The trusted `dor pw` process retains native passthrough. Executable hints use the same filename/exact-host-override boundary as agent-browser, with `playwright-cli` as the accepted name; `dor pw` applies it to the executable a binding returns (`docs/specs/dor-cli.md` → Playwright Surface Addressing). **Must serialize GUI relaunches and closes per native session.** Close the previous CLI session before polling for its replacement; return when the browser endpoint is ready, without waiting for page load. **A pop-out or pop-in whose page is not http(s) must reopen blank**, never fail; only a GUI open's URL must pass the http(s) check. **Must answer a GUI launch inside the transports' `PLAYWRIGHT_REQUEST_TIMEOUT_MS`**: startup, queueing included, gets 30 s from the request's arrival, and every CLI call it waits on is killed at that deadline. **Must bound every other CLI call to 10 s, except `open`**, which lasts as long as the page load and whose end could take its browser down; nothing waits on it past a launch's own bounds. **A launch that gives up must let its `open` land, for up to 4 s, before closing the session**, and close again when a later `open` lands unless a newer launch owns the session (rationale). Only a completed, still-current GUI launch may close startup blank tabs, and only while a real page exists. Shutdown cancels pending launches, disconnects viewers and closes tracked headed sessions. Viewer disconnect alone leaves the CLI browser alive. Concurrent input/captures share CDP attachments; disposal releases late attachments. **A screencast that fails to start must forget its page**, so the next poll releases the attachment and retries. Temporary screenshots follow the agent-browser private-directory contract. @@ -455,15 +472,31 @@ The proxy instruments any `http://` upstream, loopback and remote alike: overridden, not obeyed** (rationale); JS framebusting is neutralized separately, by the sandbox. - Unreachable / timed-out upstream: served Dormouse error page, distinct for - "couldn't connect" and "didn't respond in 30s of socket idle". -- HTTPS: synchronous `scheme` failure with a `dor ab` hint — agent-browser is the - path for real HTTPS or a login. + "couldn't connect" and "didn't respond in 30s of socket idle", in the system + color scheme; **only a loopback upstream is called a dev server**. +- HTTPS: refused, per the table below. **Every panel error but a non-http(s) + URL offers Open in agent-browser** (a swap to `ab-screencast`) where the host + can launch one, with `dor ab open ` as the fallback text. - **Link-local / cloud-metadata address: refused (`scheme`)** — an SSRF guard that stands regardless of the loosened framing policy. **Canonicalize every equivalent spelling** (decimal/octal/hex, short forms, IPv4-mapped IPv6) before range-checking, so `0xA9FEA9FE` and `::ffff:169.254.169.254` are caught too; pinned by `lib/src/host/iframe-proxy-rewrite.test.ts`. +**Must refuse `https://` at every entry to the iframe renderer on a host with +the proxy, in the one `IFRAME_HTTP_ONLY` wording** (a host without it frames +https raw): + +| Entry | Outcome | +| --- | --- | +| `surface.iframe` (`dor iframe`) | refused before any pane opens, naming `dor ab open ` | +| Display modal | iframe option disabled, showing the wording | +| Render swap to `iframe`, tool or not | refused with a console warning; the modal never offers it, since both judge the page on screen (chrome URL, then `params.url`) | +| New-tab request from a framed page | an `ab-screencast` pane, bound to its launch like a render swap, closed if the launch fails | +| A pane already holding one | `scheme` panel error with Open in agent-browser and `dor ab open ` | + +The terminal context's port rows are always `http://` (`listenerUrlsByPort`). + Header rewriting: | Direction | Header | Treatment | @@ -471,17 +504,25 @@ Header rewriting: | request | `Host` | upstream host | | request | `Origin` | upstream origin **only** when it is the proxy's own; else forwarded untouched (absent stays absent) | | request | `Referer` | proxy origin replaced with the upstream origin | -| request | `Accept-Encoding` | deleted, so HTML comes back identity for rewriting | +| 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 | | request | `Cookie` | dropped, including WebSocket handshakes | | response | `Set-Cookie` | dropped, including successful and refused WebSocket handshakes | | response | `X-Frame-Options`, CSP headers | with validated chain, replaced by `frame-ancestors 'self' `; opted-in CSP policies remain alongside it (rationale) | | 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` | upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy | +| response | `Vary` | `Sec-Fetch-Dest` appended, since the `Accept-Encoding` sent upstream depends on it | | response body | `` | removed unless the response opts into CSP preservation | **Must update this table whenever header rewriting changes.** +**Must instrument only an identity-encoded, ASCII-compatible HTML body, and keep +its `content-type` as sent**, charset included; a compressed body, or a UTF-16 +one (any WHATWG label, or a byte-order mark), passes through uninstrumented +(rationale). **Never place the shim ahead of the doctype, a `` or a +UTF-8 BOM**: it goes before ``, else after ``, else after the +document's leading BOM/doctype/``/``/`` tags. + **Must preserve enforced and report-only CSP verbatim when the upstream response sends `X-Dormouse-Preserve-CSP: 1`.** Add the validated ancestor policy separately, for every MIME type; preserve meta policies during HTML instrumentation. Never infer this opt-in from request headers. Additional upstream restrictions may prevent framing or shim execution. (rationale) **One dedicated `127.0.0.1:0` server per grant, with no token in the path** — the @@ -497,13 +538,16 @@ keyboard and pointer interaction inside the frame by design. Source of truth: `lib/src/components/wall/IframePanel.tsx`, `lib/src/host/iframe-proxy.ts`, `lib/src/host/iframe-proxy-rewrite.ts` (`FRAMING_RESPONSE_HEADERS`, `HOP_BY_HOP_RESPONSE_HEADERS`, `instrumentHtml`, -`isBlockedAddress`), `lib/src/lib/platform/iframe-proxy-types.ts`. +`isBlockedAddress`, `errorPageHtml`), `lib/src/lib/platform/iframe-proxy-types.ts` +(`IFRAME_HTTP_ONLY`), `iframeRefusal` in `lib/src/components/wall/browser-url.ts`, +`isLoopbackHostname` in `lib/src/lib/ip-literal.ts`. ### Iframe Shim **Must send four fixed, never-user-provided message kinds to the app and nothing -else** — `leader`, `pointerdown`, `location`, `open-window`. **`location` is -never relayed from a nested document**; the other three are. **`open-window` +else** — `leader`, `pointerdown`, `location`, `open-window`; `location` carries +`loaded: true` only on the document's own `pageshow`/`DOMContentLoaded` report. +**`location` is never relayed from a nested document**; the other three are. **`open-window` intercepts every anchor target but `_self`**, plus `window.open`. **Only `http:` and `https:` reach a browser Surface, re-checked at the sink.** @@ -516,11 +560,20 @@ Leader messages feed the same Wall command-mode exit path as in-document dual-tap; `IframePanel` maps proxy-origin `location` URLs back to upstream URLs for chrome/history without reloading the frame. -New-tab requests show an overlay: accept opens an adjacent browser pane; cancel drops it. -Neither switches to agent-browser. +New-tab requests show an overlay: accept opens an adjacent browser pane (for +`https://`, see [Iframe Renderer](#iframe-renderer)); cancel drops it. + +**Once a proxied frame's shim has reported, a `load` with no `location` report +within 1s marks the document uninstrumented** (not HTML, off the proxy, refused, +or its grant gone), and a banner offers Reload and Open in agent-browser. Only a +`loaded` report naming the proxy origin counts, including one up to 250ms before +the load — a clicked link's report comes from the page being left; a new frame +source waits for its first report again, since a non-HTML +document served from the start carries no shim (rationale). Source of truth: `lib/src/host/iframe-proxy-rewrite.ts` (`iframeShim`), `lib/src/components/wall/browser-url.ts` (`browserSurfaceUrl`), +`lib/src/components/Wall.tsx` (`onOpenBrowserPane`), `lib/src/lib/iframe-proxy-registry.ts`, `lib/src/components/wall/use-wall-keyboard.ts`, `lib/src/components/wall/IframePanel.tsx`. @@ -548,7 +601,8 @@ focuses/blurs the iframe element like other surfaces). The optional `PlatformAdapter.createIframeProxyUrl` method and the `IframeProxyResult` union are canonical in the platform types. Reachability is diagnosed lazily by served error pages after the iframe loads the proxy URL, and -frame refusal is not diagnosed at all, so v1 mostly returns `ok` or `scheme`. +frame refusal only as an uninstrumented load ([Iframe Shim](#iframe-shim)), so +v1 mostly returns `ok` or `scheme`. **The webview passes its own ancestor chain with every request for a proxy URL** — `location.origin` plus `location.ancestorOrigins`, knowable only in the diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 5502d91b6..86e723e47 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -42,10 +42,14 @@ The persisted `wsPort` mirror can lag the controller's already-live port after a **What parking is worth.** Lath leaves stay mounted, so a background window would otherwise retain every pane's ~20Hz decode and screenshot round trips. The ~1s debounce rides through transient visibility flips and StrictMode remounts without rebuilding the connection. -**What the two-stage split buys.** Three things at once: pointer feedback that does not wait on a screenshot child-process round trip, a resting image sharp on HiDPI, and an idle animated page that does not pay to decode the stream continuously. Either path alone gives up one of the three. +**What the two-stage split buys.** Three things at once: input feedback that does not wait on a screenshot child-process round trip, a resting image sharp on HiDPI, and an idle animated page that does not pay to decode the stream continuously. Either path alone gives up one of the three. + +**Why keys open the window too.** A keystroke's echo is the most latency-sensitive paint there is; outside the window it waited a whole crisp capture — ~120ms, plus up to ~180ms of loop pacing in a burst — while a hover repainted from the stream in ~50ms. On HiDPI the typed text is CSS-resolution until 250ms after the last key, then sharpens, as hover already did. **Why no crisp capture starts inside the provisional window.** A host screenshot round trip is ~120ms against a ~20Hz stream, so *every* capture started while provisional frames are still landing is superseded before it resolves; the shots skipped would never have drawn anything. +**Why an overdue capture paints the stream and is never re-issued.** `open` holds the daemon's queue until the page loads, up to 25s (see [Pop-Out](#pop-out)), and the URL bar, `dor ab open` and every relaunch run it. A capture issued meanwhile waits behind it, so the canvas stayed on the previous page for the whole load while the stream was already showing the new one. An 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — about three blocked CLI processes by 25s — and on VS Code the first reply's unlink could delete the second capture's file. VS Code's adapter gave up on a reply after 10s and the webview re-asked the same way, posting the full JPEG to both requests once it came; every adapter now waits 30s, and the host still joins concurrent captures, since surfaces can share a session. When each overdue paint counted against the held capture, it was discarded on arrival and re-taken — an extra full capture on every slow navigation, the crisp frame a round trip late. Its wait's pulses still owe one follow-up: a capture queued behind `open` is taken at the end of its round trip, but one slow in itself may have been taken before the page's last change. The overdue round trip timed the page load, not a capture, so the loop clamps it before it enters the pacing average; otherwise the next slow load would wait ~16s to count as overdue. + **Why a stale-dropped capture must leave the loop dirty.** A provisional frame can supersede the host capture or its pending bitmap decode. Nothing is guaranteed to pulse the loop again: a single pointer move over a static page pulses exactly once, and that one pulse is consumed by the very capture the provisional paint supersedes — leaving the pane on the blurry provisional frame until the page happens to change on its own. **Why every non-crisp painter must bump the draw generation.** The byte-dedup compares an incoming capture against the last crisp draw. A resting page whose crisp bytes match that draw dedups to a no-op and strands the pane on the blurry provisional frame; a freshly re-attached canvas mounts blank and has the same problem. @@ -74,7 +78,9 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli **Why standalone passes a screenshot path, not bytes.** The sidecar stdio is a JSON-lines pipe shared with PTY traffic; a base64 frame on it would bloat every capture and interleave with terminal output. -**Why `binaryPath` needs a gate of its own.** The subcommand allowlist covers arguments, not the executable: `streamStatus`, `open` and `popOut` supply their own args and each take a `binaryPath`, so an allowlist on subcommands never sees one. And the value is persisted into the pane's params, so an unchecked one is not a one-shot — it is arbitrary local execution in the extension host or the Tauri sidecar on every subsequent launch. Dropping rather than failing degrades a stale or hostile value to "resolve it yourself". +**Why the verb alone is no boundary.** agent-browser honors launch options after the verb: `agent-browser --session x open about:blank --executable-path /nonexistent` fails with `Failed to launch Chrome at "/nonexistent"` (checked against 0.31.1, 2026-09-23). A verb-only allowlist therefore let an allowed `open`, `back` or `tab` carry `--executable-path`, `--args`, `--extension`, `--init-script`, `--profile`, `--state` or `--proxy` past the `binaryPath` gate; it also passed `close --all` (every session), `tab new `, and `screenshot `, which writes an image over any file the user can write. A session name becomes `/.pid`, whose pid a relaunch SIGTERMs, so a `/` in it reaches outside that directory. The two hosts first parsed the same argv separately and drifted within a day: agent-browser took any URL scheme and any DPR, Playwright http(s) and DPR ≤ 10 — so one parser serves both. + +**Why `binaryPath` needs a gate of its own.** The argv check covers arguments, not the executable: `streamStatus`, `open` and `popOut` supply their own args and each take a `binaryPath`, so a check on `command`'s argv never sees one. And the value is persisted into the pane's params, so an unchecked one is not a one-shot — it is arbitrary local execution in the extension host or the Tauri sidecar on every subsequent launch. Dropping rather than failing degrades a stale or hostile value to "resolve it yourself". **Why the screenshot path is private.** The frame is a picture of the user's authenticated browser, written by an external process under the ambient umask, so a derivable name in the shared temp directory is readable by anything else on the machine for as long as it exists. Precedent: `standalone/sidecar/clipboard-ops.js` applies the same discipline, cleanup included, to clipboard images. @@ -92,10 +98,16 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli The built-in local-file viewer supplies its own content boundary and permits the inline shim, so removing its CSP would expand active documents' resource access. Its response opts into preservation without new renderer or host-bridge state. The proxy adds an independent ancestor policy: CSP policies intersect, so no directive parser or partial reconstruction can accidentally weaken the upstream. An opt-in upstream with stricter framing or script restrictions keeps those restrictions even if the shim cannot run. +**Why a UTF-16 body is recognized by its BOM too.** The browser's BOM sniff wins over any header, so a `text/html` body with no charset but `FF FE` is UTF-16; its latin1 scan finds no markers (every character is followed by a NUL), and the fallback spliced the shim in ahead of the BOM, turning the page to mojibake. A UTF-8 BOM ahead of the fallback position had the same fate. + +**Why the HTML path keeps the body's own encoding.** Each was reproduced against the proxy (2026-09-23): relabelling every HTML response `charset=utf-8` overrode both the upstream header and any ``, so a Shift_JIS or windows-1252 page mis-decoded; an upstream that compresses without being asked had the shim prepended to its gzip bytes with `content-encoding: gzip` kept, and the frame failed with `ERR_CONTENT_DECODING_FAILED`; and a valid document with neither `` nor `` got the shim before ``, switching it to quirks mode. Deleting `Accept-Encoding` on every request sent a remote upstream's scripts and styles uncompressed, typically 3-5x the bytes. + **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 Shim +**Why the uninstrumented check waits for a first report.** The proxy instruments `text/html` only, and the parent cannot read a cross-origin frame's content type. A frame judged from its first load flagged every working non-HTML page — `dor iframe …/health.json`, and every image or PDF the file-viewer Tool frames directly. Waiting for one report means the frame has shown it carries the shim, so a later silent load is a real change; a link from an instrumented page to a PDF still flags, which the banner's wording ("not HTML, …") admits. + **Why the CLI's own check is not enough.** `open-window` carries a string the framed page chose, and the new-tab prompt in front of it is user consent, not a boundary — the user is agreeing to open a pane, not vetting a scheme. The same check gates `surface.iframe`, a wire protocol on the control socket rather than the CLI, so nothing upstream of it has already filtered. **Why the panel checks again.** Every writer of `params.url` ends at the panel, and on a host with no proxy the raw fallback hands that string straight to `