Repository navigation
Show pages and Tools in Pocket over the encrypted session - #1515
Conversation
A paired phone or one-time Client can now reach the pages the laptop's iframe browser panes and Tools show. The Client frames the page at an origin of its own and carries every request over the authorized session; the Burrow answers each through a lease on the same iframe proxy a local pane frames, so the proxy's pinning, rewriting, shim, and error pages apply unchanged. - Wire: hello `capabilities.frames`, `browser` on directory entries and `type: 'browser'` entries (listed only to a frames-capable Client), and the frame.open / http.* / ws.* / flow.ack methods in frame-wire.ts. - Burrow: FrameTunnel opens a frame only at the page its surface embeds, holds the grant under a session-owned lease, closes it when the page goes, bounds frames/exchanges/sockets/headers, and keeps Burrow-to-Client body bytes inside a flow window the Client acknowledges. - Node: createBurrowFrameHost replays requests and WebSockets against the grant as its own loopback caller, translating the frame origin, dropping cookies and Sec- fields, and decoding compressed bodies. - Webview: browserOp answers a surface's embedded page from the browser chrome registry; page changes invalidate the directory. - Specs: remote-api.md "Frames", bounds, S292/S293, and the pocket-frames scope for the Client half. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- createBurrowFrameHost takes the host's own iframe proxy (main.js's iframe-proxy.cjs, VS Code's iframe-proxy-host) instead of importing a second copy into burrow.cjs, so remote and window leases share one bound. Remote leases carry REMOTE_LEASE_OWNER_PREFIX, which a window sweep (retainIframeProxyOwners) never ends. - rewriteOrigin moves to iframe-proxy-rewrite.ts and serves the proxy and the tunnel's Referer and Location translation alike. - FrameTunnel: revalidates every frame from one directory collect, encodes a body chunk only when the window sends it, derives frame membership and socket state instead of mirroring them, and reuses concatBytes, utf8Encode, and base64UrlLength. - FrameClosedEvent drops the never-sent 'closed' reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FrameClient speaks the frame methods over a session as ReadableStream responses and sockets. No UI: Pocket's hello still declares no frames, so its directory is unchanged. - flow.pause holds one exchange. A Client now acknowledges body bytes on receipt rather than when its page reads them, so a page that stops reading (a paused video) holds its own exchange, never the session's window. - A subscription outlives a cancel until the answer, so bytes the Burrow already sent are still acknowledged and the window never shrinks. - FrameClient queues behind MAX_HTTP_EXCHANGES_PER_SESSION: an unbundled page's hundreds of modules meet no browser connection limit in front of a service worker. - toBase64Url encodes into bytes then decodes once: 172 MiB/s, up from 30 MiB/s, for a body chunk's 192 KiB; same output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The non-UI remainder of the Client half, short of serving it. A framed page's requests reach the Client window through a stateless service worker and a hidden bootstrap page, and FrameClient answers them; a tunneled grant's pages get a WebSocket shim, since no worker sees a socket. Nothing in Pocket's bundle changes: it neither declares frames nor mounts one. Driven in Chromium through the real chain, same-site and cross-site: POST with Origin and Referer translated, a 6 MiB body, 120 parallel requests, a redirect, a 204, a subprotocol socket with a 400 KB message, the built-in Markdown and Monaco editors, and a Monaco save reaching disk. A top-level visit is refused and a worker with no Client window answers 503. pocket-app.md's "Frame origin" holds the contract; pocket-frames keeps serving it per deployment and the UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- frameOriginFor names one origin per Surface and page, from a device salt the host never sees: a reload keeps the page's storage, a phone does not gather a new origin per open, and the hostname names no page. - frameOriginResponse answers a frame origin's three files, framed only by the Client, and 404 for anything else; it holds no credential. - pocket-frames records the decisions: Hosted on a registrable domain of its own so a framed page cannot set a cookie on dormouse.sh, a real iPhone check before the UI, and a self-host Relay staying terminal-only for now. Not wired for deploy: the Worker's Wrangler config, WORKERS entry, and release checks wait for the domain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/frame-origin-check serves a page standing in for Pocket and a frame origin from the shipped worker, bootstrap page, bridge, socket shim and Hosted handler, on two ports for any HTTPS front. A fake OpenFrame in the page answers the frame's traffic, so it needs no session or pairing, and each run's results post back to the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker in a cross-site frame works on an iPhone, in a tab and from the Home Screen, and recovers after a force-quit. A frame's localStorage did not survive that relaunch, so a built-in Tool must keep no state in browser storage; they keep none today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A remote Client frames a page only where 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. A local pane keeps its remote pages; the directory lists none of them to a Client. Frames exist for Dor Tools and local dev servers, and a remote page's stripped protections would reach a phone with no consent the Burrow can read. Future item 1a records what lifting this needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nder Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Page traffic is far heavier than a terminal's: over the Relay it would look broken and put every page's volume on it. The Burrow answers frame.open only once the direct path carries both directions. The switch is one-way, so a frame never outlives its path. A relayed session still lists its pages, so a Client can say why they do not open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the dormouse-frames Worker on a wildcard route: three static files and a 404, no binding but its assets, and HSTS since .com does not force HTTPS. It deploys first and passes a live smoke, so a Pocket that frames never outruns it. Previews skip it, as workers.dev has no wildcard. Audited as S296 and S297. Nothing declares frames yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Tool opens on its page under the header, with a button to its terminal and back, and a browser Surface shows its page alone. The bottom bar and composer go, and touches and keys are the page's while it shows. MobilePagePane says what a page is waiting on, including the relayed session's note about a VPN on both devices. Storybook only: nothing in Pocket uses these yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Browser and Terminal buttons lead the mobile header, in the desktop's glyphs, with the one showing marked. The model says browser rather than page, and a Session names its browserDisplay, so agent-browser and Playwright streams can land in the same layout: a story shows one wearing the robot glyph, and scope mobile-browser-streams holds the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row's path fills the space beside its title, as the header's does, rather than a second line under it. The path gives way before the title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mobile header offers a close on a Tool or browser in either mode, through onBrowserClose, even where the consumer hides the terminal's kill. Pocket does not pass it yet: closing a Surface from a phone needs a new surface.close, recorded in scope pocket-frames. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
surface.close runs the desktop's kill gesture decisions in the owning Wall: a transfer or a helper's work refuses, a close that must confirm answers confirm, and unsaved edits answer unsaved, with consent skipping no refusal. A terminal never closes this way, and a session that declared no frames closes nothing. A peer op answers within a budget, so the Burrow polls for the outcome. Logged as closed, and audited as S298. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hosted's production Pocket declares frames, and shows a Tool on its browser and an embedded page alone: its frame on the session, the bootstrap page joining the worker, and the page sandboxed as a pane's. A browser showing holds no terminal, up to four frames stay loaded with unsaved edits spared, and a relayed session says what would help. Close asks what the desktop asks, the phone's own unsaved edits first. Nothing is deployed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Peer ops may now answer asynchronously, with a longer budget for a close, which removes its polling. The tunnel takes the session's directory collect rather than running its own, acks coalesce on a short timer, and flow control has hysteresis. WORKERS is the application Workers plus the frame Worker, so nothing has to opt out of an origin or a preview. Removes a wire field and a provider field nothing read, and reuses embedderOrigins, ModalFrame, messageOf, the JSON store, DEFAULT_RELAY_ORIGIN, and the hop-by-hop list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bootstrap page is sandboxed too, since the page it sits beside can script it and step out of its own sandbox through it. A close addresses the one owner it resolved, so a duplicate in another window stays. A frame whose page went while its grant was minted closes. A cancelled exchange or a closed frame drops what it left queued, which no Client would ack and would hold the flow window for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frame.open names the page origin its frame origin is for, and the Burrow refuses another, so a page that moved never inherits the old page's storage. A close finds its one owner with a read-only probe that answers for browsers too, closing nothing when no owner answers, which also ends the browser-only broadcast. A close left asking about the phone's own edits is cancelled with the wall, so its answer cannot reach the next Burrow. A socket paused while connecting stays paused once open. Request bodies now go only through http.write, and every body chunk shares a session-wide send window, so parallel uploads cannot overflow the direct sender. A socket's close follows its sends, and a page going away closes the sockets it holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The close's owner probe moves into VS Code's existing attach mechanism, the one host with duplicate windows, so the shared provider and standalone go back to one ask. The phone's own unsaved edits use the close hook's dialog rather than the Wall's module-wide prompt, so nothing outlives the wall to need cancelling. One Gate replaces the exchange slots and the send window, one body sender and shared frameChunks replace three chunking loops, and normalizeOrigin, messageOf and the exact base64url guard replace local copies. Removes the queue drops and the stripped request the fixes left redundant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only covering it left xterm's z-indexed link layer above the iframe, so a drag on the page selected in the terminal. As the desktop's ToolPanel does, the terminal is now hidden and inert while its size holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What a connection is and how to leave it, Pocket's way back to the Burrows and the one-time page's End, moves from a row above the wall to the head of the Sessions screen. The wall host now clears the status bar, and a wall with no Session offers the screen directly, since it has no header to tap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A remote close consents only to the edits it was asked about, so edits made after a save or a clean read refuse it, and the Surface's kind is judged again at the close itself. The socket shim takes a send's bytes at the call, hands a Blob over as it is for the Client to read in order, and installs ahead of the page's own scripts. A directory listing now only raises the question of whether a frame's page went, and the surface's owner answers it, so a stale or empty listing cannot revoke a live frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tunnel no longer collects a directory of its own: its trigger and a new frame ask the surface's owner directly, while a session's listing stays a cheap filter. A frame closed as gone can be tried again, since a slow owner reads as gone too. closeForClient alone holds the terminal rule, one serial queue orders a socket's messages at both ends, and PocketWall owns its host with one ConnectionHeader for both apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The socket shim's insertion point searched the first kilobyte for tags, so a tag spelled in a script's text put the shim inside that script. It now walks the document from the start and stops at the first script. Its send recognizes a buffer from another window as binary rather than stringifying it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-reading the phone's edits before each ask still lost anything typed during the last one, so the page now takes no input from the start of a close to its end, and the check goes back to one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quoted `>` in an attribute no longer puts the shim inside that attribute. A page the upstream compressed anyway is decoded before instrumenting on a tunneled grant, since its sockets need the shim. Frame staging reads Windows path separators as the slashes its list spells. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proxy decoded HTML and the frame host everything else, which took a shared module and left a stray decoder on compressed non-HTML. "Answer every body identity-encoded" is a property of the tunneled grant, so the proxy now owns all of it, and the frame host only translates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page stays listed through a slow lookup, on the last answer for its origin, since dropping it from the directory loses its unsaved edits on the phone. A request the worker gave up on is abandoned rather than run after its 504, and a worker that stopped running no longer strands an exchange. A tunneled grant decodes every coding it can and an empty body, and refuses one it cannot rather than handing on the bytes as plain. A close finishing after the Client went is still logged, a shimmed socket refuses a close a native one refuses, and the shim stays behind a charset that a script stands ahead of. The spec now says only HTTP exchanges queue behind their bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decodedBody answers a body or null rather than three states, and the lookup cache uses settleAllWithin and holds nothing for a URL with no origin. A page's own abort now reaches the abandon path, and a failed relay no longer holds the worker open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed name lookup keeps a listed page listed, as a slow one does. The flow window charges each message as well as its bytes, so a burst of tiny ones pauses its sources long before the direct sender's queue fills. A page's abort before its head now cancels the upload, and a shimmed socket's bufferedAmount falls as sends leave the phone, for pages that throttle by it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The locality helper turned a resolver failure into false, so a transient DNS failure still dropped a listed page. It now rejects, which the lookup cache already reads as no answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
2eebdc3
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a7f8c452.mouseterm.pages.dev |
| Branch Preview URL: | https://pocket-browser.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
A framed page's requests can be served by the wrong Pocket tab. Details are inline on bootstrapOf in lib/src/remote/frame-origin/worker.ts.
… tab The one-time page now frames pages as Pocket does, with its salt held in memory for the one session, so a frame origin it leaves behind cannot be reached again. Its CSP names the frame domain and its sandbox grants what a frame needs, since a frame's is at most its parent's. "Ephemeral" now means ephemeral to the person using it: the page keeps nothing of its own, and what a framed page stores stays at its own frame origin. Pocket's salt moves to sessionStorage, one per tab. Two tabs on one Surface shared an origin and one worker, which cannot tell whose page asked, so a frozen background tab could hang a visible page's requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A frame's sandbox is at most its parent's, so a flag added to the shared constant now reaches frames on the one-time page rather than being silently capped. Also names the one way two tabs can still share a frame origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
S95 in docs/specs/security-hosted.md now contradicts the one-time page's policy, so the nightly audit (a release gate) will fail it. The rule still says the /connect/ policy must not admit "a network source outside the relay APP_ORIGIN's /connect/" or permit "framing, forms, or popups". The policy now adds frame-src https://*.dormouseusercontent.com, and its sandbox grants allow-forms, allow-popups, and allow-popups-to-escape-sandbox. S296 already owns the frame source, so S95 can carve that out and name what it still forbids, for example: "…permits inline or off-origin script, being framed, a form submission (form-action), or a top navigation…". That line is outside the diff, so it isn't an inline suggestion.
The other findings are inline.
- Reword S95 for the policy the one-time page now has: frames are S296's, and the sandbox's forms and popups are not what it forbids. - A tunneled grant sends no Clear-Site-Data, whose reason is a loopback port a tunneled page is not at, and the tunnel drops the hop-by-hop headers of its own connection to the proxy. - A socket honors a pause and a resume while closing, so a held socket gets the upstream's close code rather than ws's 1006 after 30 s, and a full session window also holds one that is closing. - A Surface the computer closed during a wait is no terminal: the phone hears it closed rather than "A terminal closes only on the computer." - The worker posts nothing for a request the page aborted before it went. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Surface gone during a Client's close is one check in closeForClient, which also keeps it from asking to confirm a Surface that is gone. A tunneled grant is never fresh, rather than fresh and ignored. The Wall test shares its helper and kill with its neighbours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1515 added tunneled phone grants to the same proxy, so the merge reconciles the two gates in mintGrant: one locality check, refusing a tunneled grant first, then an external one the user has not allowed. - Pass allowExternal and tunneled to the VS Code host in an options object. Both were the fourth positional argument, so a plain merge let the webview's flag set tunneled. - Keep main's isThisMachineAddress and drop this branch's copy. - Skip the off-origin redirect page on a tunneled grant, whose Pocket frame cannot follow it. - Renumber this branch's rule to S299, since main's S294 is the frame tunnel, and set next to 300. - Ratchet docs/specs/dor-browser.md, since both sides added words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he phone #1515 sends a phone's frames through the same proxy, on tunneled grants that are refused off this machine whatever allowExternal says. The merge made the code agree; this pins it and says so. A tunneled grant now has a test that an off-origin redirect passes as sent: PocketBrowserFrame handles no `navigate`, so the Dormouse page would be a dead end there. burrow.test.ts pins the VS Code glue asking the real proxy for a phone's frame as tunneled, through the argument shape that once collided with allowExternal. S299 and the Guarantee row name local panes and point at S294 for tunneled grants; dor-browser.md defers its definition of local to remote-api.md's, which also stops saying a local pane keeps every remote page. The phone's redirect is recorded in remote-api.md's Future. pinAddresses' comment no longer claims every private address is trusted, which stopped being true when the gate landed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lets Pocket show Tools and browser Surfaces in real iframes on the phone, and close them, with every page request tunneled over the existing encrypted Noise session.
What it does
frame.open,http.*,ws.*,flow.ack,flow.pause) to the Burrow, through the same iframe proxy a local pane uses. Nothing passes through Hosted but three static files.frame.openis refused on a relayed session, and the phone says why, naming a VPN such as Tailscale. Only pages on the Burrow's own machine are listed or opened.dormouseusercontent.com, salted per tab, with a stateless service worker and a sandboxed bootstrap page. Thedormouse-framesWorker serves only those files, framed only by Hosted's relay origin, which serves Pocket and the one-time page. Self-hosted Pocket stays terminal-only.surface.closefollows the desktop's contract and wording, and leaves a reopen record. The phone's own copy of a Tool is asked about first, and its page takes no input while the close runs.Specs:
docs/specs/remote-api.md-> "Frames" and "Closing a surface",docs/specs/pocket-app.md-> "Frame origin",docs/specs/mobile-terminal-ui.md-> "Browser", plusS296,S297, andS298in the security specs.Testing
pnpm testand Hosted's suite pass, and the Pocket/Browser stories pass in Chromium and WebKit./code-review. The last blank-session review found no P0 or P1. Its four P2s are fixed, and a Codex review of those fixes found one more, also fixed.Not verified:
ExternalLinkModalat phone width, recovery after the phone wakes, the shim's later changes, and the keyboard in a frame.relay.dormouse.shorigin, together with frames.dormouse-framesdeploy from CI. The zone's wildcard record exists anddormouse-hosted-production-deploynow covers the zone, but this Worker deploys first, so a token mismatch would stop the Hosted release.Known limits
OWNER_SILENCE_LIMIT_MScloses its frames, which loses a phone's unsaved edits; Try again reopens the page.MAX_WEBSOCKETS_PER_SESSIONfails rather than queues, so hidden kept pages can use up the slots.🤖 Generated with Claude Code