From 69ae1f21db81400590ca9b07df4d9556333b4278 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 10:23:21 -0700 Subject: [PATCH 01/52] docs(specs): sketch the remote surface API for phone and VR clients One protocol, two consumption depths: the phone watches a pane directory and attaches to one surface; VR replicates the whole wall (layout tree + per-surface streams + the existing surface.* mutation vocabulary). Terminals replicate as PTY data + semantic events; browser surfaces as adaptive screencasts; iframes are placeholder-only in v1. Rides on the authorized session from the remote security model. Co-Authored-By: Claude Fable 5 --- docs/specs/remote-api.md | 336 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/specs/remote-api.md diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md new file mode 100644 index 00000000..a7f19ad8 --- /dev/null +++ b/docs/specs/remote-api.md @@ -0,0 +1,336 @@ +# Remote Surface API + +This spec sketches the API a Client uses to view and control a Host's surfaces +after a session has been authorized by the +[remote security model](./remote-security-model.md). Nothing here weakens that +model: every message below travels inside one authorized session, and the Host +can terminate the session (and every stream in it) at any time. + +Two consumers, one protocol: + +* **Phone (Dormouse Pocket)** — the user sees a directory of the Host's active + panes (terminal and browser), picks one, and views/controls just that one. +* **VR headset** — the client runs the entire Dormouse UI remotely: the full + wall layout, every surface live at once, each rendered as its own panel in + space. + +The phone is not a different API — it is a shallow consumer of the same one. + +| Capability | Phone | VR | +| --------------------- | ---------------- | ---------------- | +| `directory.watch` | yes (the picker) | optional | +| `surface.attach` | one at a time | many at once | +| `wall.watch` (layout) | no | yes | +| Layout mutations | no | yes | +| Input | to attached pane | to any surface | + +Design principle: **replicate state, don't stream a desktop.** Terminals are +sent as PTY data and rendered client-side (the Client already ships the +terminal renderer); browser surfaces are sent as per-surface screencasts. This +is what makes VR viable — each surface arrives as its own independently +placeable, independently sized stream — and it makes the phone cheap: one +attached surface costs one stream. + +--- + +# Terminology + +Reuses the existing surface model (`dor/src/protocol.ts`, +`dor/src/commands/types.ts`): + +* **Surface** — the unit of content: `terminal`, `agent-browser`, or `iframe`. + Identified by `surfaceId`; carries `ref`, `paneRef`, `title`, `focused`, + `indexInPane`, `selectedInPane`. +* **Pane** — a tile on the wall holding one or more surfaces (one selected). + The phone's picker lists panes; attaching to a pane means attaching to its + selected surface. +* **Wall** — the full layout tree (workspaces → windows → panes) plus geometry. + Only VR consumes this. +* **Viewer** — one connected Client session. Multiple viewers may coexist; the + Host UI shows who is connected. + +--- + +# Transport + +## Channels + +An authorized session (the output of `authorizeConnection`) is bound to: + +1. **Control channel** — ordered, reliable, JSON messages. Carries requests, + responses, and event subscriptions. WebSocket relayed by the Server, or a + WebRTC data channel once rendezvous completes (the Server signals; per the + security model it is never trusted with authorization). +2. **Media channels** — per-attached-surface streams. Terminal data rides the + control channel (it is small and ordering matters). Browser screencasts + prefer an unreliable/unordered channel (WebRTC data channel or video track) + so a dropped frame is skipped, not queued behind. + +Every channel carries the `sessionId` issued at connection time. Future +hardening (see the security model's PRF section): pin the WebRTC DTLS +fingerprint inside the device-key-signed connect payload so even the signaling +Server cannot man-in-the-middle the media path. + +## Envelope + +Same shape as the dor control protocol — requests correlated by `requestId`, +events correlated by `subId`: + +```ts +interface RemoteRequest { requestId: string; method: string; params?: object } +interface RemoteResponse { requestId: string; ok: boolean; result?: object; error?: string } +interface RemoteEvent { subId: string; event: string; data: object } +``` + +## Hello + +First exchange on the control channel; establishes version and capabilities so +the protocol can grow without breaking older Pockets. + +```ts +// client → host +interface ClientHello { + protocolVersion: 1; + viewer: 'phone' | 'vr' | 'desktop'; + /** What the client can render / wants to do. */ + capabilities: { + screencast: ReadonlyArray<'jpeg' | 'webp'>; + input: boolean; // false = observe-only client + wall: boolean; // wants wall.watch (VR) + }; +} + +// host → client +interface HostHello { + protocolVersion: 1; + hostId: string; + /** What this session is allowed to do; see Input authority. */ + grants: { input: boolean; layout: boolean }; +} +``` + +--- + +# Directory (the phone's picker) + +`directory.watch` subscribes to a live, lightweight listing of every pane — +enough to render the picker and know which pane wants attention, without +attaching to anything. + +```ts +interface DirectoryEntry { + paneRef: string; + surfaceId: string; // the selected surface in the pane + type: 'terminal' | 'agent-browser' | 'iframe'; + title: string; // derived title, same one the wall header shows + focused: boolean; // focused on the host + // Terminal-only, from the existing semantic-event model (terminal-state.ts): + activity?: 'unknown' | 'prompt' | 'editing' | 'running' | 'finished'; + exitCode?: number; + cwd?: string; + // Browser-only: + url?: string; + /** The pane is ringing/alerting on the host (alert-manager). */ + attention: boolean; +} + +// events on the subscription +type DirectoryEvent = + | { event: 'directory.snapshot'; data: { entries: DirectoryEntry[] } } + | { event: 'directory.upsert'; data: { entry: DirectoryEntry } } + | { event: 'directory.remove'; data: { paneRef: string } }; +``` + +Thumbnails are requested separately (`directory.thumbnail { paneRef }` → +single downscaled frame / terminal screen render) so the picker stays cheap on +cellular and thumbnails are fetched only for what is on screen. + +--- + +# Attaching to a surface + +`surface.attach { surfaceId }` opens the surface's stream; +`surface.detach { surfaceId }` closes it. The phone holds one attachment; VR +holds one per visible panel. Attachment is view-state only — it never changes +what runs on the Host. + +## Terminal surfaces + +Replicated, not screencast: the client renders its own xterm from the same +data the host UI consumes. + +```ts +// host → client, once per attach +interface TerminalAttachSnapshot { + cols: number; rows: number; + /** Serialized screen + scrollback tail, ready to feed the renderer. */ + screenState: string; + scrollbackLines: number; // how much history the snapshot carries +} + +// then a stream of: +type TerminalEvent = + | { event: 'terminal.data'; data: { bytes: string /* base64 */ } } + | { event: 'terminal.resize'; data: { cols: number; rows: number } } + | { event: 'terminal.semantic'; data: TerminalSemanticEvent } // cwd/activity/title, as today + | { event: 'terminal.closed'; data: { exitCode?: number } }; + +// client → host (requires the input grant) +type TerminalInput = + | { method: 'terminal.write'; params: { surfaceId: string; bytes: string } } + | { method: 'terminal.resize'; params: { surfaceId: string; cols: number; rows: number } }; +``` + +**Resize authority.** A terminal has one size; the Host owns it by default and +remote viewers reflow/scale to fit (the mobile UI already renders at foreign +sizes). A viewer with the input grant may request `terminal.resize`; the Host +applies it only when no local view is displaying the pane, or when the session +holds the wall lease (see VR below). This avoids two screens fighting over +`SIGWINCH`. + +## Browser surfaces (`agent-browser`) + +The existing screencast path, made remote: + +```ts +type BrowserEvent = + | { event: 'browser.frame'; data: { format: 'jpeg' | 'webp'; width: number; height: number; bytes: string } } + | { event: 'browser.tab'; data: AgentBrowserTab } // title/url/active changes + | { event: 'browser.closed'; data: {} }; + +// client → host (requires the input grant); coordinates in frame space, +// the host maps them through the screencast scale into CDP input. +type BrowserInput = + | { method: 'browser.pointer'; params: { surfaceId: string; kind: 'tap' | 'down' | 'move' | 'up' | 'scroll'; x: number; y: number; dx?: number; dy?: number } } + | { method: 'browser.key'; params: { surfaceId: string; text?: string; key?: string; modifiers?: number } } + | { method: 'browser.navigate'; params: { surfaceId: string; url: string } }; +``` + +Quality adapts per attachment (`browser.quality { surfaceId, maxFps, +maxDimension, quality }`): the phone asks for less than a headset does, and a +background VR panel asks for less than the one being looked at. + +## Iframe surfaces + +Not streamable in v1 — the pane is a live DOM pointed at a Host-local URL. +Remote viewers get a placeholder card (title + URL) in the directory and wall. +Future: tunnel the existing iframe-proxy (`lib/src/host/iframe-proxy.ts`) +through the session so remote clients can load the same proxied URL; or fall +back to rendering the page in agent-browser and screencasting it. + +--- + +# Input authority + +Layered, consistent with "the Host is the final authority": + +1. **Pairing-time**: the ACL record's approval can carry a standing grant + (observe-only vs interactive) chosen in the Host's approval UI. +2. **Session-time**: `HostHello.grants` reports what this session actually + got; a client that asked for input may still receive `input: false`. +3. **Always**: the Host UI shows connected viewers and can revoke a grant or + kill the session live; in-flight input is dropped the moment it does. + +Destructive layout operations (`surface.kill`) additionally require the +`layout` grant and are confirmed on the Host the same way local kills are +(KillConfirm), unless the Host user has opted that session into unattended +control. + +--- + +# The wall (VR) + +VR does not stream the desktop; it *is* the desktop. The headset runs the same +web UI (`lib`) against remote data sources instead of local ones. + +## Layout replication + +`wall.watch` subscribes to the layout tree plus geometry: + +```ts +interface WallSnapshot { + workspaces: Array<{ + ref: string; name: string; + windows: Array<{ + ref: string; + panes: Array<{ + paneRef: string; + /** Normalized rect within the window, for initial spatial placement. */ + rect: { x: number; y: number; w: number; h: number }; + surfaces: Surface[]; // the existing Surface shape + }>; + }>; + }>; + focusedSurfaceId: string | null; +} + +type WallEvent = + | { event: 'wall.snapshot'; data: WallSnapshot } + | { event: 'wall.changed'; data: WallSnapshot }; // coalesced; layouts are small +``` + +The rects seed VR placement; after that the headset owns spatial arrangement +locally (a VR user re-hanging panels in space is presentation, not layout, and +does not round-trip to the Host). + +## Layout mutations + +The existing `surface.*` control vocabulary, carried over the session +(requires the `layout` grant): + +``` +surface.split surface.ensure surface.send +surface.kill surface.read surface.focus +``` + +These are the same methods the dor CLI speaks today; the remote API reuses +their request/response shapes so the Host dispatches both through one handler. + +## Wall lease + +A VR session may request `wall.lease`. Holding the lease means the headset is +the primary display: it wins resize authority for the terminals it displays, +and the Host UI may dim to a "being driven remotely" state. One lease at a +time; the Host user can always reclaim it locally. Phones never need it. + +--- + +# Multi-viewer semantics + +* Any number of observe-only viewers; streams fan out per attachment. +* Input is not locked to one viewer — grants are per-session, and interleaved + typing is no worse than two keyboards on one machine. The wall lease is the + only exclusive resource (sizing/primary-display). +* Every viewer is visible on the Host (label from the ACL record, e.g. + `iPhone Safari`), with per-viewer disconnect. + +--- + +# QoS notes (phone-first) + +* Terminal output is already coalesced host-side; the remote stream reuses + that batching and adds a per-session byte budget with tail-drop + resync + (send a fresh `TerminalAttachSnapshot`) rather than unbounded buffering on a + bad link. +* Screencast frames are droppable by design; only the newest frame matters. +* The directory is metadata-only; thumbnails are pull, not push. +* Detach on backgrounding: when the phone app/PWA loses visibility, the client + detaches streams but keeps the control channel; reattach is one message. + +--- + +# Open questions + +* **Transport v1**: start WebSocket-relay-only (simplest, works everywhere, + Server sees only ciphertext if we add app-layer encryption) and add WebRTC + later, or bite off WebRTC rendezvous immediately for latency? +* **Terminal snapshot format**: serialize the emulator state (fast attach, + version-coupled) vs replay a scrollback tail of raw PTY bytes (simple, + renderer-agnostic, slower for huge scrollback)? +* **Browser media**: screencast frames over a data channel are simple and + match agent-browser today; a WebRTC video track would be smoother for VR. + Possibly phone=frames, VR=track, negotiated in the hello. +* **Iframe surfaces**: how much of the iframe-proxy is safe to expose through + the tunnel (it can reach Host-local services)? May need its own grant. +* **Audio**: browser surfaces can produce audio; VR will want it (spatial, + per-panel). Out of scope for v1. From 671d4dfb9e7694e7bf998b0c67e77d4caea6b61f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 12:22:25 -0700 Subject: [PATCH 02/52] =?UTF-8?q?docs(specs):=20remote=20API=20v1=20decisi?= =?UTF-8?q?ons=20=E2=80=94=20WebSocket=20relay,=20selfhost-first,=20no=20i?= =?UTF-8?q?frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transport is WebSocket relay only in v1; WebRTC and app-layer encryption noted as API-neutral future upgrades - new Server deployment modes section: selfhost (env-var setup password, single user, passkey sign-in, local files, no database) ships in v1 and remains supported forever; SaaS multitenant (email + passkey) comes later - iframe surfaces are simply unsupported: omitted from the directory, refuse attachment, inert placeholder in wall snapshots - DirectoryEntry: attention -> ringing + hasTODO Co-Authored-By: Claude Fable 5 --- docs/specs/remote-api.md | 73 +++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index a7f19ad8..78e7a5ab 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -55,21 +55,36 @@ Reuses the existing surface model (`dor/src/protocol.ts`, ## Channels -An authorized session (the output of `authorizeConnection`) is bound to: - -1. **Control channel** — ordered, reliable, JSON messages. Carries requests, - responses, and event subscriptions. WebSocket relayed by the Server, or a - WebRTC data channel once rendezvous completes (the Server signals; per the - security model it is never trusted with authorization). -2. **Media channels** — per-attached-surface streams. Terminal data rides the - control channel (it is small and ordering matters). Browser screencasts - prefer an unreliable/unordered channel (WebRTC data channel or video track) - so a dropped frame is skipped, not queued behind. - -Every channel carries the `sessionId` issued at connection time. Future -hardening (see the security model's PRF section): pin the WebRTC DTLS -fingerprint inside the device-key-signed connect payload so even the signaling -Server cannot man-in-the-middle the media path. +v1 transport is **WebSocket relay only**: one WebSocket per session, relayed +through the Server, bound to the `sessionId` issued by `authorizeConnection`. +Control messages and media frames share the socket: + +* **Control** — requests, responses, and event subscriptions. Terminal data + rides here too (it is small and ordering matters). +* **Media** — browser screencast frames. A dropped frame must be skipped, not + queued behind: the Host keeps at most the newest frame per attachment and + sends it only when the socket drains, so a slow link degrades to a lower + frame rate instead of growing a buffer. + +Future upgrades, none of which change the API surface: WebRTC rendezvous for +latency (the Server signals but, per the security model, is never trusted with +authorization — pin the DTLS fingerprint inside the device-key-signed connect +payload), and app-layer encryption so the relaying Server sees only +ciphertext. + +## Server deployment modes + +The Server always ships in two modes; the remote API and the security model +are identical in both — the modes differ only in how accounts come to exist. + +* **Selfhost** — an env-var sets a setup password; presenting it allows the + system's only user to create their account and register the first passkey. + Sign-in from then on is passkey-only. No database: accounts, passkey + credentials, and revocation state live in local files. +* **SaaS multitenant** — anyone can create an account with email + passkey. + +v1 ships selfhost only. Selfhost is not a stepping stone: it remains a +supported mode alongside SaaS permanently. ## Envelope @@ -121,7 +136,7 @@ attaching to anything. interface DirectoryEntry { paneRef: string; surfaceId: string; // the selected surface in the pane - type: 'terminal' | 'agent-browser' | 'iframe'; + type: 'terminal' | 'agent-browser'; // iframe surfaces are not listed (unsupported) title: string; // derived title, same one the wall header shows focused: boolean; // focused on the host // Terminal-only, from the existing semantic-event model (terminal-state.ts): @@ -130,8 +145,10 @@ interface DirectoryEntry { cwd?: string; // Browser-only: url?: string; - /** The pane is ringing/alerting on the host (alert-manager). */ - attention: boolean; + /** The pane's alert is ringing on the host (alert-manager). */ + ringing: boolean; + /** The pane has an outstanding TODO waiting for the user. */ + hasTODO: boolean; } // events on the subscription @@ -212,11 +229,10 @@ background VR panel asks for less than the one being looked at. ## Iframe surfaces -Not streamable in v1 — the pane is a live DOM pointed at a Host-local URL. -Remote viewers get a placeholder card (title + URL) in the directory and wall. -Future: tunnel the existing iframe-proxy (`lib/src/host/iframe-proxy.ts`) -through the session so remote clients can load the same proxied URL; or fall -back to rendering the page in agent-browser and screencasting it. +Not supported. Iframe surfaces are omitted from the directory and refuse +attachment; wall snapshots still list them (the layout must be truthful) and +VR renders an inert placeholder. Nothing else in the protocol assumes they +exist, so support can be added cleanly later — it is not on the critical path. --- @@ -321,16 +337,11 @@ time; the Host user can always reclaim it locally. Phones never need it. # Open questions -* **Transport v1**: start WebSocket-relay-only (simplest, works everywhere, - Server sees only ciphertext if we add app-layer encryption) and add WebRTC - later, or bite off WebRTC rendezvous immediately for latency? * **Terminal snapshot format**: serialize the emulator state (fast attach, version-coupled) vs replay a scrollback tail of raw PTY bytes (simple, renderer-agnostic, slower for huge scrollback)? -* **Browser media**: screencast frames over a data channel are simple and - match agent-browser today; a WebRTC video track would be smoother for VR. - Possibly phone=frames, VR=track, negotiated in the hello. -* **Iframe surfaces**: how much of the iframe-proxy is safe to expose through - the tunnel (it can reach Host-local services)? May need its own grant. +* **Browser media**: screencast frames over the WebSocket are v1; when WebRTC + arrives, a video track would be smoother for VR. Possibly phone=frames, + VR=track, negotiated in the hello. * **Audio**: browser surfaces can produce audio; VR will want it (spatial, per-panel). Out of scope for v1. From 15b57db54c8d66c6088ebdf15ee2330efd74a745 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 12:53:31 -0700 Subject: [PATCH 03/52] docs(specs): terminal attach = resize, last-attach-wins sizing, semantic scrollback as v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 terminal attach carries the client's dimensions and the resize IS the repaint: TUIs redraw on SIGWINCH (with a SIGWINCH-then-nudge fallback when sizes happen to match), shells redraw their prompt, and no snapshot transfers. The in-flight command's output (captured from its OSC 133/633 commandStart boundary, tail-capped) replays on attach so 'is my build done?' works. History is explicitly v2: semantic command scrollback — per-command blocks rendered at the client's own width — additive to the v1 protocol. Size authority flips from host-owned to last-attach-wins; displays that lose authority grey out as 'tethering to ', and the wall lease becomes purely presentational (wholesale tethering). Co-Authored-By: Claude Fable 5 --- docs/specs/remote-api.md | 120 ++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 27 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 78e7a5ab..96204340 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -166,29 +166,56 @@ cellular and thumbnails are fetched only for what is on screen. # Attaching to a surface -`surface.attach { surfaceId }` opens the surface's stream; -`surface.detach { surfaceId }` closes it. The phone holds one attachment; VR -holds one per visible panel. Attachment is view-state only — it never changes -what runs on the Host. +`surface.attach { surfaceId, ... }` opens the surface's stream (terminals add +their dimensions — see below); `surface.detach { surfaceId }` closes it. The +phone holds one attachment; VR holds one per visible panel. Attachment is +view-state only with one deliberate exception: attaching to a terminal takes +size authority. ## Terminal surfaces Replicated, not screencast: the client renders its own xterm from the same data the host UI consumes. +### Attach is the resize (v1) + +The remote is virtually always a different size than the Host, and a resize is +exactly what makes a terminal paint itself — so attach carries the client's +dimensions and there is no snapshot transfer: + +1. Client attaches with `{ cols, rows }`. +2. Host resizes the PTY (last-attach-wins; see Size authority). `SIGWINCH` + makes full-screen TUIs repaint completely and shells redraw their prompt + line, filling the client's screen from the live stream alone. +3. If the requested size happens to equal the current size, the Host forces + the repaint anyway: `SIGWINCH` alone first (most TUIs refetch size and + repaint), then a quick rows±1 bounce if no output follows. +4. If a command is mid-flight, its output so far is replayed before the live + stream (see In-flight replay). + +Normal-screen history does not regenerate on resize; it is deliberately absent +from v1 and arrives as semantic scrollback in v2 (below). + ```ts -// host → client, once per attach -interface TerminalAttachSnapshot { - cols: number; rows: number; - /** Serialized screen + scrollback tail, ready to feed the renderer. */ - screenState: string; - scrollbackLines: number; // how much history the snapshot carries +// client → host +{ method: 'surface.attach', params: { surfaceId: string, cols: number, rows: number } } + +// host → client, the attach result +interface TerminalAttachResult { + cols: number; rows: number; // the size the PTY now has + /** Present when a command is running; its output since commandStart. */ + inflight?: { + commandLine: string | null; + startedAt: number; + bytes: string; // base64, tail-capped + truncated: boolean; + }; } // then a stream of: type TerminalEvent = | { event: 'terminal.data'; data: { bytes: string /* base64 */ } } - | { event: 'terminal.resize'; data: { cols: number; rows: number } } + | { event: 'terminal.resize'; data: { cols: number; rows: number } } // another display took authority | { event: 'terminal.semantic'; data: TerminalSemanticEvent } // cwd/activity/title, as today | { event: 'terminal.closed'; data: { exitCode?: number } }; @@ -198,12 +225,51 @@ type TerminalInput = | { method: 'terminal.resize'; params: { surfaceId: string; cols: number; rows: number } }; ``` -**Resize authority.** A terminal has one size; the Host owns it by default and -remote viewers reflow/scale to fit (the mobile UI already renders at foreign -sizes). A viewer with the input grant may request `terminal.resize`; the Host -applies it only when no local view is displaying the pane, or when the session -holds the wall lease (see VR below). This avoids two screens fighting over -`SIGWINCH`. +### In-flight replay (v1) + +The most common reason to open a pane on the phone is a command that is still +running — "is my build done?" — and a resize repaint shows nothing for a +command quietly writing a log. The Host therefore retains the output of the +current command from its `commandStart` boundary (OSC 133/633, with the +existing keystroke-heuristic fallback), tail-capped to a fixed byte budget; +attach replays it before the live stream begins. The buffer is dropped at the +next prompt — v1 remembers the in-flight command only, never history. + +### Size authority: last-attach-wins + +A terminal has one size, and the most recent size writer owns it: attaching +with dimensions and `terminal.resize` both take authority, and the Host user +interacting with the pane locally reclaims it. Every other display of that +pane — the Host's wall pane, other attached viewers — greys out and shows only +**"tethering to \"** (the ACL record's label, e.g. `iPhone Safari`) +instead of fighting over `SIGWINCH`. Interacting with a tethered pane is how a +display takes it back. + +### Future work (v2): semantic command scrollback + +History arrives as structure the Host already extracts, not as emulator state: +OSC 133/633 segmentation gives per-command boundaries, alt-screen spans are +already tracked and stripped, and the v1 in-flight buffer is the same capture +mechanism retained for one command instead of K. v2 keeps the last K commands +per pane: + +```ts +interface CommandBlock { + commandLine: string | null; + cwd: string | null; + exitCode: number | null; // null while still running + startedAt: number; + finishedAt: number | null; + bytes: string; // output, tail-capped, alt-screen spans stripped + truncated: boolean; +} +``` + +Attach then also delivers recent blocks, and the client renders them at its +own width — collapsible cards on the phone, panels in VR — rather than +replaying a fixed-width terminal. Additive by construction: an extra field on +`TerminalAttachResult` plus a `terminal.block` event, so nothing in the v1 +protocol changes shape. ## Browser surfaces (`agent-browser`) @@ -304,9 +370,11 @@ their request/response shapes so the Host dispatches both through one handler. ## Wall lease -A VR session may request `wall.lease`. Holding the lease means the headset is -the primary display: it wins resize authority for the terminals it displays, -and the Host UI may dim to a "being driven remotely" state. One lease at a +A VR session may request `wall.lease`, declaring itself the primary display. +Sizing needs no lease — last-attach-wins already hands VR the panes it +displays — so the lease is presentational: the Host UI tethers wholesale +("tethering to \") instead of pane by pane, and panes created on the +Host while the lease is held open tethered to the leaseholder. One lease at a time; the Host user can always reclaim it locally. Phones never need it. --- @@ -315,8 +383,9 @@ time; the Host user can always reclaim it locally. Phones never need it. * Any number of observe-only viewers; streams fan out per attachment. * Input is not locked to one viewer — grants are per-session, and interleaved - typing is no worse than two keyboards on one machine. The wall lease is the - only exclusive resource (sizing/primary-display). + typing is no worse than two keyboards on one machine. Terminal size is + last-attach-wins (see Size authority); the wall lease is the only other + exclusive resource (primary display). * Every viewer is visible on the Host (label from the ACL record, e.g. `iPhone Safari`), with per-viewer disconnect. @@ -326,8 +395,8 @@ time; the Host user can always reclaim it locally. Phones never need it. * Terminal output is already coalesced host-side; the remote stream reuses that batching and adds a per-session byte budget with tail-drop + resync - (send a fresh `TerminalAttachSnapshot`) rather than unbounded buffering on a - bad link. + (an implicit re-attach: repaint via resize plus in-flight replay) rather + than unbounded buffering on a bad link. * Screencast frames are droppable by design; only the newest frame matters. * The directory is metadata-only; thumbnails are pull, not push. * Detach on backgrounding: when the phone app/PWA loses visibility, the client @@ -337,9 +406,6 @@ time; the Host user can always reclaim it locally. Phones never need it. # Open questions -* **Terminal snapshot format**: serialize the emulator state (fast attach, - version-coupled) vs replay a scrollback tail of raw PTY bytes (simple, - renderer-agnostic, slower for huge scrollback)? * **Browser media**: screencast frames over the WebSocket are v1; when WebRTC arrives, a video track would be smoother for VR. Possibly phone=frames, VR=track, negotiated in the hello. From a6f5b7244a50a43326255c1827f3106a3b4bd7fc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 12:58:12 -0700 Subject: [PATCH 04/52] docs(specs): shrink remote API v1 to the minimal phone path New 'v1 scope' section pins v1 to: hello, snapshot-only directory (no deltas, no thumbnails), one attachment per session, terminal attach-is-the-resize with last-attach-wins tethering, browser frames at fixed quality with pointer/key input, and a single flat grant (selfhost is single-user, so every paired session is the owner). Deferred, each additive to the v1 protocol: in-flight replay (first follow-up; agent TUIs repaint on resize, which is what makes it deferrable), semantic scrollback (v2), thumbnails, graded grants + layout mutations, the whole VR/wall section, WebRTC, audio. Co-Authored-By: Claude Fable 5 --- docs/specs/remote-api.md | 180 +++++++++++++++++++++++---------------- 1 file changed, 107 insertions(+), 73 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 96204340..4bdbbf8b 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -33,6 +33,36 @@ attached surface costs one stream. --- +# v1 scope + +v1 is the smallest protocol that lets a phone **sign in, pick a pane, see it +live, and type into it** — the phone column of the table above, and only it: + +* Hello (version + capabilities) +* `directory.watch`, snapshot-only (no deltas, no thumbnails) +* `surface.attach` / `surface.detach`, one attachment per session +* Terminal: attach-is-the-resize, live data + semantic events, + `terminal.write`/`terminal.resize`, last-attach-wins tethering +* Browser: screencast frames at Host-chosen fixed quality, pointer + key input +* One implicit grant: every paired session has full input (selfhost is + single-user), no layout operations + +Everything else is future work, staged in likely order of arrival: + +1. **In-flight command replay** — first follow-up after v1 (see Terminal) +2. **Semantic command scrollback** — v2 (see Terminal) +3. **Directory thumbnails** +4. **Graded grants + layout mutations** (observe-only viewers, remote + split/kill with Host-side confirm) +5. **The wall: VR** (`wall.watch`, multi-attach, wall lease) +6. **WebRTC transport + app-layer encryption** +7. **Audio** + +Each future item is additive — a new method, event, or optional field — so +nothing in the v1 protocol changes shape when it lands. + +--- + # Terminology Reuses the existing surface model (`dor/src/protocol.ts`, @@ -107,11 +137,12 @@ the protocol can grow without breaking older Pockets. interface ClientHello { protocolVersion: 1; viewer: 'phone' | 'vr' | 'desktop'; - /** What the client can render / wants to do. */ + /** What the client can render / wants to do. v1 phones send + * { screencast: ['jpeg'], input: true, wall: false }. */ capabilities: { screencast: ReadonlyArray<'jpeg' | 'webp'>; - input: boolean; // false = observe-only client - wall: boolean; // wants wall.watch (VR) + input: boolean; + wall: boolean; }; } @@ -119,7 +150,8 @@ interface ClientHello { interface HostHello { protocolVersion: 1; hostId: string; - /** What this session is allowed to do; see Input authority. */ + /** v1: always { input: true, layout: false } — selfhost is single-user, so + * every paired session is the owner. Graded grants are future work. */ grants: { input: boolean; layout: boolean }; } ``` @@ -151,26 +183,26 @@ interface DirectoryEntry { hasTODO: boolean; } -// events on the subscription type DirectoryEvent = - | { event: 'directory.snapshot'; data: { entries: DirectoryEntry[] } } - | { event: 'directory.upsert'; data: { entry: DirectoryEntry } } - | { event: 'directory.remove'; data: { paneRef: string } }; + | { event: 'directory.snapshot'; data: { entries: DirectoryEntry[] } }; ``` -Thumbnails are requested separately (`directory.thumbnail { paneRef }` → -single downscaled frame / terminal screen render) so the picker stays cheap on -cellular and thumbnails are fetched only for what is on screen. +Snapshot-only, deliberately: a directory is dozens of entries at most, so on +any change the Host coalesces and resends the whole thing. Delta events are a +future optimization there is no current reason to pay for. + +Thumbnails are future work; in v1 the picker renders from titles, activity, +and the `ringing`/`hasTODO` badges. --- # Attaching to a surface `surface.attach { surfaceId, ... }` opens the surface's stream (terminals add -their dimensions — see below); `surface.detach { surfaceId }` closes it. The -phone holds one attachment; VR holds one per visible panel. Attachment is -view-state only with one deliberate exception: attaching to a terminal takes -size authority. +their dimensions — see below); `surface.detach { surfaceId }` closes it. v1 +allows one attachment per session (the phone's model); lifting that cap for VR +is future work. Attachment is view-state only with one deliberate exception: +attaching to a terminal takes size authority. ## Terminal surfaces @@ -190,11 +222,9 @@ dimensions and there is no snapshot transfer: 3. If the requested size happens to equal the current size, the Host forces the repaint anyway: `SIGWINCH` alone first (most TUIs refetch size and repaint), then a quick rows±1 bounce if no output follows. -4. If a command is mid-flight, its output so far is replayed before the live - stream (see In-flight replay). Normal-screen history does not regenerate on resize; it is deliberately absent -from v1 and arrives as semantic scrollback in v2 (below). +from v1 (see Future work below). ```ts // client → host @@ -203,13 +233,8 @@ from v1 and arrives as semantic scrollback in v2 (below). // host → client, the attach result interface TerminalAttachResult { cols: number; rows: number; // the size the PTY now has - /** Present when a command is running; its output since commandStart. */ - inflight?: { - commandLine: string | null; - startedAt: number; - bytes: string; // base64, tail-capped - truncated: boolean; - }; + // Reserved: `inflight` (in-flight replay) and `blocks` (semantic + // scrollback) land here additively — see Future work. } // then a stream of: @@ -225,16 +250,6 @@ type TerminalInput = | { method: 'terminal.resize'; params: { surfaceId: string; cols: number; rows: number } }; ``` -### In-flight replay (v1) - -The most common reason to open a pane on the phone is a command that is still -running — "is my build done?" — and a resize repaint shows nothing for a -command quietly writing a log. The Host therefore retains the output of the -current command from its `commandStart` boundary (OSC 133/633, with the -existing keystroke-heuristic fallback), tail-capped to a fixed byte budget; -attach replays it before the live stream begins. The buffer is dropped at the -next prompt — v1 remembers the in-flight command only, never history. - ### Size authority: last-attach-wins A terminal has one size, and the most recent size writer owns it: attaching @@ -245,13 +260,31 @@ pane — the Host's wall pane, other attached viewers — greys out and shows on instead of fighting over `SIGWINCH`. Interacting with a tethered pane is how a display takes it back. -### Future work (v2): semantic command scrollback +### Future work: in-flight replay, then semantic scrollback -History arrives as structure the Host already extracts, not as emulator state: -OSC 133/633 segmentation gives per-command boundaries, alt-screen spans are -already tracked and stripped, and the v1 in-flight buffer is the same capture -mechanism retained for one command instead of K. v2 keeps the last K commands -per pane: +**In-flight replay (first follow-up after v1).** The most common reason to +open a pane on the phone is a command that is still running — "is my build +done?" — and a resize repaint shows nothing for a command quietly writing a +log. (Dormouse's primary workload, agent TUIs, do repaint on resize — which is +what makes this deferrable at all.) The Host retains the output of the current +command 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: + +```ts +inflight?: { + commandLine: string | null; + startedAt: number; + bytes: string; // base64, tail-capped + truncated: boolean; +} +``` + +**Semantic command scrollback (v2).** History arrives as structure the Host +already extracts, not as emulator state: OSC 133/633 segmentation gives +per-command boundaries, alt-screen spans are already tracked and stripped, and +the in-flight buffer is the same capture mechanism retained for K commands +instead of one: ```ts interface CommandBlock { @@ -267,9 +300,8 @@ interface CommandBlock { Attach then also delivers recent blocks, and the client renders them at its own width — collapsible cards on the phone, panels in VR — rather than -replaying a fixed-width terminal. Additive by construction: an extra field on -`TerminalAttachResult` plus a `terminal.block` event, so nothing in the v1 -protocol changes shape. +replaying a fixed-width terminal. Additive by construction: a `blocks` field +on `TerminalAttachResult` plus a `terminal.block` event. ## Browser surfaces (`agent-browser`) @@ -285,13 +317,13 @@ type BrowserEvent = // the host maps them through the screencast scale into CDP input. type BrowserInput = | { method: 'browser.pointer'; params: { surfaceId: string; kind: 'tap' | 'down' | 'move' | 'up' | 'scroll'; x: number; y: number; dx?: number; dy?: number } } - | { method: 'browser.key'; params: { surfaceId: string; text?: string; key?: string; modifiers?: number } } - | { method: 'browser.navigate'; params: { surfaceId: string; url: string } }; + | { method: 'browser.key'; params: { surfaceId: string; text?: string; key?: string; modifiers?: number } }; ``` -Quality adapts per attachment (`browser.quality { surfaceId, maxFps, -maxDimension, quality }`): the phone asks for less than a headset does, and a -background VR panel asks for less than the one being looked at. +In v1 the Host picks fixed, phone-appropriate screencast parameters (JPEG, +capped dimension and frame rate). Per-attachment quality negotiation +(`browser.quality`) and remote navigation (`browser.navigate`) are future +work — a phone can drive the page's own UI in the meantime. ## Iframe surfaces @@ -304,26 +336,28 @@ exist, so support can be added cleanly later — it is not on the critical path. # Input authority -Layered, consistent with "the Host is the final authority": +v1 is deliberately flat: selfhost is single-user, so every paired session is +the owner and gets full input (`grants: { input: true, layout: false }`). No +session gets layout operations. The Host UI still shows connected viewers and +can kill any session live; in-flight input is dropped the moment it does. -1. **Pairing-time**: the ACL record's approval can carry a standing grant - (observe-only vs interactive) chosen in the Host's approval UI. -2. **Session-time**: `HostHello.grants` reports what this session actually - got; a client that asked for input may still receive `input: false`. -3. **Always**: the Host UI shows connected viewers and can revoke a grant or - kill the session live; in-flight input is dropped the moment it does. +Future work — graded grants, layered so "the Host is the final authority" +holds at every step: -Destructive layout operations (`surface.kill`) additionally require the -`layout` grant and are confirmed on the Host the same way local kills are -(KillConfirm), unless the Host user has opted that session into unattended -control. +1. **Pairing-time**: the ACL record's approval carries a standing grant + (observe-only vs interactive) chosen in the Host's approval UI. +2. **Session-time**: `HostHello.grants` reports what the session actually got. +3. **Layout**: destructive operations (`surface.kill`) require the `layout` + grant and are confirmed on the Host the same way local kills are + (KillConfirm), unless the Host user opts a session into unattended control. --- -# The wall (VR) +# The wall (VR) — future work -VR does not stream the desktop; it *is* the desktop. The headset runs the same -web UI (`lib`) against remote data sources instead of local ones. +Nothing in this section is v1. VR does not stream the desktop; it *is* the +desktop: the headset runs the same web UI (`lib`) against remote data sources +instead of local ones. ## Layout replication @@ -381,13 +415,13 @@ time; the Host user can always reclaim it locally. Phones never need it. # Multi-viewer semantics -* Any number of observe-only viewers; streams fan out per attachment. -* Input is not locked to one viewer — grants are per-session, and interleaved - typing is no worse than two keyboards on one machine. Terminal size is - last-attach-wins (see Size authority); the wall lease is the only other - exclusive resource (primary display). -* Every viewer is visible on the Host (label from the ACL record, e.g. - `iPhone Safari`), with per-viewer disconnect. +Concurrent sessions need no special machinery in v1: attach state is +per-session, streams fan out per attachment, and terminal size is +last-attach-wins with the tether display resolving contention. Every viewer is +visible on the Host (label from the ACL record, e.g. `iPhone Safari`), with +per-viewer disconnect. Interleaved typing from two granted sessions is no +worse than two keyboards on one machine; the wall lease (future) is the only +exclusive resource. --- @@ -395,10 +429,10 @@ time; the Host user can always reclaim it locally. Phones never need it. * Terminal output is already coalesced host-side; the remote stream reuses that batching and adds a per-session byte budget with tail-drop + resync - (an implicit re-attach: repaint via resize plus in-flight replay) rather - than unbounded buffering on a bad link. + (an implicit re-attach: repaint via resize) rather than unbounded buffering + on a bad link. * Screencast frames are droppable by design; only the newest frame matters. -* The directory is metadata-only; thumbnails are pull, not push. +* The directory is metadata-only. * Detach on backgrounding: when the phone app/PWA loses visibility, the client detaches streams but keeps the control channel; reattach is one message. From a76b2bf462a0c0938140437eb319bbccf040148e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 13:06:44 -0700 Subject: [PATCH 05/52] =?UTF-8?q?docs(specs):=20selfhost=20server=20POC=20?= =?UTF-8?q?spec=20=E2=80=94=20setup=20password=20to=20phone=20terminal=20i?= =?UTF-8?q?n=20five=20slices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Hono process, no database (account.json + hosts.json), terminal-only. WebAuthn needs no library: registration uses the browser's getPublicKey() SPKI (no CBOR/attestation), assertions verify through the same server-lib-common function the host uses, and server challenges reuse HostChallengeIssuer. One host challenge feeds both the passkey assertion and the device-key signature, so connecting costs one biometric prompt. Build order is five independently testable slices; the first three are pure Node, driven end to end by the SimAuthenticator harness with no browser. Co-Authored-By: Claude Fable 5 --- docs/specs/server.md | 216 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/specs/server.md diff --git a/docs/specs/server.md b/docs/specs/server.md new file mode 100644 index 00000000..7f414bb5 --- /dev/null +++ b/docs/specs/server.md @@ -0,0 +1,216 @@ +# Server (selfhost POC) + +The coordinating Server from the +[remote security model](./remote-security-model.md), in its selfhost mode, cut +down to the smallest thing that completes this loop: + +> Run the server with a setup password. Visit it, present the password, create +> a passkey. Pair your phone with your laptop's Dormouse Terminal. Move a +> running terminal session from the laptop to the phone. + +One Node process (Hono, as the `server` package already is). No database. No +browser-surface support — **terminal-only**. The heavy lifting is already +done: every security primitive lives in `server-lib-common`, and the terminal +UI lives in `lib`/`standalone`. + +## POC guardrails + +* One account (`accountId: "owner"`), created once with the setup password. +* Terminal surfaces only; the remote-api v1 subset minus browser surfaces. +* Revocation is editing a JSON file by hand; no management UI. +* A dropped WebSocket is handled by reloading the page / reconnecting the + host. No resume protocol. +* Everything transient (challenges, sessions, relay state) is in memory; a + server restart just means everyone reconnects. + +--- + +# Configuration + +| Env var | Meaning | +| ------------------------- | ---------------------------------------------------------- | +| `DORMOUSE_SETUP_PASSWORD` | Required. Gates account creation and host enrollment. | +| `DORMOUSE_ORIGIN` | External origin, e.g. `https://dormouse.tailnet.ts.net`. Source of the WebAuthn `rpId`/`origin` and the Host's `ConnectionPolicy`. Defaults to `http://localhost:` for dev. | +| `DORMOUSE_STATE_DIR` | Where the JSON state files live. Default `./data`. | +| `PORT` | Default 3000. | + +WebAuthn requires a secure context: `localhost` works for development; for a +real phone, put the server behind TLS (`tailscale serve` is the intended +selfhost path, any reverse proxy works). The server itself always speaks +plain HTTP. + +# State files + +``` +$DORMOUSE_STATE_DIR/ + account.json { accountId: "owner", + passkeys: [{ credentialId, publicKey /* SPKI b64u */, + label, createdAt }] } + hosts.json [{ hostId, hostToken, label, enrolledAt }] +``` + +That is the entire persistent state. The Host's ACL is not here — it lives on +the Host (platform `saveState`), which is the whole point of the security +model. + +--- + +# WebAuthn without a WebAuthn library + +Two facts keep the server dependency-free: + +* **Registration**: browsers expose the new credential's public key directly — + `response.getPublicKey()` returns SPKI DER. The Pocket page sends + `{ credentialId, publicKey, clientDataJSON }`; the server checks + `clientDataJSON` (`type === 'webauthn.create'`, its challenge, its origin) + and stores the key. No CBOR, no attestation parsing (we request + `attestation: 'none'` anyway). +* **Assertions**: `verifyPasskeyAssertion` in `server-lib-common` already + verifies full assertions against an SPKI key — the same function the Host + uses, so Server and Host literally cannot disagree on what a valid assertion + is. + +Server-issued challenges (registration, sign-in) reuse `HostChallengeIssuer` +— it is a generic single-use/TTL challenge store despite the name. + +This also makes the server fully testable without a browser: the +`SimAuthenticator` harness in `server-lib-common` produces real assertions, +so `node --test` can drive setup → pairing → connect end to end via +`app.request()` and a pair of in-process WebSockets. + +--- + +# HTTP API + +| Route | Auth | Does | +| -------------------------------- | -------------- | ------------------------------------------------- | +| `GET /*` | — | Serves the Pocket web app (static build of `lib`'s pocket entry) | +| `POST /api/setup/begin` | setup password | `{ challenge }` for registration; rejects if the account already has a passkey (add more by re-presenting the password) | +| `POST /api/setup/finish` | setup password | `{ credentialId, publicKey, clientDataJSON }` → creates/updates `account.json` | +| `POST /api/signin/begin` | — | `{ challenge }` for sign-in | +| `POST /api/signin/finish` | — | full assertion → verified → `{ sessionToken }` (random, in-memory, hours-scale TTL) | +| `POST /api/host/enroll` | setup password | `{ label }` → `{ hostId, hostToken, origin, rpId }`; appends to `hosts.json` | +| `GET /api/hosts` | session token | Enrolled hosts + whether each is currently connected | +| `GET /ws/host` | host token | The Host's relay socket | +| `GET /ws/client` | session token | A Client's relay socket | + +The setup password is compared in constant time with a small fixed delay on +failure; that is the extent of POC hardening. + +--- + +# Relay + +The server routes JSON envelopes between client sockets and host sockets +(`@hono/node-ws`). Before a session is authorized it only forwards an +allowlist of handshake types; after authorization it is a dumb pipe. + +## Pairing (phone ↔ laptop, first time) + +``` +phone server host (laptop) + |-- signin (passkey) -------->| | + | generate device key | | + |-- pair-request ------------>|-- pair-request ------------->| approval modal + | | | user clicks Approve + |<-- pair-result -------------|<-- pair-result --------------| ACL record saved +``` + +The `pair-request` carries the `PairingRequest` shape from `server-lib-common` +(`accountId`, `passkeyCredentialId`, `passkeyPublicKeyHash`, +`devicePublicKey`, `requestedLabel`). The server checks the session's +credential matches, then relays; the Host runs `PairingCeremony` and only +local approval writes the ACL. + +## Connect (every session) + +``` +phone server host + |-- connect-request {hostId}->|-- challenge-request -------->| + |<-- challenge ---------------|<-- challenge (HostChallengeIssuer) + | ONE biometric prompt: | | + | WebAuthn get({challenge}) | | + | + device-key signature | | + |-- ConnectionRequest ------->| server verifies the | + | | assertion itself, then | + | |-- ConnectionRequest -------->| authorizeConnection() + |<-- session-established -----|<-- decision -----------------| (final authority) + |============ opaque remote-api relay from here ============>| +``` + +One host challenge feeds both signatures, so the user gets one Face ID prompt +per connection. The server verifies the assertion against the stored passkey +(its half of "fresh user presence is validated by the Server and the Host") +and drops the request on failure; the Host's `authorizeConnection` remains the +final authority regardless of what the server claims to have checked. + +## After authorization: remote-api v1, terminal-only + +Exactly the v1 scope of [remote-api.md](./remote-api.md) minus browser +surfaces: `hello`, `directory.watch` (snapshot-only), one `surface.attach` +(attach-is-the-resize), `terminal.data`/`semantic`/`resize`/`closed` out, +`terminal.write`/`terminal.resize` in. + +--- + +# Host side (`lib` + `standalone`) + +A `remote-host` module in `lib`, active in standalone: + +* **Enrollment** (settings UI, once): server URL + setup password → + `POST /api/host/enroll` → persist `{ serverUrl, hostId, hostToken, origin, + rpId }` via the platform adapter; open and maintain `GET /ws/host`. +* **Security**: `HostAcl` (persisted with `saveState`/`getState` as + `records()`/`fromRecords`), `HostChallengeIssuer`, `PairingCeremony`, and + `authorizeConnection` — all straight from `server-lib-common`, running in + the webview. +* **Pairing approval modal**: shows the requested label + account; Approve / + Deny. (Same modal pattern as KillConfirm.) +* **Terminal bridge**: `directory.watch` snapshots come from the existing + terminal registry/state store (title, activity, cwd, exitCode, ringing, + hasTODO — all already tracked); `surface.attach` resizes the PTY through + the existing resize path and subscribes to its data stream; + `terminal.write` feeds the existing input path. +* **Tethering**: while a remote session holds size authority, the local pane + greys out to "tethering to \"; local interaction reclaims it. + +# Pocket side (phone) + +Served by the server, built from `lib`: + +* Sign-in with passkey; session token in memory. +* Device key: `generateDeviceKeyPair()` persisted as non-extractable + CryptoKeys in IndexedDB (the tiny IndexedDB wrapper lives in `lib` — it is + DOM-dependent, so not in `server-lib-common`). +* First run against a host: pairing flow, then connect. After that: connect + straight away. +* Picker renders `directory.snapshot`; tapping a pane attaches with the + phone's cols/rows and reuses the existing mobile terminal UI (xterm). + +--- + +# Build order — five slices, each testable + +1. **Accounts & passkeys.** Setup/sign-in endpoints + `account.json` + + static-serving stub. Tests: `node --test` with `SimAuthenticator` against + `app.request()` — register, sign in, wrong password, replayed challenge, + wrong origin. Manual: create a real passkey at `localhost:3000`. +2. **Relay & host enrollment.** `hosts.json`, host/client sockets, envelope + routing, presence in `GET /api/hosts`. Tests: two in-process WebSockets + echo through the relay; token/session rejection. +3. **Security handshake over the relay.** A headless fake host (a Node script + wiring `server-lib-common` exactly as the harness's `SimHost` does) + + `SimAuthenticator` client: full pairing ceremony and connect through the + real server, plus the deny cases (unpaired device, revoked record, replayed + challenge). Still no browser anywhere. +4. **Standalone host module.** Enrollment settings, approval modal, ACL + persistence, host socket. Dogfood: pair a second browser profile (or the + phone) against your real standalone app and watch the modal + ACL. +5. **Terminal bridge + Pocket terminal view.** Directory, attach-is-the-resize, + write/resize, tether display, mobile terminal UI hookup. Dogfood — the + actual goal: pick up a running session from your laptop on your phone. + +Slices 1–3 are pure Node with full automated coverage; browsers only enter at +slice 4. After slice 5 the POC is in daily-use territory, and everything +after that (browser surfaces, in-flight replay, thumbnails, WebRTC) is +already staged in remote-api.md as additive follow-ups. From a35b5183b60e581ab8c768279e3b09a7251c7a37 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 13:25:18 -0700 Subject: [PATCH 06/52] feat(server-lib-common): shared wire contract for the selfhost POC HTTP routes/payloads, relay frames (client/server/host), and the terminal-only remote-api v1 messages, so server, host module, and Pocket UI build against one contract. Co-Authored-By: Claude Fable 5 --- server-lib-common/src/index.ts | 1 + server-lib-common/src/remote/wire.ts | 226 +++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 server-lib-common/src/remote/wire.ts diff --git a/server-lib-common/src/index.ts b/server-lib-common/src/index.ts index af9cead5..9cbc47b5 100644 --- a/server-lib-common/src/index.ts +++ b/server-lib-common/src/index.ts @@ -12,6 +12,7 @@ * connection authorization. */ +export * from './remote/wire.js'; export * from './security/webcrypto.js'; export * from './security/bytes.js'; export * from './security/ecdsa.js'; diff --git a/server-lib-common/src/remote/wire.ts b/server-lib-common/src/remote/wire.ts new file mode 100644 index 00000000..ccf1687b --- /dev/null +++ b/server-lib-common/src/remote/wire.ts @@ -0,0 +1,226 @@ +/** + * The wire contract for the selfhost POC (docs/specs/server.md): HTTP routes + * and payloads, relay frames, and the terminal-only remote-api v1 messages. + * Shared by `server`, the Host module in `lib`, and the Pocket UI so the + * three sides cannot drift — the same pattern as HELLO_ROUTE. + */ + +import type { HostAclRecord } from '../security/acl.js'; +import type { ConnectionFailure, ConnectionRequest } from '../security/connection.js'; +import type { PairingRequest } from '../security/pairing.js'; +import type { PasskeyAssertion } from '../security/passkey.js'; + +// --------------------------------------------------------------------------- +// HTTP API (see server.md "HTTP API") + +export const API_ROUTES = { + setupBegin: '/api/setup/begin', + setupFinish: '/api/setup/finish', + signinBegin: '/api/signin/begin', + signinFinish: '/api/signin/finish', + hostEnroll: '/api/host/enroll', + hosts: '/api/hosts', +} as const; + +export const WS_ROUTES = { + host: '/ws/host', + client: '/ws/client', +} as const; + +/** WS auth rides a query parameter (browsers cannot set WS headers). */ +export const WS_TOKEN_PARAM = 'token'; + +/** The selfhost mode has exactly one account. */ +export const SELFHOST_ACCOUNT_ID = 'owner'; + +export interface SetupBeginRequest { + password: string; +} +export interface SetupBeginResponse { + /** Base64url challenge for `navigator.credentials.create()`. */ + challenge: string; + rpId: string; + accountId: string; +} + +export interface SetupFinishRequest { + password: string; + /** Base64url credential id (`PublicKeyCredential.id`). */ + credentialId: string; + /** Base64url SPKI from `response.getPublicKey()`. */ + publicKey: string; + /** Base64url `response.clientDataJSON` (type `webauthn.create`). */ + clientDataJSON: string; + label: string; +} +export interface SetupFinishResponse { + accountId: string; + credentialId: string; +} + +export interface SigninBeginResponse { + /** Base64url challenge for `navigator.credentials.get()`. */ + challenge: string; + rpId: string; +} + +export interface SigninFinishRequest { + assertion: PasskeyAssertion; +} +export interface SigninFinishResponse { + /** Bearer token for `/api/hosts` and the `token` param of /ws/client. */ + sessionToken: string; + accountId: string; + expiresAt: number; +} + +export interface HostEnrollRequest { + password: string; + label: string; +} +export interface HostEnrollResponse { + hostId: string; + /** Bearer credential for the `token` param of /ws/host. */ + hostToken: string; + /** What the Host must enforce as its ConnectionPolicy. */ + origin: string; + rpId: string; +} + +export interface HostsResponse { + hosts: Array<{ hostId: string; label: string; online: boolean }>; +} + +// --------------------------------------------------------------------------- +// Relay frames (see server.md "Relay"). One JSON frame per WS message. +// `clientId` is assigned by the server per client socket; the client itself +// never sees or sends it. + +/** Client → server. `msg` is only forwarded once the session is authorized. */ +export type ClientFrame = + | { t: 'pair'; hostId: string; request: PairingRequest } + | { t: 'connect'; hostId: string } + | { t: 'connect2'; hostId: string; request: ConnectionRequest } + | { t: 'msg'; data: unknown }; + +/** Server → client. */ +export type ServerToClientFrame = + | { t: 'pair-result'; approved: boolean; record?: HostAclRecord; error?: string } + | { t: 'challenge'; hostId: string; challenge: string; expiresAt: number } + | { t: 'decision'; allowed: boolean; failures?: readonly ConnectionFailure[] } + | { t: 'msg'; data: unknown } + | { t: 'host-gone' } + | { t: 'error'; error: string }; + +/** Server → host. */ +export type ServerToHostFrame = + | { t: 'pair'; clientId: string; request: PairingRequest } + | { t: 'connect'; clientId: string } + | { t: 'connect2'; clientId: string; request: ConnectionRequest } + | { t: 'msg'; clientId: string; data: unknown } + | { t: 'client-gone'; clientId: string }; + +/** Host → server. */ +export type HostFrame = + | { t: 'pair-result'; clientId: string; approved: boolean; record?: HostAclRecord; error?: string } + | { t: 'challenge'; clientId: string; challenge: string; expiresAt: number } + | { t: 'decision'; clientId: string; allowed: boolean; failures?: readonly ConnectionFailure[] } + | { t: 'msg'; clientId: string; data: unknown }; + +// --------------------------------------------------------------------------- +// Remote-api v1, terminal-only (see remote-api.md "v1 scope" and server.md). +// These ride inside `msg` frames once a session is authorized. + +export interface RemoteRequest { + requestId: string; + method: string; + params?: unknown; +} +export interface RemoteResponse { + requestId: string; + ok: boolean; + result?: unknown; + error?: string; +} +export interface RemoteEventMsg { + subId: string; + event: string; + data: unknown; +} + +export const REMOTE_METHODS = { + hello: 'hello', + directoryWatch: 'directory.watch', + surfaceAttach: 'surface.attach', + surfaceDetach: 'surface.detach', + terminalWrite: 'terminal.write', + terminalResize: 'terminal.resize', +} as const; + +export const REMOTE_EVENTS = { + directorySnapshot: 'directory.snapshot', + terminalData: 'terminal.data', + terminalResize: 'terminal.resize', + terminalSemantic: 'terminal.semantic', + terminalClosed: 'terminal.closed', +} as const; + +export interface HelloParams { + protocolVersion: 1; + viewer: 'phone' | 'vr' | 'desktop'; +} +export interface HelloResult { + protocolVersion: 1; + hostId: string; + grants: { input: boolean; layout: boolean }; +} + +/** Terminal-only for the POC: no browser entries, so no `url`. */ +export interface DirectoryEntry { + paneRef: string; + surfaceId: string; + type: 'terminal'; + title: string; + focused: boolean; + activity?: 'unknown' | 'prompt' | 'editing' | 'running' | 'finished'; + exitCode?: number; + cwd?: string; + ringing: boolean; + hasTODO: boolean; +} +export interface DirectorySnapshot { + entries: DirectoryEntry[]; +} + +export interface AttachParams { + surfaceId: string; + cols: number; + rows: number; +} +export interface TerminalAttachResult { + cols: number; + rows: number; +} + +export interface TerminalDataEvent { + /** Base64url PTY output bytes. */ + bytes: string; +} +export interface TerminalResizeEvent { + cols: number; + rows: number; +} +export interface TerminalClosedEvent { + exitCode?: number; +} + +export interface TerminalWriteParams { + surfaceId: string; + /** Base64url input bytes. */ + bytes: string; +} +export interface TerminalResizeParams { + surfaceId: string; + cols: number; + rows: number; +} From a7d835c3bb45da2be39f2f0df1b9d39dd72be089 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 13:33:07 -0700 Subject: [PATCH 07/52] feat(server): selfhost accounts and passkey auth (slice 1) Implements slice 1 of the selfhost server POC (docs/specs/server.md): accounts and passkeys, pure Node with full automated coverage. - createApp(config) factory ({ setupPassword, origin, stateDir, now? }), replacing the module-level app singleton; index.ts builds config from env (DORMOUSE_SETUP_PASSWORD required, DORMOUSE_ORIGIN/STATE_DIR/PORT defaults) and derives rpId from the origin hostname. - state.ts: account.json persistence (atomic tmp+rename, mutation mutex, duplicate-credential guard). No hosts.json yet (slice 2). - Endpoints: /api/setup/begin+finish (constant-time password gate with a 250ms failure delay, clientDataJSON sanity checks, SPKI import check), /api/signin/begin+finish (single-use challenge consume then verifyPasskeyAssertion, in-memory 12h sessions). Setup and sign-in use separate HostChallengeIssuer instances so challenges cannot cross flows. - Exposed SessionStore.validate (raw token, for the slice-2 WS path) and a requireSession Bearer middleware. - 19 node --test cases via SimAuthenticator: register happy path (on-disk account.json), wrong password (begin/finish), challenge replay, wrong origin, wrong clientData type, duplicate credential, unimportable key, sign-in happy path, unknown credential, replay, tampered signature, foreign origin, expired session, requireSession gating. Co-Authored-By: Claude --- server/src/app.ts | 311 ++++++++++++++++++++++++++++++++++-- server/src/index.ts | 23 ++- server/src/state.ts | 107 +++++++++++++ server/test/app.test.mjs | 8 +- server/test/helpers.mjs | 84 ++++++++++ server/test/setup.test.mjs | 172 ++++++++++++++++++++ server/test/signin.test.mjs | 118 ++++++++++++++ 7 files changed, 808 insertions(+), 15 deletions(-) create mode 100644 server/src/state.ts create mode 100644 server/test/helpers.mjs create mode 100644 server/test/setup.test.mjs create mode 100644 server/test/signin.test.mjs diff --git a/server/src/app.ts b/server/src/app.ts index ae08ca1a..32f9c318 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,16 +1,307 @@ +/** + * The selfhost POC server (docs/specs/server.md), slice 1: accounts & passkeys. + * + * Built as a factory — `createApp(config)` — rather than a module-level + * singleton so tests can spin up an isolated server (its own state dir, its own + * in-memory challenge/session stores, its own injectable clock) per case, and + * so `index.ts` stays a thin env-to-config adapter. + * + * "WebAuthn without a WebAuthn library" (server.md): registration trusts the + * browser-provided SPKI public key and only sanity-checks `clientDataJSON`; + * assertions are verified by `verifyPasskeyAssertion` from `server-lib-common`, + * the exact same verifier the Host uses, so Server and Host cannot disagree on + * what a valid assertion is. Challenges are minted by `HostChallengeIssuer` + * (a generic single-use/TTL store despite the name). Setup and sign-in get + * SEPARATE issuers so a challenge minted for one flow can never be redeemed in + * the other. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + import { Hono } from 'hono'; -import { HELLO_ROUTE, helloResponse } from 'server-lib-common'; +import type { MiddlewareHandler } from 'hono'; +import { + API_ROUTES, + HELLO_ROUTE, + HostChallengeIssuer, + SELFHOST_ACCOUNT_ID, + fromBase64Url, + getWebCrypto, + helloResponse, + toBase64Url, + utf8Decode, + verifyPasskeyAssertion, +} from 'server-lib-common'; +import type { + PasskeyAssertion, + SetupBeginRequest, + SetupBeginResponse, + SetupFinishRequest, + SetupFinishResponse, + SigninBeginResponse, + SigninFinishRequest, + SigninFinishResponse, +} from 'server-lib-common'; + +import { AccountStore, DuplicateCredentialError } from './state.js'; + +/** Runtime configuration; see `index.ts` for how env maps onto this. */ +export interface AppConfig { + /** Gates account creation and passkey enrollment. */ + readonly setupPassword: string; + /** External origin, e.g. `https://dormouse.tailnet.ts.net`; source of `rpId`. */ + readonly origin: string; + /** Directory holding `account.json`. */ + readonly stateDir: string; + /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ + readonly now?: () => number; +} + +/** A live sign-in session held in memory (server.md: everything transient is in memory). */ +export interface Session { + readonly accountId: string; + readonly expiresAt: number; +} + +type AppEnv = { Variables: { session: Session } }; + +/** Sessions live 12 hours (server.md: "hours-scale TTL"). */ +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; +/** A small fixed delay on password failure — the extent of POC brute-force hardening. */ +const PASSWORD_FAILURE_DELAY_MS = 250; /** - * The Hono application. Built here — separate from the `serve()` entrypoint in - * `index.ts` — so tests can exercise routes via `app.request()` without binding - * a port. + * In-memory session store. Exposed on the created app so slice 2's WS path can + * validate a raw `token` query param, and the `requireSession` middleware can + * validate a `Bearer` header, against one shared source of truth. */ -export const app = new Hono(); +export class SessionStore { + readonly #sessions = new Map(); + readonly #now: () => number; + + constructor(now: () => number) { + this.#now = now; + } + + /** Mint a fresh session token (32 random bytes, base64url) for an account. */ + mint(accountId: string): { token: string; session: Session } { + const token = toBase64Url(randomBytes(32)); + const session: Session = { accountId, expiresAt: this.#now() + SESSION_TTL_MS }; + this.#sessions.set(token, session); + return { token, session }; + } + + /** Validate a raw token; returns the session or `null` if unknown/expired. */ + validate(token: string): Session | null { + const session = this.#sessions.get(token); + if (!session) return null; + if (this.#now() >= session.expiresAt) { + this.#sessions.delete(token); + return null; + } + return session; + } +} + +/** What {@link createApp} hands back: the Hono app plus its auth internals. */ +export interface CreatedApp { + readonly app: Hono; + readonly sessions: SessionStore; + /** Middleware for session-gated routes (slice 2's `/api/hosts`, etc.). */ + readonly requireSession: MiddlewareHandler; +} + +export function createApp(config: AppConfig): CreatedApp { + const now = config.now ?? (() => Date.now()); + const rpId = new URL(config.origin).hostname; + const accounts = new AccountStore(config.stateDir, now); + const sessions = new SessionStore(now); + // Separate issuers per flow: a setup challenge cannot be redeemed at sign-in. + const setupChallenges = new HostChallengeIssuer({ now }); + const signinChallenges = new HostChallengeIssuer({ now }); + + // Precompute a fixed-length digest of the expected password so the + // constant-time compare never has to branch on length (timingSafeEqual + // throws on unequal-length buffers). + const expectedPasswordHash = sha256(config.setupPassword); + const passwordOk = (provided: unknown): boolean => + typeof provided === 'string' && timingSafeEqual(sha256(provided), expectedPasswordHash); + + const app = new Hono(); + + // GET / — stub landing page; slice 5 replaces this with the Pocket web app. + app.get('/', (c) => c.text('Dormouse selfhost server')); + + // Shared greeting, kept from the skeleton so `lib` and `server` stay agreed. + app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); + + // --- Setup: password-gated passkey registration ------------------------- + + app.post(API_ROUTES.setupBegin, async (c) => { + const body = await readJson(c); + if (!body || !passwordOk(body.password)) { + await delay(PASSWORD_FAILURE_DELAY_MS); + return c.json({ error: 'invalid setup password' }, 401); + } + const { challenge } = setupChallenges.issue(); + const res: SetupBeginResponse = { challenge, rpId, accountId: SELFHOST_ACCOUNT_ID }; + return c.json(res); + }); + + app.post(API_ROUTES.setupFinish, async (c) => { + const body = await readJson(c); + if (!body || !passwordOk(body.password)) { + await delay(PASSWORD_FAILURE_DELAY_MS); + return c.json({ error: 'invalid setup password' }, 401); + } + + // Decode and sanity-check clientDataJSON — we do NOT parse attestation + // (attestation: 'none'); the browser already handed us the public key. + const clientData = decodeClientData(body.clientDataJSON); + if (!clientData) return c.json({ error: 'malformed clientDataJSON' }, 400); + if (clientData.type !== 'webauthn.create') { + return c.json({ error: 'clientData type must be webauthn.create' }, 400); + } + if (typeof clientData.challenge !== 'string' || !setupChallenges.consume(clientData.challenge)) { + return c.json({ error: 'unrecognized or expired challenge' }, 400); + } + if (clientData.origin !== config.origin) { + return c.json({ error: 'origin mismatch' }, 400); + } + + // Reject any key we could not verify assertions against later. + if (!(await importableSpkiP256(body.publicKey))) { + return c.json({ error: 'unimportable public key' }, 400); + } + + try { + await accounts.appendPasskey({ + credentialId: body.credentialId, + publicKey: body.publicKey, + label: typeof body.label === 'string' ? body.label : '', + }); + } catch (err) { + if (err instanceof DuplicateCredentialError) { + return c.json({ error: 'credential already registered' }, 409); + } + throw err; + } + + const res: SetupFinishResponse = { + accountId: SELFHOST_ACCOUNT_ID, + credentialId: body.credentialId, + }; + return c.json(res); + }); + + // --- Sign-in: passkey assertion → session token ------------------------- + + app.post(API_ROUTES.signinBegin, (c) => { + const { challenge } = signinChallenges.issue(); + const res: SigninBeginResponse = { challenge, rpId }; + return c.json(res); + }); + + app.post(API_ROUTES.signinFinish, async (c) => { + const body = await readJson(c); + const assertion = body?.assertion; + if (!assertion || typeof assertion.credentialId !== 'string') { + return c.json({ error: 'malformed assertion' }, 400); + } + + const stored = await accounts.findPasskey(assertion.credentialId); + if (!stored) return c.json({ error: 'unknown credential' }, 404); + + // Pull the challenge out of the assertion's own clientDataJSON so we can + // consume it (single-use) before verifying. Consuming first guarantees a + // captured assertion can never be replayed even if verification succeeds. + const clientData = decodeClientData(assertion.clientDataJSON); + if (!clientData || typeof clientData.challenge !== 'string') { + return c.json({ error: 'malformed clientDataJSON' }, 400); + } + const challenge = clientData.challenge; + if (!signinChallenges.consume(challenge)) { + return c.json({ error: 'unrecognized or expired challenge' }, 400); + } + + const result = await verifyPasskeyAssertion(assertion as PasskeyAssertion, stored.publicKey, { + challenge, + origin: config.origin, + rpId, + }); + if (!result.ok) { + return c.json({ error: `assertion rejected: ${result.reason}` }, 401); + } + + const { token, session } = sessions.mint(SELFHOST_ACCOUNT_ID); + const res: SigninFinishResponse = { + sessionToken: token, + accountId: session.accountId, + expiresAt: session.expiresAt, + }; + return c.json(res); + }); + + // Exported for slice 2: gate a route on a valid `Authorization: Bearer` token. + const requireSession: MiddlewareHandler = async (c, next) => { + const header = c.req.header('Authorization') ?? ''; + const match = /^Bearer (.+)$/.exec(header); + const session = match ? sessions.validate(match[1]!) : null; + if (!session) return c.json({ error: 'unauthorized' }, 401); + c.set('session', session); + await next(); + }; + + return { app, sessions, requireSession }; +} + +// --------------------------------------------------------------------------- +// Helpers + +/** SHA-256 of a UTF-8 string, as a fixed 32-byte buffer. */ +function sha256(text: string): Buffer { + return createHash('sha256').update(text, 'utf8').digest(); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readJson(c: { req: { json(): Promise } }): Promise { + try { + return (await c.req.json()) as T; + } catch { + return null; + } +} -app.get('/', (c) => c.text('Hello from Hono!')); +/** Decode base64url clientDataJSON to its parsed object, or `null` if malformed. */ +function decodeClientData( + clientDataJSON: unknown, +): { type?: unknown; challenge?: unknown; origin?: unknown } | null { + if (typeof clientDataJSON !== 'string') return null; + try { + const parsed: unknown = JSON.parse(utf8Decode(fromBase64Url(clientDataJSON))); + if (typeof parsed !== 'object' || parsed === null) return null; + return parsed; + } catch { + return null; + } +} -// The route path and response shape both come from `server-lib-common`, the -// package `lib` (frontend) and `server` (backend) share, so the two sides can -// never drift out of agreement. -app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); +/** True if `publicKey` (base64url SPKI) imports as an ECDSA P-256 verify key. */ +async function importableSpkiP256(publicKey: unknown): Promise { + if (typeof publicKey !== 'string') return false; + try { + await getWebCrypto().subtle.importKey( + 'spki', + fromBase64Url(publicKey), + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'], + ); + return true; + } catch { + return false; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 86d3d923..ef4c40b1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,9 +1,28 @@ +/** + * Process entrypoint: translate environment variables (docs/specs/server.md, + * "Configuration") into an {@link AppConfig} and bind a port. Kept separate from + * `app.ts` so the app itself stays testable without touching env or the network. + */ + import { serve } from '@hono/node-server'; -import { app } from './app.js'; +import { createApp } from './app.js'; const port = Number(process.env.PORT ?? 3000); +const setupPassword = process.env.DORMOUSE_SETUP_PASSWORD; +if (!setupPassword) { + console.error( + 'DORMOUSE_SETUP_PASSWORD is required — it gates account creation and host enrollment.', + ); + process.exit(1); +} + +const origin = process.env.DORMOUSE_ORIGIN ?? `http://localhost:${port}`; +const stateDir = process.env.DORMOUSE_STATE_DIR ?? './data'; + +const { app } = createApp({ setupPassword, origin, stateDir }); + serve({ fetch: app.fetch, port }, (info) => { - console.log(`server listening on http://localhost:${info.port}`); + console.log(`server listening on http://localhost:${info.port} (origin ${origin})`); }); diff --git a/server/src/state.ts b/server/src/state.ts new file mode 100644 index 00000000..a4e18162 --- /dev/null +++ b/server/src/state.ts @@ -0,0 +1,107 @@ +/** + * Persistent account state for the selfhost POC (docs/specs/server.md, "State + * files"). The entire durable footprint of slice 1 is one file: + * + * $DORMOUSE_STATE_DIR/account.json + * { accountId: "owner", passkeys: [{ credentialId, publicKey, label, createdAt }] } + * + * Deliberately not a database: one account, a handful of passkeys, hand-editable + * for revocation. Writes go through a temp-file-plus-rename so a crash mid-write + * can never leave a half-written (and therefore unparseable) account.json, and + * mutations are serialized through a promise chain so two concurrent + * registrations cannot clobber each other's append (read-modify-write races). + * + * `hosts.json` is intentionally absent — it arrives in slice 2. + */ + +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { SELFHOST_ACCOUNT_ID } from 'server-lib-common'; + +/** A registered passkey as stored on disk. `publicKey` is base64url SPKI. */ +export interface StoredPasskey { + readonly credentialId: string; + readonly publicKey: string; + readonly label: string; + readonly createdAt: number; +} + +/** The whole of `account.json`. */ +export interface Account { + readonly accountId: string; + readonly passkeys: StoredPasskey[]; +} + +/** Thrown by {@link AccountStore.appendPasskey} when the credential id is already registered. */ +export class DuplicateCredentialError extends Error { + constructor(credentialId: string) { + super(`credential ${credentialId} is already registered`); + this.name = 'DuplicateCredentialError'; + } +} + +export class AccountStore { + readonly #stateDir: string; + readonly #path: string; + readonly #now: () => number; + /** Serializes mutations so overlapping appends do not lose writes. */ + #tail: Promise = Promise.resolve(); + + constructor(stateDir: string, now: () => number = () => Date.now()) { + this.#stateDir = stateDir; + this.#path = join(stateDir, 'account.json'); + this.#now = now; + } + + /** Read `account.json`, or `null` if the account has not been created yet. */ + async load(): Promise { + let raw: string; + try { + raw = await readFile(this.#path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + return JSON.parse(raw) as Account; + } + + /** Look up a stored passkey by its base64url credential id. */ + async findPasskey(credentialId: string): Promise { + const account = await this.load(); + return account?.passkeys.find((p) => p.credentialId === credentialId); + } + + /** + * Append a passkey to the account, creating the account on first + * registration. Rejects with {@link DuplicateCredentialError} if the + * credential id already exists. Runs under the mutex. + */ + appendPasskey(passkey: Omit): Promise { + const run = async (): Promise => { + const account: Account = (await this.load()) ?? { + accountId: SELFHOST_ACCOUNT_ID, + passkeys: [], + }; + if (account.passkeys.some((p) => p.credentialId === passkey.credentialId)) { + throw new DuplicateCredentialError(passkey.credentialId); + } + account.passkeys.push({ ...passkey, createdAt: this.#now() }); + await this.#writeAtomic(account); + return account; + }; + // Chain onto the tail regardless of whether the previous op resolved or + // rejected, so one failed append does not wedge the queue. + const result = this.#tail.then(run, run); + this.#tail = result.catch(() => undefined); + return result; + } + + async #writeAtomic(account: Account): Promise { + await mkdir(this.#stateDir, { recursive: true }); + const tmp = `${this.#path}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(account, null, 2)}\n`, 'utf8'); + await rename(tmp, this.#path); + } +} diff --git a/server/test/app.test.mjs b/server/test/app.test.mjs index a88d344e..d10e6eca 100644 --- a/server/test/app.test.mjs +++ b/server/test/app.test.mjs @@ -3,15 +3,17 @@ import assert from 'node:assert/strict'; import { HELLO_ROUTE } from 'server-lib-common'; -import { app } from '../dist/app.js'; +import { freshApp } from './helpers.mjs'; -test('GET / returns the Hono hello-world', async () => { +test('GET / serves the stub landing page', async () => { + const { app } = await freshApp(); const res = await app.request('/'); assert.equal(res.status, 200); - assert.equal(await res.text(), 'Hello from Hono!'); + assert.equal(await res.text(), 'Dormouse selfhost server'); }); test(`GET ${HELLO_ROUTE} returns the shared greeting`, async () => { + const { app } = await freshApp(); const res = await app.request(HELLO_ROUTE); assert.equal(res.status, 200); assert.deepEqual(await res.json(), { message: 'Hello, world!' }); diff --git a/server/test/helpers.mjs b/server/test/helpers.mjs new file mode 100644 index 00000000..b5742a4d --- /dev/null +++ b/server/test/helpers.mjs @@ -0,0 +1,84 @@ +/** + * Shared scaffolding for the slice-1 server tests. Each test gets a fresh temp + * state dir and its own `createApp`, so cases never share account.json, + * challenge stores, or sessions. Real WebAuthn is produced by `SimAuthenticator` + * from the server-lib-common harness — no browser required. + */ + +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { API_ROUTES, toBase64Url, utf8Encode } from 'server-lib-common'; + +import { createApp } from '../dist/app.js'; +import { SimAuthenticator } from '../../server-lib-common/test/harness/actors.mjs'; + +export const ORIGIN = 'http://localhost:3000'; +export const RP_ID = 'localhost'; +export const PASSWORD = 'correct horse battery staple'; + +/** A manually-advanced clock for TTL/expiry tests. */ +export function makeClock(startMs = 1_700_000_000_000) { + let ms = startMs; + return { + now: () => ms, + advance(delta) { + ms += delta; + }, + }; +} + +export async function freshApp({ password = PASSWORD, origin = ORIGIN, now } = {}) { + const stateDir = await mkdtemp(join(tmpdir(), 'dormouse-server-')); + const created = createApp({ setupPassword: password, origin, stateDir, now }); + return { ...created, stateDir, origin, rpId: new URL(origin).hostname }; +} + +export function post(app, path, body) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); +} + +export async function readAccount(stateDir) { + return JSON.parse(await readFile(join(stateDir, 'account.json'), 'utf8')); +} + +export function newAuthenticator() { + return SimAuthenticator.create({ rpId: RP_ID }); +} + +/** Build registration clientDataJSON exactly as a browser would (webauthn.create). */ +export function registrationClientData({ challenge, origin = ORIGIN, type = 'webauthn.create' }) { + return toBase64Url(utf8Encode(JSON.stringify({ type, challenge, origin, crossOrigin: false }))); +} + +/** begin → finish registration for `authenticator`; returns the finish Response. */ +export async function register( + app, + authenticator, + { password = PASSWORD, origin = ORIGIN, label = 'Test Passkey' } = {}, +) { + const begin = await post(app, API_ROUTES.setupBegin, { password }); + const { challenge } = await begin.json(); + const clientDataJSON = registrationClientData({ challenge, origin }); + return post(app, API_ROUTES.setupFinish, { + password, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON, + label, + }); +} + +/** begin → assert → finish sign-in for `authenticator`; returns the finish Response. */ +export async function signin(app, authenticator, { origin = ORIGIN, rpId = RP_ID, tamper } = {}) { + const begin = await post(app, API_ROUTES.signinBegin, {}); + const { challenge } = await begin.json(); + const assertion = await authenticator.assert({ challenge, origin, rpId, tamper }); + const res = await post(app, API_ROUTES.signinFinish, { assertion }); + return { res, assertion }; +} diff --git a/server/test/setup.test.mjs b/server/test/setup.test.mjs new file mode 100644 index 00000000..7dab0484 --- /dev/null +++ b/server/test/setup.test.mjs @@ -0,0 +1,172 @@ +/** + * Slice-1 setup/registration coverage (docs/specs/server.md, "Accounts & + * passkeys"): the password gate, the clientDataJSON sanity checks, single-use + * challenges, and the account.json that lands on disk. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { API_ROUTES } from 'server-lib-common'; + +import { + ORIGIN, + PASSWORD, + RP_ID, + freshApp, + newAuthenticator, + post, + readAccount, + register, + registrationClientData, +} from './helpers.mjs'; + +test('register happy path writes account.json', async () => { + const { app, stateDir } = await freshApp(); + const authenticator = await newAuthenticator(); + + const res = await register(app, authenticator, { label: 'iPhone Safari' }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + accountId: 'owner', + credentialId: authenticator.credentialId, + }); + + const account = await readAccount(stateDir); + assert.equal(account.accountId, 'owner'); + assert.equal(account.passkeys.length, 1); + const [passkey] = account.passkeys; + assert.equal(passkey.credentialId, authenticator.credentialId); + assert.equal(passkey.publicKey, authenticator.publicKey); + assert.equal(passkey.label, 'iPhone Safari'); + assert.equal(typeof passkey.createdAt, 'number'); +}); + +test('a second passkey can be added by re-presenting the password', async () => { + const { app, stateDir } = await freshApp(); + assert.equal((await register(app, await newAuthenticator())).status, 200); + assert.equal((await register(app, await newAuthenticator())).status, 200); + const account = await readAccount(stateDir); + assert.equal(account.passkeys.length, 2); +}); + +test('setup/begin rejects a wrong password', async () => { + const { app } = await freshApp(); + const res = await post(app, API_ROUTES.setupBegin, { password: 'wrong' }); + assert.equal(res.status, 401); +}); + +test('setup/finish rejects a wrong password', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + // Get a valid challenge with the correct password, then finish with a wrong one. + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + const res = await post(app, API_ROUTES.setupFinish, { + password: 'wrong', + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge }), + label: 'x', + }); + assert.equal(res.status, 401); +}); + +test('setup/finish rejects a replayed challenge', async () => { + const { app } = await freshApp(); + const first = await newAuthenticator(); + + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + const clientDataJSON = registrationClientData({ challenge }); + + const ok = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: first.credentialId, + publicKey: first.publicKey, + clientDataJSON, + label: 'first', + }); + assert.equal(ok.status, 200); + + // Reuse the (now consumed) challenge with a different credential. + const second = await newAuthenticator(); + const replay = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: second.credentialId, + publicKey: second.publicKey, + clientDataJSON, + label: 'second', + }); + assert.equal(replay.status, 400); + assert.match((await replay.json()).error, /challenge/); +}); + +test('setup/finish rejects a mismatched origin in clientDataJSON', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge, origin: 'http://evil.example' }), + label: 'x', + }); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /origin/); +}); + +test('setup/finish rejects the wrong clientData type', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge, type: 'webauthn.get' }), + label: 'x', + }); + assert.equal(res.status, 400); +}); + +test('setup/finish rejects a duplicate credentialId', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const res = await register(app, authenticator); + assert.equal(res.status, 409); +}); + +test('setup/finish rejects an unimportable public key', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: 'bm90LWEta2V5', // "not-a-key", valid base64url but not SPKI + clientDataJSON: registrationClientData({ challenge }), + label: 'x', + }); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /public key/); +}); + +test('origin/rpId derive from config', async () => { + const { app } = await freshApp(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const body = await begin.json(); + assert.equal(body.rpId, RP_ID); + assert.equal(body.accountId, 'owner'); + assert.equal(typeof body.challenge, 'string'); + assert.equal(new URL(ORIGIN).hostname, RP_ID); +}); diff --git a/server/test/signin.test.mjs b/server/test/signin.test.mjs new file mode 100644 index 00000000..41535f0a --- /dev/null +++ b/server/test/signin.test.mjs @@ -0,0 +1,118 @@ +/** + * Slice-1 sign-in coverage (docs/specs/server.md): passkey assertions minted by + * the harness `SimAuthenticator`, single-use sign-in challenges, session minting + * and expiry, and the `requireSession` gate slice 2 will hang `/api/hosts` off. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Hono } from 'hono'; +import { API_ROUTES } from 'server-lib-common'; +import { SimAuthenticator } from '../../server-lib-common/test/harness/actors.mjs'; + +import { freshApp, makeClock, newAuthenticator, post, register, signin } from './helpers.mjs'; + +test('sign-in happy path mints a session the store accepts', async () => { + const { app, sessions } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res } = await signin(app, authenticator); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.accountId, 'owner'); + assert.equal(typeof body.sessionToken, 'string'); + assert.equal(typeof body.expiresAt, 'number'); + + const session = sessions.validate(body.sessionToken); + assert.ok(session); + assert.equal(session.accountId, 'owner'); + assert.equal(sessions.validate('not-a-real-token'), null); +}); + +test('sign-in rejects an unknown credential', async () => { + const { app } = await freshApp(); + assert.equal((await register(app, await newAuthenticator())).status, 200); + + // A different, never-registered authenticator. + const stranger = await newAuthenticator(); + const { res } = await signin(app, stranger); + assert.equal(res.status, 404); +}); + +test('sign-in rejects a replayed challenge/assertion', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res, assertion } = await signin(app, authenticator); + assert.equal(res.status, 200); + + // Same assertion again — its challenge was consumed on the first finish. + const replay = await post(app, API_ROUTES.signinFinish, { assertion }); + assert.equal(replay.status, 400); + assert.match((await replay.json()).error, /challenge/); +}); + +test('sign-in rejects a tampered signature', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + // Sign the assertion with a foreign key: valid shape, invalid signature. + const signWith = await SimAuthenticator.foreignSigningKey(); + const { res } = await signin(app, authenticator, { tamper: { signWith } }); + assert.equal(res.status, 401); + assert.match((await res.json()).error, /signature/); +}); + +test('sign-in rejects an assertion for a foreign origin', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res } = await signin(app, authenticator, { tamper: { origin: 'http://evil.example' } }); + assert.equal(res.status, 401); + assert.match((await res.json()).error, /origin/); +}); + +test('an expired session token no longer validates', async () => { + const clock = makeClock(); + const { app, sessions } = await freshApp({ now: clock.now }); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res } = await signin(app, authenticator); + const { sessionToken } = await res.json(); + assert.ok(sessions.validate(sessionToken)); + + clock.advance(12 * 60 * 60 * 1000 + 1); // past the 12h TTL + assert.equal(sessions.validate(sessionToken), null); +}); + +test('requireSession gates a route on the Bearer token', async () => { + const { app, sessions, requireSession } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + const { res } = await signin(app, authenticator); + const { sessionToken } = await res.json(); + + // Mount the exported middleware on a throwaway route to exercise it directly. + const probe = new Hono(); + probe.get('/probe', requireSession, (c) => c.json({ accountId: c.get('session').accountId })); + + const withToken = await probe.request('/probe', { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + assert.equal(withToken.status, 200); + assert.deepEqual(await withToken.json(), { accountId: 'owner' }); + + assert.equal((await probe.request('/probe')).status, 401); + assert.equal( + (await probe.request('/probe', { headers: { Authorization: 'Bearer nope' } })).status, + 401, + ); + // Sanity: the store still recognizes the live token. + assert.ok(sessions.validate(sessionToken)); +}); From 56f86ac1870db62eb38e9dcbe7a3952d01a90dbd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 14:02:36 -0700 Subject: [PATCH 08/52] feat(server): host enrollment and WebSocket relay (slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds slice 2 of the selfhost POC (docs/specs/server.md): hosts.json, host enrollment, the client/host WebSocket relay, and presence. - HostStore: atomic `hosts.json` of { hostId, hostToken, label, enrolledAt }, random base64url ids/tokens, append-under-mutex — mirrors AccountStore. - POST /api/host/enroll (password-gated) → { hostId, hostToken, origin, rpId }. - GET /api/hosts (session Bearer) → per-host { hostId, label, online }. - RelayHub + @hono/node-ws: one live socket per hostId (reconnect replaces), server-assigned secret clientId, handshake allowlist (pair/connect/connect2 up, pair-result/challenge/decision down), msg gated on an allowed host decision, client-gone/host-gone teardown. Bad WS tokens 401 at upgrade. - index.ts injects the WS handler after serve(). Tests: 37 server tests green (enroll, presence flip, token rejection, frame round-trips, msg gating both directions, disconnect fan-out, vanished-peer safety); server-lib-common still 111 green. Co-Authored-By: Claude --- pnpm-lock.yaml | 19 +++ server/package.json | 1 + server/src/app.ts | 124 ++++++++++++++++- server/src/index.ts | 7 +- server/src/relay.ts | 261 ++++++++++++++++++++++++++++++++++++ server/src/state.ts | 97 ++++++++++++-- server/test/helpers.mjs | 136 ++++++++++++++++++- server/test/hosts.test.mjs | 125 +++++++++++++++++ server/test/relay.test.mjs | 265 +++++++++++++++++++++++++++++++++++++ 9 files changed, 1015 insertions(+), 20 deletions(-) create mode 100644 server/src/relay.ts create mode 100644 server/test/hosts.test.mjs create mode 100644 server/test/relay.test.mjs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4137d2e8..19eb81a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@hono/node-server': specifier: ^1.13.0 version: 1.19.14(hono@4.12.27) + '@hono/node-ws': + specifier: ^1.3.1 + version: 1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27) hono: specifier: ^4.6.0 version: 4.12.27 @@ -757,6 +760,13 @@ packages: peerDependencies: hono: ^4 + '@hono/node-ws@1.3.1': + resolution: {integrity: sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.19.11 + hono: ^4.6.0 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -4616,6 +4626,15 @@ snapshots: dependencies: hono: 4.12.27 + '@hono/node-ws@1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + hono: 4.12.27 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': diff --git a/server/package.json b/server/package.json index f117f321..f9eaad1e 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@hono/node-server": "^1.13.0", + "@hono/node-ws": "^1.3.1", "hono": "^4.6.0", "server-lib-common": "workspace:*" }, diff --git a/server/src/app.ts b/server/src/app.ts index 32f9c318..2b47f79e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,12 +19,16 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { Hono } from 'hono'; -import type { MiddlewareHandler } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; +import { createNodeWebSocket } from '@hono/node-ws'; +import type { NodeWebSocket } from '@hono/node-ws'; import { API_ROUTES, HELLO_ROUTE, HostChallengeIssuer, SELFHOST_ACCOUNT_ID, + WS_ROUTES, + WS_TOKEN_PARAM, fromBase64Url, getWebCrypto, helloResponse, @@ -33,6 +37,9 @@ import { verifyPasskeyAssertion, } from 'server-lib-common'; import type { + HostEnrollRequest, + HostEnrollResponse, + HostsResponse, PasskeyAssertion, SetupBeginRequest, SetupBeginResponse, @@ -43,7 +50,10 @@ import type { SigninFinishResponse, } from 'server-lib-common'; -import { AccountStore, DuplicateCredentialError } from './state.js'; +import { RelayHub } from './relay.js'; +import type { ClientConn, HostConn } from './relay.js'; +import { AccountStore, DuplicateCredentialError, HostStore } from './state.js'; +import type { StoredHost } from './state.js'; /** Runtime configuration; see `index.ts` for how env maps onto this. */ export interface AppConfig { @@ -63,7 +73,7 @@ export interface Session { readonly expiresAt: number; } -type AppEnv = { Variables: { session: Session } }; +type AppEnv = { Variables: { session: Session; host: StoredHost } }; /** Sessions live 12 hours (server.md: "hours-scale TTL"). */ const SESSION_TTL_MS = 12 * 60 * 60 * 1000; @@ -107,15 +117,25 @@ export class SessionStore { export interface CreatedApp { readonly app: Hono; readonly sessions: SessionStore; - /** Middleware for session-gated routes (slice 2's `/api/hosts`, etc.). */ + /** Middleware for session-gated routes (`/api/hosts`, etc.). */ readonly requireSession: MiddlewareHandler; + /** The relay hub; exposed so `/api/hosts` presence and tests can read it. */ + readonly hub: RelayHub; + /** + * Bind the WS relay onto the http server returned by `serve()`. `index.ts` + * (and tests) MUST call this after `serve()`, per the `@hono/node-ws` pattern + * — the WebSocket routes are inert until the upgrade handler is injected. + */ + readonly injectWebSocket: NodeWebSocket['injectWebSocket']; } export function createApp(config: AppConfig): CreatedApp { const now = config.now ?? (() => Date.now()); const rpId = new URL(config.origin).hostname; const accounts = new AccountStore(config.stateDir, now); + const hostStore = new HostStore(config.stateDir, now); const sessions = new SessionStore(now); + const hub = new RelayHub(); // Separate issuers per flow: a setup challenge cannot be redeemed at sign-in. const setupChallenges = new HostChallengeIssuer({ now }); const signinChallenges = new HostChallengeIssuer({ now }); @@ -128,6 +148,9 @@ export function createApp(config: AppConfig): CreatedApp { typeof provided === 'string' && timingSafeEqual(sha256(provided), expectedPasswordHash); const app = new Hono(); + // The WS relay routes need the http server that `serve()` builds later, so the + // adapter is created here and `injectWebSocket` is handed back to the caller. + const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); // GET / — stub landing page; slice 5 replaces this with the Pocket web app. app.get('/', (c) => c.text('Dormouse selfhost server')); @@ -242,7 +265,27 @@ export function createApp(config: AppConfig): CreatedApp { return c.json(res); }); - // Exported for slice 2: gate a route on a valid `Authorization: Bearer` token. + // --- Host enrollment: password-gated, appends to hosts.json -------------- + + app.post(API_ROUTES.hostEnroll, async (c) => { + const body = await readJson(c); + if (!body || !passwordOk(body.password)) { + await delay(PASSWORD_FAILURE_DELAY_MS); + return c.json({ error: 'invalid setup password' }, 401); + } + const label = typeof body.label === 'string' ? body.label : ''; + const host = await hostStore.enroll(label); + // The Host enforces `origin`/`rpId` as its ConnectionPolicy (server.md). + const res: HostEnrollResponse = { + hostId: host.hostId, + hostToken: host.hostToken, + origin: config.origin, + rpId, + }; + return c.json(res); + }); + + // Gate a route on a valid `Authorization: Bearer` session token. const requireSession: MiddlewareHandler = async (c, next) => { const header = c.req.header('Authorization') ?? ''; const match = /^Bearer (.+)$/.exec(header); @@ -252,7 +295,76 @@ export function createApp(config: AppConfig): CreatedApp { await next(); }; - return { app, sessions, requireSession }; + // --- Host presence: enrolled hosts + whether each is connected ----------- + + app.get(API_ROUTES.hosts, requireSession, async (c) => { + const hosts = await hostStore.list(); + const res: HostsResponse = { + hosts: hosts.map((h) => ({ + hostId: h.hostId, + label: h.label, + online: hub.isHostOnline(h.hostId), + })), + }; + return c.json(res); + }); + + // --- The relay: one host socket per hostId, many client sockets ---------- + // Auth rides the `token` query param (browsers cannot set WS headers). A bad + // token short-circuits with 401 here, so `injectWebSocket` never upgrades it. + + app.get( + WS_ROUTES.host, + async (c, next) => { + const token = c.req.query(WS_TOKEN_PARAM); + const host = token ? await hostStore.findByToken(token) : undefined; + if (!host) return c.json({ error: 'unknown host token' }, 401); + c.set('host', host); + return next(); + }, + upgradeWebSocket((c) => { + // The auth middleware above ran on this same context and stashed `host`. + const host = (c as Context).get('host'); + let conn: HostConn | undefined; + return { + onOpen: (_evt, ws) => { + conn = hub.registerHost(host.hostId, ws); + }, + onMessage: (evt) => { + if (conn && typeof evt.data === 'string') hub.onHostFrame(conn, evt.data); + }, + onClose: () => { + if (conn) hub.unregisterHost(conn); + }, + }; + }), + ); + + app.get( + WS_ROUTES.client, + (c, next) => { + const token = c.req.query(WS_TOKEN_PARAM); + const session = token ? sessions.validate(token) : null; + if (!session) return c.json({ error: 'unauthorized' }, 401); + return next(); + }, + upgradeWebSocket(() => { + let conn: ClientConn | undefined; + return { + onOpen: (_evt, ws) => { + conn = hub.registerClient(ws); + }, + onMessage: (evt) => { + if (conn && typeof evt.data === 'string') hub.onClientFrame(conn, evt.data); + }, + onClose: () => { + if (conn) hub.unregisterClient(conn); + }, + }; + }), + ); + + return { app, sessions, requireSession, hub, injectWebSocket }; } // --------------------------------------------------------------------------- diff --git a/server/src/index.ts b/server/src/index.ts index ef4c40b1..a0fd864b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,8 +21,11 @@ if (!setupPassword) { const origin = process.env.DORMOUSE_ORIGIN ?? `http://localhost:${port}`; const stateDir = process.env.DORMOUSE_STATE_DIR ?? './data'; -const { app } = createApp({ setupPassword, origin, stateDir }); +const { app, injectWebSocket } = createApp({ setupPassword, origin, stateDir }); -serve({ fetch: app.fetch, port }, (info) => { +const server = serve({ fetch: app.fetch, port }, (info) => { console.log(`server listening on http://localhost:${info.port} (origin ${origin})`); }); + +// Bind the relay's WS upgrade handler onto the running server (@hono/node-ws). +injectWebSocket(server); diff --git a/server/src/relay.ts b/server/src/relay.ts new file mode 100644 index 00000000..db005e43 --- /dev/null +++ b/server/src/relay.ts @@ -0,0 +1,261 @@ +/** + * The relay hub (docs/specs/server.md, "Relay"): routes JSON envelopes between + * Client sockets and Host sockets. It is the coordinating Server's dumb pipe — + * before a session is authorized it forwards only the handshake allowlist + * (`pair`/`connect`/`connect2` up, `pair-result`/`challenge`/`decision` down); + * after authorization it forwards `msg` frames verbatim. + * + * State is deliberately tiny and in-memory (a server restart just means everyone + * reconnects) and the machine is kept small so slice 3 can layer connection + * *verification* on top without reshaping it: + * + * - one live socket per `hostId` (a reconnect replaces the old socket); + * - each client is bound to at most one host (`clientId → hostId`) and carries + * an `established` flag that gates `msg` in both directions; + * - the session becomes established purely on the Host's authority — when the + * Host sends `{ t: 'decision', allowed: true }` for that client. + * + * `clientId` is a server-assigned secret: it is stamped onto every host-bound + * frame so the Host can address replies, but is never sent to the client. + */ + +import { randomBytes } from 'node:crypto'; + +import { toBase64Url } from 'server-lib-common'; +import type { + ClientFrame, + HostFrame, + ServerToClientFrame, + ServerToHostFrame, +} from 'server-lib-common'; + +/** + * The slice of a WebSocket the hub actually uses. `WSContext` from + * `@hono/node-ws` satisfies it, but keeping the surface this small keeps the + * routing logic transport-agnostic and unit-testable. + */ +export interface RelaySocket { + send(data: string): void; + close(code?: number, reason?: string): void; +} + +/** A live Host socket. */ +export interface HostConn { + readonly hostId: string; + readonly socket: RelaySocket; +} + +/** A live Client socket and its (single) relationship to a Host. */ +export interface ClientConn { + readonly clientId: string; + readonly socket: RelaySocket; + /** The Host this client is currently talking to, or `null` if unbound. */ + hostId: string | null; + /** Whether `msg` frames may flow — set true only by an allowed Host decision. */ + established: boolean; +} + +export class RelayHub { + readonly #hosts = new Map(); + readonly #clients = new Map(); + + /** True while a socket for `hostId` is connected — drives `GET /api/hosts` presence. */ + isHostOnline(hostId: string): boolean { + return this.#hosts.has(hostId); + } + + // --- Host lifecycle ------------------------------------------------------- + + /** + * Register a freshly-opened Host socket. Only one socket may own a `hostId`, + * so an existing one is displaced and closed; the displaced socket's `close` + * event is ignored by {@link unregisterHost} because the map already points + * at the new connection (a generation guard). + */ + registerHost(hostId: string, socket: RelaySocket): HostConn { + const conn: HostConn = { hostId, socket }; + const existing = this.#hosts.get(hostId); + this.#hosts.set(hostId, conn); + if (existing) safeClose(existing.socket, 4000, 'replaced by a newer host connection'); + return conn; + } + + /** Handle one raw frame from a Host socket. Unknown/malformed frames are ignored. */ + onHostFrame(host: HostConn, raw: string): void { + const frame = parseFrame(raw); + if (!frame || typeof frame.t !== 'string' || typeof frame.clientId !== 'string') return; + const client = this.#clients.get(frame.clientId); + switch (frame.t) { + case 'pair-result': + if (client) { + this.#toClient(client, { + t: 'pair-result', + approved: frame.approved, + record: frame.record, + error: frame.error, + }); + } + return; + case 'challenge': + // The client's `challenge` frame carries the originating hostId (the + // host frame does not — the hub knows it from the socket). + if (client) { + this.#toClient(client, { + t: 'challenge', + hostId: host.hostId, + challenge: frame.challenge, + expiresAt: frame.expiresAt, + }); + } + return; + case 'decision': + // The Host is the final authority: an allowed decision is what + // establishes the (host, client) session and unblocks `msg`. + if (frame.allowed && client) { + client.hostId = host.hostId; + client.established = true; + } + if (client) { + this.#toClient(client, { + t: 'decision', + allowed: frame.allowed, + failures: frame.failures, + }); + } + return; + case 'msg': + // Blocked unless this exact host/client pair has an established session. + if (client && client.established && client.hostId === host.hostId) { + this.#toClient(client, { t: 'msg', data: frame.data }); + } + return; + default: + return; // unknown host frame type — ignore + } + } + + /** + * Tear down a Host socket. Guarded so a socket displaced by + * {@link registerHost} is a no-op. Its clients are told `host-gone` and their + * sessions cleared (no resume protocol — they reconnect). + */ + unregisterHost(host: HostConn): void { + if (this.#hosts.get(host.hostId) !== host) return; // already replaced + this.#hosts.delete(host.hostId); + for (const client of this.#clients.values()) { + if (client.hostId === host.hostId) { + this.#toClient(client, { t: 'host-gone' }); + client.hostId = null; + client.established = false; + } + } + } + + // --- Client lifecycle ----------------------------------------------------- + + /** Register a freshly-opened Client socket with a fresh secret `clientId`. */ + registerClient(socket: RelaySocket): ClientConn { + const clientId = toBase64Url(randomBytes(16)); + const conn: ClientConn = { clientId, socket, hostId: null, established: false }; + this.#clients.set(clientId, conn); + return conn; + } + + /** Handle one raw frame from a Client socket. Malformed/unknown frames get an `error`. */ + onClientFrame(client: ClientConn, raw: string): void { + const frame = parseFrame(raw); + if (!frame || typeof frame.t !== 'string') { + this.#toClient(client, { t: 'error', error: 'malformed frame' }); + return; + } + switch (frame.t) { + case 'pair': + case 'connect': + case 'connect2': { + if (typeof frame.hostId !== 'string') { + this.#toClient(client, { t: 'error', error: 'missing hostId' }); + return; + } + const host = this.#hosts.get(frame.hostId); + if (!host) { + this.#toClient(client, { t: 'error', error: `host ${frame.hostId} is offline` }); + return; + } + // Binding to a (new) host, or re-attempting `connect`, drops any prior + // established session — a client holds at most one at a time. + if (client.hostId !== frame.hostId || frame.t === 'connect') { + client.established = false; + } + client.hostId = frame.hostId; + if (frame.t === 'pair') { + this.#toHost(host, { t: 'pair', clientId: client.clientId, request: frame.request }); + } else if (frame.t === 'connect') { + this.#toHost(host, { t: 'connect', clientId: client.clientId }); + } else { + this.#toHost(host, { t: 'connect2', clientId: client.clientId, request: frame.request }); + } + return; + } + case 'msg': + // Blocked until the session is established; silently dropped otherwise. + if (client.established && client.hostId !== null) { + const host = this.#hosts.get(client.hostId); + if (host) this.#toHost(host, { t: 'msg', clientId: client.clientId, data: frame.data }); + } + return; + default: + this.#toClient(client, { t: 'error', error: 'unknown frame type' }); + return; + } + } + + /** Tear down a Client socket: tell its Host `client-gone`, then forget it. */ + unregisterClient(client: ClientConn): void { + this.#clients.delete(client.clientId); + if (client.hostId !== null) { + const host = this.#hosts.get(client.hostId); + if (host) this.#toHost(host, { t: 'client-gone', clientId: client.clientId }); + } + } + + // --- Sending -------------------------------------------------------------- + + #toClient(client: ClientConn, frame: ServerToClientFrame): void { + safeSend(client.socket, frame); + } + + #toHost(host: HostConn, frame: ServerToHostFrame): void { + safeSend(host.socket, frame); + } +} + +// --------------------------------------------------------------------------- +// Helpers + +/** Parse a raw WS text frame; `null` if it is not a JSON object. */ +function parseFrame(raw: string): (T & { t?: unknown; clientId?: unknown; hostId?: unknown }) | null { + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return null; + return parsed as T & { t?: unknown }; + } catch { + return null; + } +} + +/** Serialize and send, swallowing errors from a socket that is mid-close. */ +function safeSend(socket: RelaySocket, frame: unknown): void { + try { + socket.send(JSON.stringify(frame)); + } catch { + // The peer vanished between our map lookup and this send — nothing to do. + } +} + +function safeClose(socket: RelaySocket, code: number, reason: string): void { + try { + socket.close(code, reason); + } catch { + // Already closing/closed. + } +} diff --git a/server/src/state.ts b/server/src/state.ts index a4e18162..e4d95e84 100644 --- a/server/src/state.ts +++ b/server/src/state.ts @@ -1,24 +1,23 @@ /** - * Persistent account state for the selfhost POC (docs/specs/server.md, "State - * files"). The entire durable footprint of slice 1 is one file: + * Persistent state for the selfhost POC (docs/specs/server.md, "State files"): * * $DORMOUSE_STATE_DIR/account.json * { accountId: "owner", passkeys: [{ credentialId, publicKey, label, createdAt }] } + * $DORMOUSE_STATE_DIR/hosts.json + * [{ hostId, hostToken, label, enrolledAt }] * - * Deliberately not a database: one account, a handful of passkeys, hand-editable - * for revocation. Writes go through a temp-file-plus-rename so a crash mid-write - * can never leave a half-written (and therefore unparseable) account.json, and - * mutations are serialized through a promise chain so two concurrent - * registrations cannot clobber each other's append (read-modify-write races). - * - * `hosts.json` is intentionally absent — it arrives in slice 2. + * Deliberately not a database: one account, a handful of passkeys and hosts, + * hand-editable for revocation. Writes go through a temp-file-plus-rename so a + * crash mid-write can never leave a half-written (and therefore unparseable) + * file, and mutations are serialized through a promise chain so two concurrent + * appends cannot clobber each other (read-modify-write races). */ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { join } from 'node:path'; -import { SELFHOST_ACCOUNT_ID } from 'server-lib-common'; +import { SELFHOST_ACCOUNT_ID, toBase64Url } from 'server-lib-common'; /** A registered passkey as stored on disk. `publicKey` is base64url SPKI. */ export interface StoredPasskey { @@ -105,3 +104,79 @@ export class AccountStore { await rename(tmp, this.#path); } } + +/** An enrolled Host as stored in `hosts.json`. `hostToken` is the WS bearer secret. */ +export interface StoredHost { + readonly hostId: string; + readonly hostToken: string; + readonly label: string; + readonly enrolledAt: number; +} + +/** + * Persistent host enrollment (`hosts.json`). Mirrors {@link AccountStore}: an + * append-only JSON array, atomic writes, and a mutex so concurrent enrollments + * cannot lose a write. Revocation is deleting a line by hand (POC guardrail). + */ +export class HostStore { + readonly #stateDir: string; + readonly #path: string; + readonly #now: () => number; + /** Serializes mutations so overlapping enrollments do not lose writes. */ + #tail: Promise = Promise.resolve(); + + constructor(stateDir: string, now: () => number = () => Date.now()) { + this.#stateDir = stateDir; + this.#path = join(stateDir, 'hosts.json'); + this.#now = now; + } + + /** Read `hosts.json`, or `[]` if no host has been enrolled yet. */ + async list(): Promise { + let raw: string; + try { + raw = await readFile(this.#path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + return JSON.parse(raw) as StoredHost[]; + } + + /** Look up an enrolled host by its bearer token (the `/ws/host` credential). */ + async findByToken(hostToken: string): Promise { + const hosts = await this.list(); + return hosts.find((h) => h.hostToken === hostToken); + } + + /** + * Enroll a new host: mint a random `hostId` (16 bytes) and `hostToken` + * (32 bytes), both base64url, append them, and return the record. Runs under + * the mutex. + */ + enroll(label: string): Promise { + const run = async (): Promise => { + const hosts = await this.list(); + const host: StoredHost = { + hostId: toBase64Url(randomBytes(16)), + hostToken: toBase64Url(randomBytes(32)), + label, + enrolledAt: this.#now(), + }; + hosts.push(host); + await this.#writeAtomic(hosts); + return host; + }; + // Chain regardless of prior resolve/reject so one failure cannot wedge the queue. + const result = this.#tail.then(run, run); + this.#tail = result.catch(() => undefined); + return result; + } + + async #writeAtomic(hosts: StoredHost[]): Promise { + await mkdir(this.#stateDir, { recursive: true }); + const tmp = `${this.#path}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(hosts, null, 2)}\n`, 'utf8'); + await rename(tmp, this.#path); + } +} diff --git a/server/test/helpers.mjs b/server/test/helpers.mjs index b5742a4d..cfb066e5 100644 --- a/server/test/helpers.mjs +++ b/server/test/helpers.mjs @@ -9,7 +9,8 @@ import { mkdtemp, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { API_ROUTES, toBase64Url, utf8Encode } from 'server-lib-common'; +import { serve } from '@hono/node-server'; +import { API_ROUTES, WS_ROUTES, WS_TOKEN_PARAM, toBase64Url, utf8Encode } from 'server-lib-common'; import { createApp } from '../dist/app.js'; import { SimAuthenticator } from '../../server-lib-common/test/harness/actors.mjs'; @@ -82,3 +83,136 @@ export async function signin(app, authenticator, { origin = ORIGIN, rpId = RP_ID const res = await post(app, API_ROUTES.signinFinish, { assertion }); return { res, assertion }; } + +// --- Slice 2: live server + WebSocket relay scaffolding -------------------- + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Poll `fn` until it returns truthy, or throw after `timeout`ms. */ +export async function until(fn, { timeout = 1000, interval = 5 } = {}) { + const deadline = Date.now() + timeout; + for (;;) { + if (await fn()) return; + if (Date.now() > deadline) throw new Error('condition not met in time'); + await sleep(interval); + } +} + +/** + * Boot a real listening server for a `createApp` result (WS needs a socket, not + * `app.request`). Binds port 0 and reports the OS-assigned port; the returned + * `wsUrl` is ready for `/ws/host` / `/ws/client`. + */ +/** Every {@link wsConnect} socket, so a server teardown can force them shut. */ +const OPEN_SOCKETS = new Set(); + +export function startServer(created) { + return new Promise((resolve) => { + const server = serve({ fetch: created.app.fetch, port: 0 }, (info) => { + created.injectWebSocket(server); + resolve({ + server, + port: info.port, + wsUrl: `ws://localhost:${info.port}`, + // An http server waits on its live connections, and an *upgraded* WS + // socket is no longer one it tracks — so close the client ends we know + // about and resolve on the drain callback OR a short fallback, never + // hanging teardown. + close: () => + new Promise((res) => { + for (const ws of OPEN_SOCKETS) { + try { + ws.close(); + } catch { + /* already closing */ + } + } + let done = false; + const finish = () => { + if (!done) { + done = true; + res(); + } + }; + server.close(finish); + server.closeAllConnections?.(); + setTimeout(finish, 300).unref(); + }), + }); + }); + }); +} + +/** + * Open a WebSocket and wrap it in a tiny test harness: `ready` resolves on open + * (rejects on a failed upgrade), `take()` yields received frames in order with + * an internal cursor, and `quiet()` asserts no frame arrived in a window. + */ +export function wsConnect(url) { + const ws = new WebSocket(url); + OPEN_SOCKETS.add(ws); + ws.addEventListener('close', () => OPEN_SOCKETS.delete(ws)); + const messages = []; + let cursor = 0; + ws.addEventListener('message', (ev) => { + messages.push(JSON.parse(typeof ev.data === 'string' ? ev.data : '')); + }); + const ready = new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (ev) => reject(ev.error ?? new Error('ws error'))); + ws.addEventListener('close', (ev) => reject(new Error(`closed before open (${ev.code})`))); + }); + const closed = new Promise((resolve) => ws.addEventListener('close', (ev) => resolve(ev))); + return { + ws, + ready, + closed, + messages, + send: (frame) => ws.send(JSON.stringify(frame)), + close: () => ws.close(), + /** Next unconsumed frame, waiting up to `timeout`ms for it to arrive. */ + async take(timeout = 1000) { + await until(() => messages.length > cursor, { timeout }); + return messages[cursor++]; + }, + /** True if no new frame arrives within `ms` (i.e. the pipe stayed blocked). */ + async quiet(ms = 60) { + const before = messages.length; + await sleep(ms); + return messages.length === before; + }, + }; +} + +/** POST /api/host/enroll with the setup password; returns the JSON body. */ +export async function enrollHost(app, { label = 'Laptop', password = PASSWORD } = {}) { + const res = await post(app, API_ROUTES.hostEnroll, { password, label }); + return { res, body: await res.json() }; +} + +/** Register a fresh passkey and sign in; returns the live session token. */ +export async function ownerSession(app) { + const authenticator = await newAuthenticator(); + await register(app, authenticator); + const { res } = await signin(app, authenticator); + const { sessionToken } = await res.json(); + return { authenticator, sessionToken }; +} + +/** Enroll a host and open its `/ws/host` socket (awaiting the upgrade). */ +export async function connectHost(app, server, opts) { + const { body } = await enrollHost(app, opts); + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${body.hostToken}`); + await socket.ready; + return { host: body, socket }; +} + +/** Register+sign-in an owner and open a `/ws/client` socket (awaiting the upgrade). */ +export async function connectClient(app, server) { + const { sessionToken, authenticator } = await ownerSession(app); + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${sessionToken}`); + await socket.ready; + return { sessionToken, authenticator, socket }; +} diff --git a/server/test/hosts.test.mjs b/server/test/hosts.test.mjs new file mode 100644 index 00000000..112549cb --- /dev/null +++ b/server/test/hosts.test.mjs @@ -0,0 +1,125 @@ +/** + * Slice-2 host enrollment and presence (docs/specs/server.md, "Relay & host + * enrollment"): the password-gated `POST /api/host/enroll`, the session-gated + * `GET /api/hosts` presence flag, and WS token rejection on both relay routes. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { API_ROUTES, WS_ROUTES, WS_TOKEN_PARAM } from 'server-lib-common'; + +import { + RP_ID, + connectHost, + enrollHost, + freshApp, + ownerSession, + post, + startServer, + until, + wsConnect, +} from './helpers.mjs'; + +/** GET /api/hosts as the owner; returns the parsed body. */ +async function listHosts(app, sessionToken) { + const res = await app.request(API_ROUTES.hosts, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + return { res, body: await res.json() }; +} + +test('enroll happy path returns host credentials and policy', async () => { + const { app, origin } = await freshApp(); + const { res, body } = await enrollHost(app, { label: 'MacBook' }); + assert.equal(res.status, 200); + assert.equal(typeof body.hostId, 'string'); + assert.equal(typeof body.hostToken, 'string'); + assert.notEqual(body.hostId, body.hostToken); + assert.equal(body.origin, origin); + assert.equal(body.rpId, RP_ID); +}); + +test('enroll rejects a wrong password', async () => { + const { app } = await freshApp(); + const res = await post(app, API_ROUTES.hostEnroll, { password: 'wrong', label: 'x' }); + assert.equal(res.status, 401); +}); + +test('a second enrollment appends and gets distinct credentials', async () => { + const { app } = await freshApp(); + const { body: a } = await enrollHost(app); + const { body: b } = await enrollHost(app); + assert.notEqual(a.hostId, b.hostId); + assert.notEqual(a.hostToken, b.hostToken); + + const { sessionToken } = await ownerSession(app); + const { body } = await listHosts(app, sessionToken); + assert.equal(body.hosts.length, 2); +}); + +test('GET /api/hosts requires a session', async () => { + const { app } = await freshApp(); + assert.equal((await app.request(API_ROUTES.hosts)).status, 401); +}); + +test('GET /api/hosts online flag flips with the host socket', async () => { + const created = await freshApp(); + const { app } = created; + const server = await startServer(created); + try { + const { sessionToken } = await ownerSession(app); + + const enrolled = await enrollHost(app, { label: 'Laptop' }); + const hostId = enrolled.body.hostId; + + let listed = (await listHosts(app, sessionToken)).body.hosts; + assert.deepEqual(listed, [{ hostId, label: 'Laptop', online: false }]); + + const socket = wsConnect( + `${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${enrolled.body.hostToken}`, + ); + await socket.ready; + await until(async () => (await listHosts(app, sessionToken)).body.hosts[0].online === true); + + socket.close(); + await socket.closed; + await until(async () => (await listHosts(app, sessionToken)).body.hosts[0].online === false); + } finally { + await server.close(); + } +}); + +test('/ws/host rejects a bad token', async () => { + const created = await freshApp(); + const server = await startServer(created); + try { + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=bogus`); + await assert.rejects(socket.ready); + } finally { + await server.close(); + } +}); + +test('/ws/client rejects a bad token', async () => { + const created = await freshApp(); + const server = await startServer(created); + try { + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=bogus`); + await assert.rejects(socket.ready); + } finally { + await server.close(); + } +}); + +test('a host socket opens with a real enrollment token', async () => { + const created = await freshApp(); + const server = await startServer(created); + try { + const { socket } = await connectHost(created.app, server); + socket.close(); + await socket.closed; + } finally { + await server.close(); + } +}); diff --git a/server/test/relay.test.mjs b/server/test/relay.test.mjs new file mode 100644 index 00000000..bc5da394 --- /dev/null +++ b/server/test/relay.test.mjs @@ -0,0 +1,265 @@ +/** + * Slice-2 relay routing (docs/specs/server.md, "Relay"): two real in-process + * WebSockets echoing through the hub. Covers the handshake allowlist (`pair` / + * `connect` / `connect2` up, `pair-result` / `challenge` / `decision` down), the + * `msg` gate that only opens on an allowed Host decision, presence teardown + * (`client-gone` / `host-gone`), and the malformed/unknown-frame paths. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { WS_ROUTES, WS_TOKEN_PARAM } from 'server-lib-common'; + +import { connectClient, connectHost, freshApp, startServer, wsConnect } from './helpers.mjs'; + +/** A boot-a-real-server fixture; every test tears its server down in `finally`. */ +async function relay() { + const created = await freshApp(); + const server = await startServer(created); + return { app: created.app, server, close: () => server.close() }; +} + +const PAIRING_REQUEST = { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', +}; + +test('pair round-trips client→host with a stamped clientId, pair-result routes back', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.send({ t: 'pair', hostId: host.hostId, request: PAIRING_REQUEST }); + const forwarded = await hostWs.take(); + assert.equal(forwarded.t, 'pair'); + assert.equal(typeof forwarded.clientId, 'string'); + assert.deepEqual(forwarded.request, PAIRING_REQUEST); + + const record = { hostId: host.hostId, accountId: 'owner' }; + hostWs.send({ t: 'pair-result', clientId: forwarded.clientId, approved: true, record }); + const result = await clientWs.take(); + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, true); + assert.deepEqual(result.record, record); + assert.equal(result.clientId, undefined); // the clientId secret never leaks to the client + } finally { + await close(); + } +}); + +test('connect round-trips and challenge routes back with the originating hostId', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + assert.equal(connFrame.t, 'connect'); + assert.equal(typeof connFrame.clientId, 'string'); + + hostWs.send({ t: 'challenge', clientId: connFrame.clientId, challenge: 'chal-abc', expiresAt: 999 }); + const challenge = await clientWs.take(); + assert.deepEqual(challenge, { + t: 'challenge', + hostId: host.hostId, + challenge: 'chal-abc', + expiresAt: 999, + }); + } finally { + await close(); + } +}); + +test('msg is blocked until an allowed decision, then flows both ways', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + const clientId = connFrame.clientId; + + // Blocked before the decision. + clientWs.send({ t: 'msg', data: { attempted: true } }); + assert.ok(await hostWs.quiet(), 'host must not receive msg before the decision'); + + // The Host's allowed decision establishes the session. + hostWs.send({ t: 'decision', clientId, allowed: true }); + const decision = await clientWs.take(); + assert.deepEqual(decision, { t: 'decision', allowed: true }); + + // Client → host. + clientWs.send({ t: 'msg', data: { up: 1 } }); + const up = await hostWs.take(); + assert.equal(up.t, 'msg'); + assert.equal(up.clientId, clientId); + assert.deepEqual(up.data, { up: 1 }); + + // Host → client (clientId stripped). + hostWs.send({ t: 'msg', clientId, data: { down: 2 } }); + const down = await clientWs.take(); + assert.equal(down.t, 'msg'); + assert.deepEqual(down.data, { down: 2 }); + assert.equal(down.clientId, undefined); + } finally { + await close(); + } +}); + +test('msg stays blocked for a second, un-established client', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: host.hostId }); + const first = await hostWs.take(); + hostWs.send({ t: 'decision', clientId: first.clientId, allowed: true }); + await clientWs.take(); + + // A second client connects but is never approved. + const { socket: client2Ws } = await connectClient(app, server); + client2Ws.send({ t: 'connect', hostId: host.hostId }); + const second = await hostWs.take(); + + hostWs.send({ t: 'msg', clientId: second.clientId, data: { x: 1 } }); + assert.ok(await client2Ws.quiet(), 'un-established client must not receive host msg'); + client2Ws.send({ t: 'msg', data: { y: 1 } }); + assert.ok(await hostWs.quiet(), 'un-established client msg must not reach the host'); + } finally { + await close(); + } +}); + +test('connect to an unknown/offline host returns an error and nothing else', async () => { + const { app, server, close } = await relay(); + try { + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: 'no-such-host' }); + const err = await clientWs.take(); + assert.equal(err.t, 'error'); + assert.match(err.error, /offline/); + assert.ok(await clientWs.quiet(), 'no further frames for an offline connect'); + } finally { + await close(); + } +}); + +test('malformed JSON and unknown client frames get an error; host garbage is ignored', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.ws.send('this is not json{'); + assert.equal((await clientWs.take()).t, 'error'); + + clientWs.send({ t: 'nonsense-type' }); + assert.equal((await clientWs.take()).t, 'error'); + + // Garbage from the host is dropped without a reply or a crash — the relay + // still routes a following valid frame. + hostWs.ws.send('garbage{'); + hostWs.send({ t: 'unknown-host-frame', clientId: 'whatever' }); + assert.ok(await hostWs.quiet()); + + clientWs.send({ t: 'connect', hostId: host.hostId }); + assert.equal((await hostWs.take()).t, 'connect'); + } finally { + await close(); + } +}); + +test('client disconnect delivers client-gone to its host', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + + clientWs.close(); + await clientWs.closed; + + const gone = await hostWs.take(); + assert.deepEqual(gone, { t: 'client-gone', clientId: connFrame.clientId }); + } finally { + await close(); + } +}); + +test('host disconnect delivers host-gone to all its clients', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const clientA = await connectClient(app, server); + const clientB = await connectClient(app, server); + clientA.socket.send({ t: 'connect', hostId: host.hostId }); + await hostWs.take(); + clientB.socket.send({ t: 'connect', hostId: host.hostId }); + await hostWs.take(); + + hostWs.close(); + await hostWs.closed; + + assert.deepEqual(await clientA.socket.take(), { t: 'host-gone' }); + assert.deepEqual(await clientB.socket.take(), { t: 'host-gone' }); + } finally { + await close(); + } +}); + +test('a msg to a vanished client is dropped and the server keeps routing', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + hostWs.send({ t: 'decision', clientId: connFrame.clientId, allowed: true }); + await clientWs.take(); + + clientWs.close(); + await clientWs.closed; + await hostWs.take(); // client-gone + + // The counterpart is gone; this must not throw or crash the process. + hostWs.send({ t: 'msg', clientId: connFrame.clientId, data: { orphan: true } }); + + // Prove the relay is still alive: a fresh client still round-trips. + const client2 = await connectClient(app, server); + client2.socket.send({ t: 'connect', hostId: host.hostId }); + assert.equal((await hostWs.take()).t, 'connect'); + } finally { + await close(); + } +}); + +test('a new host socket replaces the old one for the same hostId', async () => { + const { app, server, close } = await relay(); + try { + const first = await connectHost(app, server, { label: 'Laptop' }); + // Re-open /ws/host with the SAME token → same hostId, displaces the first. + const second = wsConnect( + `${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${first.host.hostToken}`, + ); + await second.ready; + + // The displaced socket is closed by the hub. + await first.socket.closed; + + // The new socket serves the same hostId: a client connect reaches it. + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: first.host.hostId }); + assert.equal((await second.take()).t, 'connect'); + second.close(); + } finally { + await close(); + } +}); From bd83bca44ad6998b93cebc61f63eae4e3a6434cd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 14:20:36 -0700 Subject: [PATCH 09/52] feat(server): security handshake over the relay (slice 3) Layer server-side handshake verification on the transport-dumb RelayHub via a new handshake policy module the hub consults: - pair: verify the request is consistent with the authenticated session (owner account, a registered passkey credential, matching stored public-key hash) before relaying; reject locally otherwise, never forwarding. - connect2: verify accountId, that the asserted credential is registered and the presented publicKey matches the STORED key, that the assertion verifies against the stored key over request.challenge, and that the challenge equals the one the server relayed to this client (single-use). Reject before forwarding on any failure; the Host stays the final authority via its own decision. - Remember each relayed Host challenge (clientId -> challenge) for the freshness half of connect2 validation. - Fix the slice-2 stale-session gap: invalidate established sessions (host-gone + clear) on Host replacement, not only on disconnect. Add a reusable headless FakeHost harness (HostAcl/HostChallengeIssuer/ PairingCeremony/authorizeConnection + minimal remote-api hello) and a runnable scripts/fake-host.mjs for manual testing. E2E tests drive the full flow (real authenticator + device key as the phone, FakeHost as the laptop) through a real listening server, plus deny cases: unpaired device (Host denies), server-side rejections before forwarding (assertion/challenge mismatch, unknown credential, substituted publicKey, unregistered pair credential), replayed connect2, msg blocked after denial, and host-restart session invalidation. Co-Authored-By: Claude --- server/scripts/fake-host.mjs | 79 ++++++ server/src/app.ts | 15 +- server/src/handshake.ts | 163 +++++++++++++ server/src/relay.ts | 89 ++++++- server/test/handshake.test.mjs | 391 ++++++++++++++++++++++++++++++ server/test/harness/fake-host.mjs | 178 ++++++++++++++ server/test/relay.test.mjs | 27 ++- 7 files changed, 916 insertions(+), 26 deletions(-) create mode 100644 server/scripts/fake-host.mjs create mode 100644 server/src/handshake.ts create mode 100644 server/test/handshake.test.mjs create mode 100644 server/test/harness/fake-host.mjs diff --git a/server/scripts/fake-host.mjs b/server/scripts/fake-host.mjs new file mode 100644 index 00000000..7dc7400c --- /dev/null +++ b/server/scripts/fake-host.mjs @@ -0,0 +1,79 @@ +/** + * Manual smoke tool: enroll a Host against a running selfhost server and run one + * auto-approving `FakeHost`, logging every handshake event. This is the headless + * stand-in for the standalone Host (slice 4) — handy for driving a real Pocket + * page through pairing + connect without a laptop app. + * + * DORMOUSE_SETUP_PASSWORD=... node scripts/fake-host.mjs http://localhost:3000 + * + * The server URL (default http://localhost:3000) is argv[2]. The setup password + * comes from DORMOUSE_SETUP_PASSWORD (same secret that gates enrollment). Build + * first (`pnpm --filter server build`) so `server-lib-common` is compiled. + */ + +import { API_ROUTES } from 'server-lib-common'; + +import { FakeHost } from '../test/harness/fake-host.mjs'; + +const serverUrl = (process.argv[2] ?? 'http://localhost:3000').replace(/\/$/, ''); +const password = process.env.DORMOUSE_SETUP_PASSWORD; +if (!password) { + console.error('DORMOUSE_SETUP_PASSWORD is required (it gates host enrollment).'); + process.exit(1); +} + +const label = process.env.FAKE_HOST_LABEL ?? 'Fake Host (script)'; + +async function main() { + const res = await fetch(`${serverUrl}${API_ROUTES.hostEnroll}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password, label }), + }); + if (!res.ok) { + console.error(`enroll failed: ${res.status} ${await res.text()}`); + process.exit(1); + } + const host = await res.json(); + console.log(`enrolled host ${host.hostId} (origin ${host.origin}, rpId ${host.rpId})`); + + const fakeHost = new FakeHost({ + serverUrl, + hostToken: host.hostToken, + hostId: host.hostId, + origin: host.origin, + rpId: host.rpId, + autoApprove: true, + }); + + fakeHost.on('open', () => console.log('host socket open — waiting for clients')); + fakeHost.on('pair', ({ clientId, request }) => + console.log(`pair ← ${clientId} label=${request?.requestedLabel} (auto-approving)`), + ); + fakeHost.on('paired', ({ clientId }) => console.log(`paired ✓ ${clientId}`)); + fakeHost.on('connect', ({ clientId }) => console.log(`connect ← ${clientId} (issued challenge)`)); + fakeHost.on('decision', ({ clientId, allowed, failures }) => + console.log(`decision → ${clientId} allowed=${allowed}${allowed ? '' : ` ${failures?.join(',')}`}`), + ); + fakeHost.on('msg', ({ clientId, request, response }) => + console.log(`msg ${clientId} ${request.method} → ok=${response.ok}`), + ); + fakeHost.on('client-gone', ({ clientId }) => console.log(`client-gone ${clientId}`)); + fakeHost.on('close', (ev) => { + console.log(`host socket closed (${ev?.code ?? '?'}) — exiting`); + process.exit(0); + }); + + await fakeHost.ready; + + process.on('SIGINT', () => { + console.log('\nshutting down'); + fakeHost.close(); + process.exit(0); + }); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 2b47f79e..d16910c9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -50,6 +50,7 @@ import type { SigninFinishResponse, } from 'server-lib-common'; +import { Handshake } from './handshake.js'; import { RelayHub } from './relay.js'; import type { ClientConn, HostConn } from './relay.js'; import { AccountStore, DuplicateCredentialError, HostStore } from './state.js'; @@ -135,7 +136,9 @@ export function createApp(config: AppConfig): CreatedApp { const accounts = new AccountStore(config.stateDir, now); const hostStore = new HostStore(config.stateDir, now); const sessions = new SessionStore(now); - const hub = new RelayHub(); + // Server-side handshake policy layered on the transport-dumb hub (slice 3). + const handshake = new Handshake(accounts, { origin: config.origin, rpId, now }); + const hub = new RelayHub(handshake); // Separate issuers per flow: a setup challenge cannot be redeemed at sign-in. const setupChallenges = new HostChallengeIssuer({ now }); const signinChallenges = new HostChallengeIssuer({ now }); @@ -350,12 +353,20 @@ export function createApp(config: AppConfig): CreatedApp { }, upgradeWebSocket(() => { let conn: ClientConn | undefined; + // `onClientFrame` is async (pair/connect2 verification), so serialize + // frames from this socket through a promise chain — a client's frames + // must be processed in the order they arrived, not raced by the gate. + let chain: Promise = Promise.resolve(); return { onOpen: (_evt, ws) => { conn = hub.registerClient(ws); }, onMessage: (evt) => { - if (conn && typeof evt.data === 'string') hub.onClientFrame(conn, evt.data); + if (conn && typeof evt.data === 'string') { + const c = conn; + const data = evt.data; + chain = chain.then(() => hub.onClientFrame(c, data)).catch(() => undefined); + } }, onClose: () => { if (conn) hub.unregisterClient(conn); diff --git a/server/src/handshake.ts b/server/src/handshake.ts new file mode 100644 index 00000000..cb8e0ce6 --- /dev/null +++ b/server/src/handshake.ts @@ -0,0 +1,163 @@ +/** + * Server-side handshake verification (docs/specs/server.md "Relay"; + * docs/specs/remote-security-model.md). The {@link RelayHub} stays a + * transport-dumb pipe — this module is the policy it consults before forwarding + * the two security-critical Client frames: + * + * - `pair`: the pairing request must be consistent with the authenticated + * session — the owner account, a registered passkey credential, + * and the matching stored public-key hash. Otherwise the server + * refuses to relay it to the Host at all. + * - `connect2`: the WebAuthn assertion must verify against the STORED passkey + * public key (not the one the request carries), over the exact + * Host challenge the server just relayed to this client. This is + * the Server's half of "fresh user presence is validated by the + * Server and the Host". + * + * A rejection never reaches the Host, so a forged request cannot even burn a + * Host challenge. The relayed challenge is single-use on the server side too, so + * a replayed `connect2` is refused here before it is forwarded. The Host's + * `authorizeConnection` remains the final authority on everything the server + * cannot see (the ACL, the device key, the challenge it actually issued). + */ + +import { + SELFHOST_ACCOUNT_ID, + hashPasskeyPublicKey, + verifyPasskeyAssertion, +} from 'server-lib-common'; +import type { ConnectionFailure, ConnectionRequest, PairingRequest } from 'server-lib-common'; + +import type { AccountStore } from './state.js'; + +/** Result of {@link HandshakeGate.checkPair}. */ +export type PairCheck = { readonly ok: true } | { readonly ok: false; readonly error: string }; + +/** Result of {@link HandshakeGate.checkConnect2}. Failures reuse the Host's vocabulary. */ +export type Connect2Check = + | { readonly ok: true } + | { readonly ok: false; readonly failures: readonly ConnectionFailure[] }; + +/** + * The policy surface the {@link RelayHub} consults. Kept as an interface so the + * hub depends on the contract, not the concrete {@link Handshake}, and stays + * transport-dumb. + */ +export interface HandshakeGate { + /** Verify a `pair` request before relaying it to the Host. */ + checkPair(request: PairingRequest): Promise; + /** Remember the Host challenge the server just relayed to a client (freshness half). */ + observeChallenge(clientId: string, hostId: string, challenge: string, expiresAt: number): void; + /** Verify a `connect2` request before relaying it to the Host. */ + checkConnect2(clientId: string, request: ConnectionRequest): Promise; + /** Drop any remembered challenge for a client that disconnected. */ + forgetClient(clientId: string): void; +} + +export interface HandshakeConfig { + /** External origin the WebAuthn assertion must have been produced for. */ + readonly origin: string; + /** Relying-party id the assertion must be scoped to. */ + readonly rpId: string; + /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ + readonly now?: () => number; +} + +/** The last Host challenge the server relayed to a client. */ +interface RelayedChallenge { + readonly hostId: string; + readonly challenge: string; + readonly expiresAt: number; +} + +export class Handshake implements HandshakeGate { + readonly #accounts: AccountStore; + readonly #origin: string; + readonly #rpId: string; + readonly #now: () => number; + /** clientId → the last Host challenge relayed to it; consumed single-use. */ + readonly #relayed = new Map(); + + constructor(accounts: AccountStore, config: HandshakeConfig) { + this.#accounts = accounts; + this.#origin = config.origin; + this.#rpId = config.rpId; + this.#now = config.now ?? (() => Date.now()); + } + + async checkPair(request: PairingRequest): Promise { + if (!request || typeof request !== 'object') { + return { ok: false, error: 'malformed pairing request' }; + } + if (request.accountId !== SELFHOST_ACCOUNT_ID) { + return { ok: false, error: 'pairing request is not for this account' }; + } + if (typeof request.passkeyCredentialId !== 'string') { + return { ok: false, error: 'pairing request has no passkey credential' }; + } + const stored = await this.#accounts.findPasskey(request.passkeyCredentialId); + if (!stored) { + return { ok: false, error: 'passkey credential is not registered to this account' }; + } + const expectedHash = await hashPasskeyPublicKey(stored.publicKey); + if (request.passkeyPublicKeyHash !== expectedHash) { + return { ok: false, error: 'passkey public key hash does not match the registered key' }; + } + return { ok: true }; + } + + observeChallenge(clientId: string, hostId: string, challenge: string, expiresAt: number): void { + this.#relayed.set(clientId, { hostId, challenge, expiresAt }); + } + + forgetClient(clientId: string): void { + this.#relayed.delete(clientId); + } + + async checkConnect2(clientId: string, request: ConnectionRequest): Promise { + const failures: ConnectionFailure[] = []; + + // (d) Freshness half: the request must answer the exact Host challenge the + // server relayed to THIS client, unexpired. Consume it unconditionally + // (single-use) so a replayed connect2 is refused here before forwarding. + const relayed = this.#relayed.get(clientId); + this.#relayed.delete(clientId); + const challengeFresh = + relayed !== undefined && + typeof request?.challenge === 'string' && + relayed.challenge === request.challenge && + this.#now() < relayed.expiresAt; + if (!challengeFresh) failures.push('challenge-invalid'); + + // (a) Only the single owner account. + if (!request || request.accountId !== SELFHOST_ACCOUNT_ID) failures.push('account-mismatch'); + + // (b) The asserted credential must be a registered passkey, and the request's + // publicKey must equal the STORED key for it (plain string compare). + const assertion = request?.passkey?.assertion; + const credentialId = assertion?.credentialId; + const stored = + typeof credentialId === 'string' ? await this.#accounts.findPasskey(credentialId) : undefined; + if (!stored) { + failures.push('passkey-not-paired'); + } else if (request.passkey.publicKey !== stored.publicKey) { + failures.push('passkey-key-mismatch'); + } + + // (c) The assertion must verify against the STORED key over request.challenge. + // Verifying against the stored key — never against request.passkey.publicKey — + // is what makes a substituted publicKey useless to an attacker. + if (stored && assertion && typeof request.challenge === 'string') { + const result = await verifyPasskeyAssertion(assertion, stored.publicKey, { + challenge: request.challenge, + origin: this.#origin, + rpId: this.#rpId, + }); + if (!result.ok) failures.push('passkey-assertion-invalid'); + } else { + failures.push('passkey-assertion-invalid'); + } + + return failures.length === 0 ? { ok: true } : { ok: false, failures }; + } +} diff --git a/server/src/relay.ts b/server/src/relay.ts index db005e43..e708b9f9 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -17,6 +17,11 @@ * * `clientId` is a server-assigned secret: it is stamped onto every host-bound * frame so the Host can address replies, but is never sent to the client. + * + * Slice 3 layers *verification* on top without reshaping any of this: the hub + * consults an injected {@link HandshakeGate} before relaying the two + * security-critical Client frames (`pair`, `connect2`) and remembers each Host + * challenge it relays, but the routing and session model are untouched. */ import { randomBytes } from 'node:crypto'; @@ -29,6 +34,8 @@ import type { ServerToHostFrame, } from 'server-lib-common'; +import type { HandshakeGate } from './handshake.js'; + /** * The slice of a WebSocket the hub actually uses. `WSContext` from * `@hono/node-ws` satisfies it, but keeping the surface this small keeps the @@ -58,6 +65,11 @@ export interface ClientConn { export class RelayHub { readonly #hosts = new Map(); readonly #clients = new Map(); + readonly #gate: HandshakeGate; + + constructor(gate: HandshakeGate) { + this.#gate = gate; + } /** True while a socket for `hostId` is connected — drives `GET /api/hosts` presence. */ isHostOnline(hostId: string): boolean { @@ -71,12 +83,21 @@ export class RelayHub { * so an existing one is displaced and closed; the displaced socket's `close` * event is ignored by {@link unregisterHost} because the map already points * at the new connection (a generation guard). + * + * A replacement also invalidates every session established with the OLD Host + * process: the new process has a fresh ACL and no memory of them, so their + * in-flight `msg` frames must never be treated as authorized. Slice 2 did this + * only on disconnect; because the displaced socket's `close` is a no-op here, + * the invalidation has to happen at replacement time too. */ registerHost(hostId: string, socket: RelaySocket): HostConn { const conn: HostConn = { hostId, socket }; const existing = this.#hosts.get(hostId); this.#hosts.set(hostId, conn); - if (existing) safeClose(existing.socket, 4000, 'replaced by a newer host connection'); + if (existing) { + this.#dropClientsOf(hostId); + safeClose(existing.socket, 4000, 'replaced by a newer host connection'); + } return conn; } @@ -98,8 +119,16 @@ export class RelayHub { return; case 'challenge': // The client's `challenge` frame carries the originating hostId (the - // host frame does not — the hub knows it from the socket). + // host frame does not — the hub knows it from the socket). The server + // also remembers the challenge so it can do its half of `connect2` + // freshness validation before forwarding. if (client) { + this.#gate.observeChallenge( + client.clientId, + host.hostId, + frame.challenge, + frame.expiresAt, + ); this.#toClient(client, { t: 'challenge', hostId: host.hostId, @@ -142,8 +171,17 @@ export class RelayHub { unregisterHost(host: HostConn): void { if (this.#hosts.get(host.hostId) !== host) return; // already replaced this.#hosts.delete(host.hostId); + this.#dropClientsOf(host.hostId); + } + + /** + * Tell every client bound to `hostId` its Host is gone and clear its session, + * so no `msg` can flow to a Host that is no longer the one it handshook with. + * Used on both Host disconnect and Host replacement. + */ + #dropClientsOf(hostId: string): void { for (const client of this.#clients.values()) { - if (client.hostId === host.hostId) { + if (client.hostId === hostId) { this.#toClient(client, { t: 'host-gone' }); client.hostId = null; client.established = false; @@ -161,8 +199,13 @@ export class RelayHub { return conn; } - /** Handle one raw frame from a Client socket. Malformed/unknown frames get an `error`. */ - onClientFrame(client: ClientConn, raw: string): void { + /** + * Handle one raw frame from a Client socket. Malformed/unknown frames get an + * `error`. Async because `pair` and `connect2` consult the {@link HandshakeGate} + * (account lookups, crypto); the WS handler serializes calls per socket so + * frames from one client stay in order. + */ + async onClientFrame(client: ClientConn, raw: string): Promise { const frame = parseFrame(raw); if (!frame || typeof frame.t !== 'string') { this.#toClient(client, { t: 'error', error: 'malformed frame' }); @@ -176,8 +219,7 @@ export class RelayHub { this.#toClient(client, { t: 'error', error: 'missing hostId' }); return; } - const host = this.#hosts.get(frame.hostId); - if (!host) { + if (!this.#hosts.has(frame.hostId)) { this.#toClient(client, { t: 'error', error: `host ${frame.hostId} is offline` }); return; } @@ -187,11 +229,35 @@ export class RelayHub { client.established = false; } client.hostId = frame.hostId; + + if (frame.t === 'connect') { + const host = this.#hosts.get(frame.hostId); + if (host) this.#toHost(host, { t: 'connect', clientId: client.clientId }); + return; + } if (frame.t === 'pair') { - this.#toHost(host, { t: 'pair', clientId: client.clientId, request: frame.request }); - } else if (frame.t === 'connect') { - this.#toHost(host, { t: 'connect', clientId: client.clientId }); - } else { + // Only relay a pairing request the authenticated session could have + // made: the owner account, a registered credential, a matching key + // hash. A forged request is answered locally and never reaches the Host. + const check = await this.#gate.checkPair(frame.request); + if (!check.ok) { + this.#toClient(client, { t: 'pair-result', approved: false, error: check.error }); + return; + } + const host = this.#hosts.get(frame.hostId); + if (host) this.#toHost(host, { t: 'pair', clientId: client.clientId, request: frame.request }); + return; + } + // connect2: the server verifies the assertion (against the STORED key) + // and challenge freshness before forwarding. On failure the client gets + // a denial and the Host's challenge stays unburned. + const check = await this.#gate.checkConnect2(client.clientId, frame.request); + if (!check.ok) { + this.#toClient(client, { t: 'decision', allowed: false, failures: check.failures }); + return; + } + const host = this.#hosts.get(frame.hostId); + if (host) { this.#toHost(host, { t: 'connect2', clientId: client.clientId, request: frame.request }); } return; @@ -212,6 +278,7 @@ export class RelayHub { /** Tear down a Client socket: tell its Host `client-gone`, then forget it. */ unregisterClient(client: ClientConn): void { this.#clients.delete(client.clientId); + this.#gate.forgetClient(client.clientId); if (client.hostId !== null) { const host = this.#hosts.get(client.hostId); if (host) this.#toHost(host, { t: 'client-gone', clientId: client.clientId }); diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs new file mode 100644 index 00000000..46d12556 --- /dev/null +++ b/server/test/handshake.test.mjs @@ -0,0 +1,391 @@ +/** + * Slice-3 security handshake, end to end through a real listening server + * (docs/specs/server.md "Relay"; docs/specs/remote-security-model.md). The + * "phone" is a real WebAuthn authenticator (`SimAuthenticator`) plus a real + * device keypair (`generateDeviceKeyPair` / `signDeviceChallenge`); the "laptop" + * is the reusable `FakeHost` wiring the actual security primitives. Nothing is + * stubbed but the transport is loopback. + * + * The deny cases prove the two independent guarantees of the model: the server + * refuses to relay anything it cannot verify (a forged request never even burns + * a Host challenge), and the Host is the final authority for everything the + * server cannot see (a registered passkey on an unpaired device is denied by + * the Host after the server forwards it). + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + SELFHOST_ACCOUNT_ID, + WS_ROUTES, + WS_TOKEN_PARAM, + generateDeviceKeyPair, + hashPasskeyPublicKey, + signDeviceChallenge, + toBase64Url, +} from 'server-lib-common'; + +import { + ORIGIN, + RP_ID, + enrollHost, + freshApp, + newAuthenticator, + ownerSession, + sleep, + startServer, + wsConnect, +} from './helpers.mjs'; +import { FakeHost } from './harness/fake-host.mjs'; + +// --- Fixtures -------------------------------------------------------------- + +/** Boot a server + one enrolled `FakeHost`, ready to accept clients. */ +async function boot({ autoApprove = true } = {}) { + const created = await freshApp(); + const server = await startServer(created); + const { body: host } = await enrollHost(created.app, { label: 'Laptop' }); + const fakeHost = await startFakeHost(server, host, { autoApprove }); + const extraHosts = []; + const close = async () => { + fakeHost.close(); + for (const h of extraHosts) h.close(); + await server.close(); + }; + return { app: created.app, server, host, fakeHost, extraHosts, close }; +} + +/** Connect a `FakeHost` for an already-enrolled host record. */ +async function startFakeHost(server, host, { autoApprove = true } = {}) { + const fakeHost = new FakeHost({ + serverUrl: server.wsUrl, + hostToken: host.hostToken, + hostId: host.hostId, + origin: host.origin, + rpId: host.rpId, + autoApprove, + }); + await fakeHost.ready; + return fakeHost; +} + +/** + * A "phone": a registered passkey (the authenticator), a device keypair, and a + * live client socket authenticated with the session token from the same sign-in. + */ +async function phone(app, server) { + const { authenticator, sessionToken } = await ownerSession(app); + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${sessionToken}`); + await socket.ready; + const deviceKey = await generateDeviceKeyPair(); + return { authenticator, sessionToken, socket, deviceKey }; +} + +/** Collect the named FakeHost events into one array, in arrival order. */ +function collect(fakeHost, ...names) { + const events = []; + for (const name of names) fakeHost.on(name, (payload) => events.push({ name, ...payload })); + return events; +} + +// --- Request builders (with tamper knobs for the deny cases) --------------- + +async function buildPairingRequest(p, overrides = {}) { + return { + accountId: SELFHOST_ACCOUNT_ID, + passkeyCredentialId: p.authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(p.authenticator.publicKey), + devicePublicKey: p.deviceKey.devicePublicKey, + requestedLabel: 'iPhone Safari', + ...overrides, + }; +} + +async function buildConnectionRequest(p, challenge, hostId, tamper = {}) { + const authenticator = tamper.assertWith ?? p.authenticator; + const assertion = await authenticator.assert({ + challenge: tamper.assertChallenge ?? challenge, + origin: ORIGIN, + rpId: RP_ID, + tamper: tamper.assertion ?? {}, + }); + const deviceSignature = await signDeviceChallenge(p.deviceKey.privateKey, { + hostId: tamper.signForHostId ?? hostId, + challenge, + devicePublicKey: p.deviceKey.devicePublicKey, + }); + return { + accountId: tamper.accountId ?? SELFHOST_ACCOUNT_ID, + devicePublicKey: p.deviceKey.devicePublicKey, + challenge: tamper.requestChallenge ?? challenge, + deviceSignature, + passkey: { + publicKey: tamper.passkeyPublicKey ?? authenticator.publicKey, + assertion, + }, + }; +} + +// --- Flow helpers ---------------------------------------------------------- + +async function pair(p, hostId, overrides) { + p.socket.send({ t: 'pair', hostId, request: await buildPairingRequest(p, overrides) }); + return p.socket.take(); +} + +async function connect(p, hostId) { + p.socket.send({ t: 'connect', hostId }); + return p.socket.take(); // the relayed `challenge` frame +} + +async function connect2(p, hostId, challenge, tamper) { + const request = await buildConnectionRequest(p, challenge, hostId, tamper); + p.socket.send({ t: 'connect2', hostId, request }); + return { request, decision: await p.socket.take() }; +} + +function helloRequest(requestId = 'r1') { + return { requestId, method: 'hello', params: { protocolVersion: 1, viewer: 'phone' } }; +} + +/** A fresh, well-formed base64url value that is not equal to a real challenge. */ +function otherChallenge() { + return toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); +} + +// --- The full flow --------------------------------------------------------- + +test('pair (autoApprove) → approved with an ACL record for this phone', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + const result = await pair(p, host.hostId); + + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, true); + assert.equal(result.clientId, undefined, 'the clientId secret never leaks to the phone'); + assert.equal(result.record.hostId, host.hostId); + assert.equal(result.record.accountId, SELFHOST_ACCOUNT_ID); + assert.equal(result.record.passkeyCredentialId, p.authenticator.credentialId); + assert.equal(result.record.devicePublicKey, p.deviceKey.devicePublicKey); + assert.equal(result.record.label, 'iPhone Safari'); + // The Host actually wrote it. + assert.equal(fakeHost.acl.activeRecords().length, 1); + } finally { + await close(); + } +}); + +test('connect → challenge → connect2 allowed → hello round-trips, unknown method refused', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + + const challengeFrame = await connect(p, host.hostId); + assert.equal(challengeFrame.t, 'challenge'); + assert.equal(challengeFrame.hostId, host.hostId); + assert.equal(typeof challengeFrame.challenge, 'string'); + + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge); + assert.deepEqual(decision, { t: 'decision', allowed: true }); + + // hello over the now-opaque `msg` relay. + p.socket.send({ t: 'msg', data: helloRequest('r1') }); + const hello = await p.socket.take(); + assert.equal(hello.t, 'msg'); + assert.equal(hello.data.requestId, 'r1'); + assert.equal(hello.data.ok, true); + assert.equal(hello.data.result.protocolVersion, 1); + assert.equal(hello.data.result.hostId, host.hostId); + + // Unknown methods echo ok:false. + p.socket.send({ t: 'msg', data: { requestId: 'r2', method: 'frobnicate' } }); + const echoed = await p.socket.take(); + assert.equal(echoed.data.requestId, 'r2'); + assert.equal(echoed.data.ok, false); + assert.match(echoed.data.error, /unknown method/); + + assert.equal(fakeHost.established.size, 1); + } finally { + await close(); + } +}); + +// --- Host is the final authority ------------------------------------------- + +test('unpaired device: server forwards, Host denies (device-not-paired), msg stays blocked', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + // Deliberately skip pairing: the passkey is registered to the account, so + // every server-side check passes and the request is forwarded — but the + // Host has no ACL record for this device key. + const decisions = collect(fakeHost, 'decision'); + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge); + + assert.equal(decision.t, 'decision'); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('device-not-paired'), JSON.stringify(decision.failures)); + assert.equal(decisions.length, 1, 'the Host actually saw the connect2 (server forwarded it)'); + + // A denied decision never establishes the session: msg is dropped both ways. + const msgs = collect(fakeHost, 'msg'); + p.socket.send({ t: 'msg', data: helloRequest('blocked') }); + assert.ok(await p.socket.quiet(), 'no msg response after a denied decision'); + assert.equal(msgs.length, 0, 'the Host never receives msg for an unestablished client'); + } finally { + await close(); + } +}); + +// --- Server refuses to relay what it cannot verify ------------------------- + +test('server rejects a pair for a credential not on the account, without forwarding', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + const pairs = collect(fakeHost, 'pair'); + const stranger = await newAuthenticator(); // never registered + + const result = await pair(p, host.hostId, { + passkeyCredentialId: stranger.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(stranger.publicKey), + }); + + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, false); + assert.match(result.error, /not registered/); + assert.equal(pairs.length, 0, 'the Host never saw the forged pair request'); + } finally { + await close(); + } +}); + +test('server rejects connect2 when the assertion is bound to a different challenge', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertChallenge: otherChallenge(), // assertion signs a stale/other challenge + }); + + assert.equal(decision.allowed, false); + assert.ok( + decision.failures.includes('passkey-assertion-invalid'), + JSON.stringify(decision.failures), + ); + assert.equal(decisions.length, 0, 'rejected before forwarding — Host challenge stays unburned'); + } finally { + await close(); + } +}); + +test('server rejects connect2 for an unknown (unregistered) credential', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const stranger = await newAuthenticator(); // valid assertions, but not registered + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertWith: stranger, + }); + + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-not-paired'), JSON.stringify(decision.failures)); + assert.equal(decisions.length, 0, 'rejected before forwarding'); + } finally { + await close(); + } +}); + +test('server rejects connect2 when the passkey publicKey is substituted', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + // A compromised server could swap the presented key; verification against the + // STORED key must still catch it. + const substitute = await newAuthenticator(); + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + passkeyPublicKey: substitute.publicKey, + }); + + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-key-mismatch'), JSON.stringify(decision.failures)); + assert.equal(decisions.length, 0, 'rejected before forwarding'); + } finally { + await close(); + } +}); + +test('server rejects a replayed connect2 (same challenge twice) before forwarding', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const challengeFrame = await connect(p, host.hostId); + const { request, decision } = await connect2(p, host.hostId, challengeFrame.challenge); + assert.equal(decision.allowed, true); + assert.equal(decisions.length, 1); + + // Resend the exact same connect2. The server's relayed challenge is single-use. + p.socket.send({ t: 'connect2', hostId: host.hostId, request }); + const replay = await p.socket.take(); + assert.equal(replay.allowed, false); + assert.ok(replay.failures.includes('challenge-invalid'), JSON.stringify(replay.failures)); + assert.equal(decisions.length, 1, 'the Host never saw the replay'); + } finally { + await close(); + } +}); + +// --- Stale-session invalidation on Host restart/replacement ----------------- + +test('a Host restart invalidates an established session (host-gone, then msg blocked)', async () => { + const { app, server, host, fakeHost, extraHosts, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const challengeFrame = await connect(p, host.hostId); + assert.equal((await connect2(p, host.hostId, challengeFrame.challenge)).decision.allowed, true); + + // Prove the session works first. + p.socket.send({ t: 'msg', data: helloRequest('r1') }); + assert.equal((await p.socket.take()).data.ok, true); + + // The Host "restarts": a new socket for the same host displaces the old one. + // Its ACL is empty again, so the old established session must be invalidated. + const restarted = await startFakeHost(server, host); + extraHosts.push(restarted); + + const gone = await p.socket.take(); + assert.deepEqual(gone, { t: 'host-gone' }); + + const msgs = collect(restarted, 'msg'); + p.socket.send({ t: 'msg', data: helloRequest('r2') }); + assert.ok(await p.socket.quiet(), 'the old session no longer relays msg'); + assert.equal(msgs.length, 0, 'the restarted Host receives nothing from the stale client'); + } finally { + await close(); + } +}); + +// A short settle so no test leaves an in-flight frame racing teardown. +test.after?.(async () => { + await sleep(10); +}); diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs new file mode 100644 index 00000000..67bef3ba --- /dev/null +++ b/server/test/harness/fake-host.mjs @@ -0,0 +1,178 @@ +/** + * A headless Node Host (Dormouse Terminal) for exercising the relay end to end. + * + * It speaks the Host side of the wire contract (server-lib-common `wire.ts`) + * over a real `/ws/host` socket, wiring the exact security primitives the real + * standalone Host will use in slice 4 — `HostAcl`, `HostChallengeIssuer`, + * `PairingCeremony`, and `authorizeConnection`. Everything is in memory, so a + * fresh instance (reconnecting with the same token) models a Host restart: its + * ACL starts empty again. + * + * Constructor: `{ serverUrl, hostToken, hostId, origin, rpId, autoApprove }`. + * `serverUrl` may be `http(s)://…` or `ws(s)://…`. When `autoApprove` is true a + * `pair` is approved the moment it arrives; otherwise call `approve(clientId)` / + * `deny(clientId)` from the pairing-approval hook. Subscribe to events for logs + * and assertions: `open`, `close`, `pair`, `paired`, `denied`, `connect`, + * `decision`, `msg`, `client-gone`. + * + * Slice 5's smoke test and manual `scripts/fake-host.mjs` reuse this class. + */ + +import { EventEmitter } from 'node:events'; + +import { + HostAcl, + HostChallengeIssuer, + PairingCeremony, + WS_ROUTES, + WS_TOKEN_PARAM, + authorizeConnection, +} from 'server-lib-common'; + +export class FakeHost extends EventEmitter { + constructor({ serverUrl, hostToken, hostId, origin, rpId, autoApprove = true }) { + super(); + this.hostId = hostId; + this.autoApprove = autoApprove; + this.policy = { rpId, origin }; + this.acl = new HostAcl(hostId); + this.challenges = new HostChallengeIssuer(); + this.ceremony = new PairingCeremony(this.acl); + /** clientIds whose connection the Host allowed — the `msg` gate on this side. */ + this.established = new Set(); + /** clientId → pairingId awaiting a manual approve/deny (autoApprove off). */ + this.pending = new Map(); + + const wsBase = serverUrl.replace(/^http/, 'ws'); + this.ws = new WebSocket(`${wsBase}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${hostToken}`); + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener('open', () => { + this.emit('open'); + resolve(); + }); + this.ws.addEventListener('error', (ev) => reject(ev.error ?? new Error('host ws error'))); + this.ws.addEventListener('close', (ev) => reject(new Error(`closed before open (${ev.code})`))); + }); + this.closed = new Promise((resolve) => this.ws.addEventListener('close', (ev) => resolve(ev))); + this.ws.addEventListener('close', (ev) => this.emit('close', ev)); + this.ws.addEventListener('message', (ev) => { + void this.#onFrame(ev.data); + }); + } + + #send(frame) { + try { + this.ws.send(JSON.stringify(frame)); + } catch { + /* socket mid-close */ + } + } + + async #onFrame(raw) { + let frame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : ''); + } catch { + return; + } + if (!frame || typeof frame.t !== 'string' || typeof frame.clientId !== 'string') return; + const { clientId } = frame; + switch (frame.t) { + case 'pair': { + this.emit('pair', { clientId, request: frame.request }); + const ticket = this.ceremony.begin(frame.request); + this.pending.set(clientId, ticket.pairingId); + if (this.autoApprove) this.approve(clientId); + return; + } + case 'connect': { + const { challenge, expiresAt } = this.challenges.issue(); + this.emit('connect', { clientId, challenge }); + this.#send({ t: 'challenge', clientId, challenge, expiresAt }); + return; + } + case 'connect2': { + const decision = await authorizeConnection( + { hostId: this.hostId, acl: this.acl, challenges: this.challenges, policy: this.policy }, + frame.request, + ); + if (decision.allowed) this.established.add(clientId); + this.emit('decision', { clientId, allowed: decision.allowed, failures: decision.failures }); + // `failures` is optional on the wire; omit it on an allowed decision. + this.#send({ + t: 'decision', + clientId, + allowed: decision.allowed, + ...(decision.allowed ? {} : { failures: decision.failures }), + }); + return; + } + case 'msg': { + if (!this.established.has(clientId)) return; // gate: never before an allowed decision + this.#handleRemoteApi(clientId, frame.data); + return; + } + case 'client-gone': { + this.established.delete(clientId); + this.pending.delete(clientId); + this.emit('client-gone', { clientId }); + return; + } + default: + return; + } + } + + /** Minimal remote-api v1: answer `hello`, refuse everything else with ok:false. */ + #handleRemoteApi(clientId, data) { + const request = data; + if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { + return; + } + let response; + if (request.method === 'hello') { + response = { + requestId: request.requestId, + ok: true, + result: { protocolVersion: 1, hostId: this.hostId, grants: { input: true, layout: true } }, + }; + } else { + response = { + requestId: request.requestId, + ok: false, + error: `unknown method: ${request.method}`, + }; + } + this.emit('msg', { clientId, request, response }); + this.#send({ t: 'msg', clientId, data: response }); + } + + /** Local approval on the Host: the only path that writes to the ACL. */ + approve(clientId, { approvedBy = 'host-user', label } = {}) { + const pairingId = this.pending.get(clientId); + if (!pairingId) return undefined; + this.pending.delete(clientId); + const record = this.ceremony.approve(pairingId, { approvedBy, label }); + this.emit('paired', { clientId, record }); + this.#send({ t: 'pair-result', clientId, approved: true, record }); + return record; + } + + /** Local denial on the Host: the ACL is untouched. */ + deny(clientId, { error = 'pairing denied by host' } = {}) { + const pairingId = this.pending.get(clientId); + if (!pairingId) return; + this.pending.delete(clientId); + this.ceremony.deny(pairingId); + this.emit('denied', { clientId }); + this.#send({ t: 'pair-result', clientId, approved: false, error }); + } + + close() { + try { + this.ws.close(); + } catch { + /* already closing */ + } + } +} diff --git a/server/test/relay.test.mjs b/server/test/relay.test.mjs index bc5da394..8b841be1 100644 --- a/server/test/relay.test.mjs +++ b/server/test/relay.test.mjs @@ -9,7 +9,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { WS_ROUTES, WS_TOKEN_PARAM } from 'server-lib-common'; +import { WS_ROUTES, WS_TOKEN_PARAM, hashPasskeyPublicKey } from 'server-lib-common'; import { connectClient, connectHost, freshApp, startServer, wsConnect } from './helpers.mjs'; @@ -20,25 +20,26 @@ async function relay() { return { app: created.app, server, close: () => server.close() }; } -const PAIRING_REQUEST = { - accountId: 'owner', - passkeyCredentialId: 'cred-1', - passkeyPublicKeyHash: 'hash-1', - devicePublicKey: 'device-1', - requestedLabel: 'iPhone Safari', -}; - test('pair round-trips client→host with a stamped clientId, pair-result routes back', async () => { const { app, server, close } = await relay(); try { const { host, socket: hostWs } = await connectHost(app, server); - const { socket: clientWs } = await connectClient(app, server); - - clientWs.send({ t: 'pair', hostId: host.hostId, request: PAIRING_REQUEST }); + const { socket: clientWs, authenticator } = await connectClient(app, server); + + // Slice 3 verifies pair frames server-side, so the request must reference + // the session's real (registered) passkey rather than a synthetic one. + const pairingRequest = { + accountId: 'owner', + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(authenticator.publicKey), + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + }; + clientWs.send({ t: 'pair', hostId: host.hostId, request: pairingRequest }); const forwarded = await hostWs.take(); assert.equal(forwarded.t, 'pair'); assert.equal(typeof forwarded.clientId, 'string'); - assert.deepEqual(forwarded.request, PAIRING_REQUEST); + assert.deepEqual(forwarded.request, pairingRequest); const record = { hostId: host.hostId, accountId: 'owner' }; hostWs.send({ t: 'pair-result', clientId: forwarded.clientId, approved: true, record }); From e15d39a7acdd59b45ecbe982780a2d524872432f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 14:39:57 -0700 Subject: [PATCH 10/52] =?UTF-8?q?feat(lib):=20remote=20host=20module=20?= =?UTF-8?q?=E2=80=94=20enrollment,=20pairing=20approval,=20terminal=20brid?= =?UTF-8?q?ge=20(slice=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lib/src/remote/host: a standalone-active Host that enrolls with the selfhost Server, holds the /ws/host relay socket, shows a real pairing approval modal, persists its HostAcl to localStorage, is final authority for connections (authorizeConnection), and bridges real terminal panes over the remote-api v1 terminal-only protocol. Mirrors server/test/harness/fake-host.mjs exactly: pair-result carries the HostAclRecord, an allowed decision omits failures, msg is gated on an allowed decision, and client-gone drops per-client state. - enrollment.ts / acl.ts: localStorage persistence (records()/fromRecords) - remote-host.ts: DOM-free controller (injected socket + session factory) - remote-api.ts: hello / directory.watch (coalesced snapshots) / surface.attach (attach-is-the-resize via xterm.resize) / terminal.write / terminal.resize / surface.detach, forwarding PTY data as base64url - RemotePairingModal(Host): approval UI wired next to the other Wall modals - activation.ts: window.dormouseRemoteHost console hook (enroll/status/clear) - tests: pure directory builder, ACL + enrollment localStorage round-trips, frame handling with a fake socket and real WebCrypto Co-Authored-By: Claude --- lib/src/components/Wall.tsx | 2 + lib/src/remote/host/RemotePairingModal.tsx | 67 ++++ .../remote/host/RemotePairingModalHost.tsx | 42 +++ lib/src/remote/host/acl.test.ts | 66 ++++ lib/src/remote/host/acl.ts | 51 +++ lib/src/remote/host/activation.ts | 87 +++++ lib/src/remote/host/directory-collect.ts | 54 +++ lib/src/remote/host/directory.test.ts | 90 +++++ lib/src/remote/host/directory.ts | 48 +++ lib/src/remote/host/enrollment.test.ts | 87 +++++ lib/src/remote/host/enrollment.ts | 99 +++++ lib/src/remote/host/pairing-approval.ts | 51 +++ lib/src/remote/host/remote-api.ts | 272 ++++++++++++++ lib/src/remote/host/remote-host.test.ts | 353 ++++++++++++++++++ lib/src/remote/host/remote-host.ts | 338 +++++++++++++++++ 15 files changed, 1707 insertions(+) create mode 100644 lib/src/remote/host/RemotePairingModal.tsx create mode 100644 lib/src/remote/host/RemotePairingModalHost.tsx create mode 100644 lib/src/remote/host/acl.test.ts create mode 100644 lib/src/remote/host/acl.ts create mode 100644 lib/src/remote/host/activation.ts create mode 100644 lib/src/remote/host/directory-collect.ts create mode 100644 lib/src/remote/host/directory.test.ts create mode 100644 lib/src/remote/host/directory.ts create mode 100644 lib/src/remote/host/enrollment.test.ts create mode 100644 lib/src/remote/host/enrollment.ts create mode 100644 lib/src/remote/host/pairing-approval.ts create mode 100644 lib/src/remote/host/remote-api.ts create mode 100644 lib/src/remote/host/remote-host.test.ts create mode 100644 lib/src/remote/host/remote-host.ts diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 63cfa250..6b289365 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -10,6 +10,7 @@ import 'dockview-react/dist/styles/dockview.css'; import { Baseboard } from './Baseboard'; import { ExternalLinkModalHost } from './ExternalLinkModalHost'; import { AgentBrowserScreenModalHost } from './AgentBrowserScreenModalHost'; +import { RemotePairingModalHost } from '../remote/host/RemotePairingModalHost'; import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; @@ -1845,6 +1846,7 @@ export function Wall({ onKeyboardActiveChange={setDialogKeyboardActive} resolveLabel={surfaceRefForId} /> + diff --git a/lib/src/remote/host/RemotePairingModal.tsx b/lib/src/remote/host/RemotePairingModal.tsx new file mode 100644 index 00000000..ab3cdfc8 --- /dev/null +++ b/lib/src/remote/host/RemotePairingModal.tsx @@ -0,0 +1,67 @@ +import { useRef } from 'react'; +import { ModalFrame, ModalReviewBlock, modalActionButton } from '../../components/design'; +import type { PairingRequest } from 'server-lib-common'; + +/** + * The Host's local pairing-approval modal (server.md → "Pairing approval + * modal"; same pattern as KillConfirm). Approving here is the only path that + * writes the ACL, so the dialog shows exactly who is asking: the requested + * label, the account, and a short fingerprint of the requesting browser's + * device key. + */ +export function RemotePairingModal({ + request, + onApprove, + onDeny, +}: { + request: PairingRequest; + onApprove: () => void; + onDeny: () => void; +}) { + const denyButtonRef = useRef(null); + const fingerprint = request.devicePublicKey.slice(0, 8); + + return ( + +

+ Pair a new device +

+

+ A device is requesting remote access to this terminal. +

+ + + Device + {request.requestedLabel || '(unnamed)'} + Account + {request.accountId} + Key + {fingerprint}… + + +
+ + +
+
+ ); +} diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx new file mode 100644 index 00000000..c5bccd2e --- /dev/null +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -0,0 +1,42 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import { RemotePairingModal } from './RemotePairingModal'; +import { + getPairingApprovalSnapshot, + subscribePairingApproval, +} from './pairing-approval'; +import { installRemoteHostConsoleHook } from './activation'; + +/** + * Renders the head of the pairing-approval queue and, on mount, activates the + * remote Host (from any persisted enrollment) and installs the console hook. + * Wired next to the other modal hosts in the wall — additive, and inert unless + * the user has enrolled a Host. + */ +export function RemotePairingModalHost({ + onKeyboardActiveChange, +}: { + onKeyboardActiveChange?: (active: boolean) => void; +}) { + const pending = useSyncExternalStore(subscribePairingApproval, getPairingApprovalSnapshot); + const head = pending[0] ?? null; + + useEffect(() => { + installRemoteHostConsoleHook(); + }, []); + + useEffect(() => { + onKeyboardActiveChange?.(head !== null); + return () => onKeyboardActiveChange?.(false); + }, [onKeyboardActiveChange, head]); + + if (!head) return null; + + return ( + head.approve()} + onDeny={() => head.deny()} + /> + ); +} diff --git a/lib/src/remote/host/acl.test.ts b/lib/src/remote/host/acl.test.ts new file mode 100644 index 00000000..0aa81f48 --- /dev/null +++ b/lib/src/remote/host/acl.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HostAcl } from 'server-lib-common'; +import { ACL_KEY_PREFIX, loadAclRecords, loadHostAcl, saveAclRecords } from './acl'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +function makeRecord(hostId: string) { + const acl = new HostAcl(hostId); + acl.approve({ + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + approvedBy: 'host-user', + label: 'iPhone Safari', + }); + return acl.records(); +} + +describe('remote-host acl persistence', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('round-trips records through localStorage', () => { + const store = stubLocalStorage(); + const records = makeRecord('host-1'); + saveAclRecords('host-1', records); + + expect(store.get(`${ACL_KEY_PREFIX}host-1`)).toBe(JSON.stringify(records)); + expect(loadAclRecords('host-1')).toEqual(records); + + const acl = loadHostAcl('host-1'); + const active = acl.activeRecords(); + expect(active).toHaveLength(1); + expect(active[0]?.label).toBe('iPhone Safari'); + expect(acl.hasActiveDevice('device-1')).toBe(true); + }); + + it('drops records belonging to a different host', () => { + stubLocalStorage(); + saveAclRecords('host-1', makeRecord('host-1')); + // A different host must not inherit host-1's ACL. + expect(loadAclRecords('host-2')).toEqual([]); + expect(loadHostAcl('host-2').activeRecords()).toEqual([]); + }); + + it('returns an empty ACL for malformed storage', () => { + const store = stubLocalStorage(); + store.set(`${ACL_KEY_PREFIX}host-1`, 'not json'); + expect(loadAclRecords('host-1')).toEqual([]); + expect(loadHostAcl('host-1').activeRecords()).toEqual([]); + }); + + it('treats a missing localStorage as an empty ACL', () => { + vi.stubGlobal('localStorage', undefined); + expect(loadAclRecords('host-1')).toEqual([]); + expect(() => saveAclRecords('host-1', [])).not.toThrow(); + }); +}); diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts new file mode 100644 index 00000000..230e330c --- /dev/null +++ b/lib/src/remote/host/acl.ts @@ -0,0 +1,51 @@ +/** + * Host ACL persistence. The ACL is the authorization primitive (see + * `server-lib-common/security/acl.ts`) and — per the security model — it lives + * on the Host, never the Server. Here it is persisted to `localStorage` as the + * record array `HostAcl.records()` produces, restored via `HostAcl.fromRecords`. + * + * Keyed per host so a browser profile that re-enrolls under a new hostId does + * not inherit a stale ACL. + */ + +import { HostAcl, type HostAclRecord } from 'server-lib-common'; + +export const ACL_KEY_PREFIX = 'dormouse.remote-host.acl.'; + +function aclKey(hostId: string): string { + return `${ACL_KEY_PREFIX}${hostId}`; +} + +/** Load the persisted records for a host, dropping anything malformed. */ +export function loadAclRecords(hostId: string): HostAclRecord[] { + try { + const raw = globalThis.localStorage?.getItem(aclKey(hostId)); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + // Only keep records for this host; fromRecords rejects a mismatched hostId. + return parsed.filter( + (record): record is HostAclRecord => + !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, + ); + } catch { + return []; + } +} + +export function saveAclRecords(hostId: string, records: readonly HostAclRecord[]): void { + try { + globalThis.localStorage?.setItem(aclKey(hostId), JSON.stringify(records)); + } catch { + // No localStorage: the in-memory ACL still works for this session. + } +} + +/** Rehydrate a live `HostAcl` from persisted records. */ +export function loadHostAcl(hostId: string): HostAcl { + try { + return HostAcl.fromRecords(hostId, loadAclRecords(hostId)); + } catch { + return new HostAcl(hostId); + } +} diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts new file mode 100644 index 00000000..af3314bb --- /dev/null +++ b/lib/src/remote/host/activation.ts @@ -0,0 +1,87 @@ +/** + * Activation glue: starts a single {@link RemoteHost} from the persisted + * enrollment on app start, and exposes a `window.dormouseRemoteHost` console + * hook for enrolling in the POC (no settings UI needed). + * + * This is the one module that binds the DOM-free controller to the terminal + * bridge (`RemoteApiSession` touches xterm / the platform adapter), so only the + * running app imports it — the controller and its tests stay DOM-free. + * + * Enroll from the devtools console: + * + * await window.dormouseRemoteHost.enroll('https://your-server', 'SETUP_PASSWORD', 'My Laptop') + * window.dormouseRemoteHost.status() + * window.dormouseRemoteHost.clearEnrollment() + */ + +import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; +import { RemoteApiSession } from './remote-api'; +import { RemoteHost } from './remote-host'; + +let current: RemoteHost | null = null; + +function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { + const host = new RemoteHost({ + enrollment, + createSession: (opts) => + new RemoteApiSession({ + hostId: opts.hostId, + // The controller sends the untyped remote-api payload inside a `msg`. + send: (payload) => opts.send(payload), + }), + }); + host.start(); + return host; +} + +/** Start the Host if an enrollment exists and none is running. Idempotent. */ +export function activateRemoteHost(): void { + if (current) return; + const enrollment = getEnrollment(); + if (!enrollment) return; + current = startFromEnrollment(enrollment); +} + +export function stopRemoteHost(): void { + current?.stop(); + current = null; +} + +export interface RemoteHostConsoleStatus { + enrolled: boolean; + serverUrl: string | null; + hostId: string | null; + connection: string; + pairedClients: number; +} + +function remoteHostStatus(): RemoteHostConsoleStatus { + const enrollment = getEnrollment(); + return { + enrolled: !!enrollment, + serverUrl: enrollment?.serverUrl ?? null, + hostId: enrollment?.hostId ?? null, + connection: current?.status ?? 'stopped', + pairedClients: current?.activeRecords.length ?? 0, + }; +} + +/** Install the `window.dormouseRemoteHost` console hook and activate. Idempotent. */ +export function installRemoteHostConsoleHook(): void { + activateRemoteHost(); + const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; + if (target.dormouseRemoteHost) return; + target.dormouseRemoteHost = { + async enroll(serverUrl: string, password: string, label: string) { + const enrollment = await enrollHost(serverUrl, password, label); + stopRemoteHost(); + current = startFromEnrollment(enrollment); + return { hostId: enrollment.hostId, serverUrl: enrollment.serverUrl }; + }, + status: remoteHostStatus, + clearEnrollment() { + stopRemoteHost(); + clearEnrollment(); + }, + }; +} diff --git a/lib/src/remote/host/directory-collect.ts b/lib/src/remote/host/directory-collect.ts new file mode 100644 index 00000000..976fdba0 --- /dev/null +++ b/lib/src/remote/host/directory-collect.ts @@ -0,0 +1,54 @@ +/** + * The impure half of the directory: reads the live terminal registry, pane + * state store, and activity store to produce the `DirectoryEntry[]` the phone's + * picker renders. Every entry is a terminal pane (the POC is terminal-only); + * browser/iframe surfaces never enter the xterm registry, so iterating it lists + * exactly the terminal panes. + */ + +import type { DirectoryEntry } from 'server-lib-common'; +import { + buildAppTitleResolver, + deriveHeader, + getActivity, + getActivitySnapshot, + getTerminalPaneState, + getTerminalPaneStateSnapshot, + resolveDisplayPrimary, +} from '../../lib/terminal-registry'; +import { registry } from '../../lib/terminal-store'; +import { buildDirectorySnapshot, type DirectoryPaneInput } from './directory'; + +export function collectDirectorySnapshot(): DirectoryEntry[] { + const paneStates = getTerminalPaneStateSnapshot(); + const activityStates = getActivitySnapshot(); + const appTitleForPane = buildAppTitleResolver(paneStates, activityStates); + + const ids = [...registry.keys()]; + // The wall derives titles across all visible panes so duplicates disambiguate; + // feed it the same set here. + const allPanes = ids.map((id) => getTerminalPaneState(id)); + const active = typeof document !== 'undefined' ? document.activeElement : null; + + const inputs: DirectoryPaneInput[] = ids.map((id) => { + const pane = getTerminalPaneState(id); + const activity = getActivity(id); + const element = registry.get(id)?.element ?? null; + const focused = !!element && !!active && element.contains(active); + const title = resolveDisplayPrimary( + deriveHeader(pane, allPanes, { appTitleForPane }).primary, + null, + ); + return { + paneRef: id, + surfaceId: id, + title, + focused, + pane, + ringing: activity.status === 'ALERT_RINGING', + hasTODO: activity.todo === true, + }; + }); + + return buildDirectorySnapshot(inputs); +} diff --git a/lib/src/remote/host/directory.test.ts b/lib/src/remote/host/directory.test.ts new file mode 100644 index 00000000..b7344ad7 --- /dev/null +++ b/lib/src/remote/host/directory.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { buildDirectoryEntry, buildDirectorySnapshot, type DirectoryPaneInput } from './directory'; +import type { CwdState, ShellActivity, TerminalPaneState } from '../../lib/terminal-state'; + +function pane(partial: Partial = {}): TerminalPaneState { + return { + cwd: null, + activity: { kind: 'unknown' }, + pendingCommandLine: null, + currentCommand: null, + lastCommand: null, + title: null, + titleCandidates: {}, + ...partial, + }; +} + +function cwd(path: string): CwdState { + return { path, pathKind: 'posix', isRemote: false, source: 'osc7', updatedAt: 1 }; +} + +function input(partial: Partial = {}): DirectoryPaneInput { + return { + paneRef: 'p1', + surfaceId: 'p1', + title: 'shell', + focused: false, + pane: pane(), + ringing: false, + hasTODO: false, + ...partial, + }; +} + +describe('buildDirectoryEntry', () => { + it('maps a running pane with a cwd and carries the flags through', () => { + const entry = buildDirectoryEntry( + input({ + paneRef: 'pane-a', + surfaceId: 'pane-a', + title: 'pnpm dev', + focused: true, + ringing: true, + hasTODO: true, + pane: pane({ activity: { kind: 'running' }, cwd: cwd('/home/me/project') }), + }), + ); + expect(entry).toEqual({ + paneRef: 'pane-a', + surfaceId: 'pane-a', + type: 'terminal', + title: 'pnpm dev', + focused: true, + activity: 'running', + cwd: '/home/me/project', + ringing: true, + hasTODO: true, + }); + // No exitCode field while running. + expect('exitCode' in entry).toBe(false); + }); + + it('includes exitCode only when a finished command has a real code', () => { + const withCode = buildDirectoryEntry( + input({ pane: pane({ activity: { kind: 'finished', exitCode: 1 } as ShellActivity }) }), + ); + expect(withCode.activity).toBe('finished'); + expect(withCode.exitCode).toBe(1); + + const noCode = buildDirectoryEntry( + input({ pane: pane({ activity: { kind: 'finished' } as ShellActivity }) }), + ); + expect(noCode.activity).toBe('finished'); + expect('exitCode' in noCode).toBe(false); + }); + + it('omits cwd when the pane has none', () => { + const entry = buildDirectoryEntry(input()); + expect('cwd' in entry).toBe(false); + expect(entry.activity).toBe('unknown'); + }); + + it('builds a snapshot preserving order', () => { + const entries = buildDirectorySnapshot([ + input({ paneRef: 'a', surfaceId: 'a' }), + input({ paneRef: 'b', surfaceId: 'b' }), + ]); + expect(entries.map((e) => e.surfaceId)).toEqual(['a', 'b']); + }); +}); diff --git a/lib/src/remote/host/directory.ts b/lib/src/remote/host/directory.ts new file mode 100644 index 00000000..a1b9d07e --- /dev/null +++ b/lib/src/remote/host/directory.ts @@ -0,0 +1,48 @@ +/** + * Pure `directory.snapshot` entry construction (remote-api.md → "Directory"). + * Split from the impure collector (`directory-collect.ts`) so the mapping from + * pane state to the wire `DirectoryEntry` is unit-testable without the terminal + * registry, xterm, or the DOM. + */ + +import type { DirectoryEntry } from 'server-lib-common'; +import type { TerminalPaneState } from '../../lib/terminal-state'; + +/** Everything one directory entry needs, already resolved from the live stores. */ +export interface DirectoryPaneInput { + paneRef: string; + surfaceId: string; + /** The derived title the wall header shows (deriveHeader + resolveDisplayPrimary). */ + title: string; + /** Focused on the Host. */ + focused: boolean; + pane: TerminalPaneState; + /** The pane's alert is ringing on the Host (alert-manager). */ + ringing: boolean; + /** The pane has an outstanding TODO. */ + hasTODO: boolean; +} + +export function buildDirectoryEntry(input: DirectoryPaneInput): DirectoryEntry { + const { pane } = input; + // `exitCode` only when the last command finished with a real code; `activity` + // maps straight across (ShellActivity['kind'] is the wire union verbatim). + const exitCode = pane.activity.kind === 'finished' ? pane.activity.exitCode : undefined; + const cwd = pane.cwd?.path; + return { + paneRef: input.paneRef, + surfaceId: input.surfaceId, + type: 'terminal', + title: input.title, + focused: input.focused, + activity: pane.activity.kind, + ...(exitCode !== undefined ? { exitCode } : {}), + ...(cwd ? { cwd } : {}), + ringing: input.ringing, + hasTODO: input.hasTODO, + }; +} + +export function buildDirectorySnapshot(inputs: readonly DirectoryPaneInput[]): DirectoryEntry[] { + return inputs.map(buildDirectoryEntry); +} diff --git a/lib/src/remote/host/enrollment.test.ts b/lib/src/remote/host/enrollment.test.ts new file mode 100644 index 00000000..fba9b442 --- /dev/null +++ b/lib/src/remote/host/enrollment.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ENROLLMENT_KEY, + clearEnrollment, + enrollHost, + getEnrollment, +} from './enrollment'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +describe('remote-host enrollment', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('posts to /api/host/enroll, normalizes the url, and persists', async () => { + const store = stubLocalStorage(); + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + hostId: 'host-abc', + hostToken: 'tok-xyz', + origin: 'https://dormouse.example', + rpId: 'dormouse.example', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + // Trailing slash should be stripped before appending the route. + const enrollment = await enrollHost('https://dormouse.example/', 'hunter2', 'My Laptop'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dormouse.example/api/host/enroll', + expect.objectContaining({ method: 'POST' }), + ); + const body = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); + expect(body).toEqual({ password: 'hunter2', label: 'My Laptop' }); + + expect(enrollment).toEqual({ + serverUrl: 'https://dormouse.example', + hostId: 'host-abc', + hostToken: 'tok-xyz', + origin: 'https://dormouse.example', + rpId: 'dormouse.example', + }); + expect(JSON.parse(store.get(ENROLLMENT_KEY)!)).toEqual(enrollment); + expect(getEnrollment()).toEqual(enrollment); + }); + + it('throws on a non-ok response', async () => { + stubLocalStorage(); + vi.stubGlobal('fetch', vi.fn(async () => new Response('bad password', { status: 401 }))); + await expect(enrollHost('https://dormouse.example', 'wrong', 'x')).rejects.toThrow(/401/); + }); + + it('clears and rejects malformed persisted enrollment', () => { + const store = stubLocalStorage(); + expect(getEnrollment()).toBeNull(); + + store.set(ENROLLMENT_KEY, JSON.stringify({ serverUrl: 'x' })); // missing fields + expect(getEnrollment()).toBeNull(); + + store.set( + ENROLLMENT_KEY, + JSON.stringify({ + serverUrl: 's', + hostId: 'h', + hostToken: 't', + origin: 'o', + rpId: 'r', + }), + ); + expect(getEnrollment()).not.toBeNull(); + + clearEnrollment(); + expect(store.has(ENROLLMENT_KEY)).toBe(false); + expect(getEnrollment()).toBeNull(); + }); +}); diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts new file mode 100644 index 00000000..0e91bf8f --- /dev/null +++ b/lib/src/remote/host/enrollment.ts @@ -0,0 +1,99 @@ +/** + * Host enrollment against the selfhost Server (docs/specs/server.md → "Host + * side"). Enrollment is the one-time exchange that turns a setup password into + * the durable credentials the Host needs to hold its `/ws/host` socket: + * `{ serverUrl, hostId, hostToken, origin, rpId }`. `origin`/`rpId` become the + * Host's `ConnectionPolicy` — the Server tells the Host what it must enforce, + * and the Host enforces it as final authority regardless. + * + * Persisted in `localStorage` (browser-only, no platform adapter dependency) so + * the standalone app can rehydrate and reconnect on the next launch. + */ + +import { API_ROUTES, type HostEnrollResponse } from 'server-lib-common'; + +export interface HostEnrollment { + /** Origin the Server is reachable at, e.g. `https://dormouse.tailnet.ts.net`. */ + serverUrl: string; + hostId: string; + /** Bearer credential for the `token` query param of `/ws/host`. */ + hostToken: string; + /** The Host's `ConnectionPolicy.origin`. */ + origin: string; + /** The Host's `ConnectionPolicy.rpId`. */ + rpId: string; +} + +/** Single localStorage key holding the whole enrollment blob. */ +export const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; + +function isEnrollment(value: unknown): value is HostEnrollment { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.serverUrl === 'string' && + typeof v.hostId === 'string' && + typeof v.hostToken === 'string' && + typeof v.origin === 'string' && + typeof v.rpId === 'string' + ); +} + +export function getEnrollment(): HostEnrollment | null { + try { + const raw = globalThis.localStorage?.getItem(ENROLLMENT_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + return isEnrollment(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function clearEnrollment(): void { + try { + globalThis.localStorage?.removeItem(ENROLLMENT_KEY); + } catch { + // No localStorage (some host/test contexts): nothing to clear. + } +} + +function saveEnrollment(enrollment: HostEnrollment): void { + try { + globalThis.localStorage?.setItem(ENROLLMENT_KEY, JSON.stringify(enrollment)); + } catch { + // No localStorage: the caller still gets the in-memory enrollment back. + } +} + +/** + * `POST /api/host/enroll` with the setup password, persist the returned + * credentials, and hand the enrollment back. Throws with the server's status + * text on failure so the caller (console hook / settings UI) can surface it. + */ +export async function enrollHost( + serverUrl: string, + password: string, + label: string, +): Promise { + const base = serverUrl.replace(/\/+$/, ''); + const response = await fetch(`${base}${API_ROUTES.hostEnroll}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password, label }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`host enroll failed (${response.status})${detail ? `: ${detail}` : ''}`); + } + const body = (await response.json()) as HostEnrollResponse; + const enrollment: HostEnrollment = { + serverUrl: base, + hostId: body.hostId, + hostToken: body.hostToken, + origin: body.origin, + rpId: body.rpId, + }; + saveEnrollment(enrollment); + return enrollment; +} diff --git a/lib/src/remote/host/pairing-approval.ts b/lib/src/remote/host/pairing-approval.ts new file mode 100644 index 00000000..60f35375 --- /dev/null +++ b/lib/src/remote/host/pairing-approval.ts @@ -0,0 +1,51 @@ +/** + * The pairing-approval queue: an external store (same shape as + * `external-link-confirmation.ts`) that bridges the {@link RemoteHost}'s frame + * loop to the React approval modal. A `pair` frame enqueues a request; the modal + * renders the head of the queue and calls `approve`/`deny`, which run the real + * `PairingCeremony` on the Host (the only path that writes the ACL). + */ + +import type { PairingRequest } from 'server-lib-common'; + +export interface PendingPairing { + /** Server-assigned client socket id; the approve/deny reply is keyed by it. */ + clientId: string; + request: PairingRequest; + requestedAt: number; + /** Approve locally on the Host — writes the ACL and replies `pair-result`. */ + approve: (label?: string) => void; + /** Deny locally — the ACL is untouched. */ + deny: (error?: string) => void; +} + +let queue: readonly PendingPairing[] = []; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +export function enqueuePairingApproval(pending: PendingPairing): void { + // Coalesce by clientId: a re-sent pair for the same client replaces the old. + queue = [...queue.filter((p) => p.clientId !== pending.clientId), pending]; + emit(); +} + +export function resolvePairingApproval(clientId: string): void { + const next = queue.filter((p) => p.clientId !== clientId); + if (next.length === queue.length) return; + queue = next; + emit(); +} + +export function getPairingApprovalSnapshot(): readonly PendingPairing[] { + return queue; +} + +export function subscribePairingApproval(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts new file mode 100644 index 00000000..41c112b4 --- /dev/null +++ b/lib/src/remote/host/remote-api.ts @@ -0,0 +1,272 @@ +/** + * Remote-api v1, terminal-only (remote-api.md → "v1 scope"). One + * {@link RemoteApiSession} per authorized Client session translates the wire + * protocol into the Host's existing terminal plumbing: + * + * - `hello` → capabilities (input yes, layout no). + * - `directory.watch` → an immediate snapshot plus coalesced re-snapshots + * whenever pane state / activity / focus changes. + * - `surface.attach` → resize the real PTY through the existing xterm resize + * path (attach-is-the-resize) and stream its output as + * `terminal.data`; `terminal.closed` on PTY exit. + * - `terminal.write` → the existing PTY input path. + * - `terminal.resize` → take size authority (last-attach-wins). + * - `surface.detach` → stop streaming. + * + * The bytes on the wire are base64url PTY bytes; xterm on the Client renders + * them, exactly as the Host's own xterm renders the same stream locally. + */ + +import { + REMOTE_EVENTS, + REMOTE_METHODS, + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, + type AttachParams, + type HelloResult, + type RemoteEventMsg, + type RemoteRequest, + type RemoteResponse, + type TerminalAttachResult, + type TerminalResizeParams, + type TerminalWriteParams, +} from 'server-lib-common'; +import { getPlatform } from '../../lib/platform'; +import { registry } from '../../lib/terminal-store'; +import { subscribeToActivity } from '../../lib/session-activity-store'; +import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; +import { collectDirectorySnapshot } from './directory-collect'; + +/** Coalesce window for directory re-snapshots (remote-api.md: "Host coalesces"). */ +const DIRECTORY_DEBOUNCE_MS = 150; +/** + * When an attach requests the size the PTY already has, `terminal.resize` is a + * no-op, so we bounce the PTY's rows to force one SIGWINCH-driven repaint. + */ +const FORCE_REPAINT_BOUNCE_MS = 60; + +interface Attachment { + surfaceId: string; + ptyId: string; + subId: string; + onData: (detail: { id: string; data: string }) => void; + onExit: (detail: { id: string; exitCode: number }) => void; +} + +export interface RemoteApiSessionOptions { + hostId: string; + /** Sends a remote-api response/event; the caller wraps it in a `msg` frame. */ + send: (payload: RemoteResponse | RemoteEventMsg) => void; +} + +export class RemoteApiSession { + readonly #hostId: string; + readonly #send: (payload: RemoteResponse | RemoteEventMsg) => void; + + #directorySubId: string | null = null; + #unsubDirectory: (() => void) | null = null; + #directoryTimer: ReturnType | null = null; + #attachment: Attachment | null = null; + + constructor(options: RemoteApiSessionOptions) { + this.#hostId = options.hostId; + this.#send = options.send; + } + + handle(data: unknown): void { + const request = data as RemoteRequest; + if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { + return; + } + try { + switch (request.method) { + case REMOTE_METHODS.hello: + return this.#hello(request); + case REMOTE_METHODS.directoryWatch: + return this.#directoryWatch(request); + case REMOTE_METHODS.surfaceAttach: + return this.#attach(request); + case REMOTE_METHODS.surfaceDetach: + return this.#detach(request); + case REMOTE_METHODS.terminalWrite: + return this.#write(request); + case REMOTE_METHODS.terminalResize: + return this.#resize(request); + default: + return this.#fail(request, `unknown method: ${request.method}`); + } + } catch (error) { + this.#fail(request, error instanceof Error ? error.message : 'internal error'); + } + } + + dispose(): void { + this.#directorySubId = null; + if (this.#directoryTimer) { + clearTimeout(this.#directoryTimer); + this.#directoryTimer = null; + } + this.#unsubDirectory?.(); + this.#unsubDirectory = null; + this.#teardownAttachment(); + } + + // --- Responses --- + + #ok(request: RemoteRequest, result: unknown): void { + this.#send({ requestId: request.requestId, ok: true, result }); + } + + #fail(request: RemoteRequest, error: string): void { + this.#send({ requestId: request.requestId, ok: false, error }); + } + + #event(subId: string, event: string, data: unknown): void { + this.#send({ subId, event, data }); + } + + // --- Methods --- + + #hello(request: RemoteRequest): void { + // v1 selfhost: every paired session is the owner, so full input, no layout. + const result: HelloResult = { + protocolVersion: 1, + hostId: this.#hostId, + grants: { input: true, layout: false }, + }; + this.#ok(request, result); + } + + #directoryWatch(request: RemoteRequest): void { + // The subscription id the client correlates snapshots by is this request id. + this.#directorySubId = request.requestId; + this.#ok(request, { subId: request.requestId }); + this.#emitDirectory(); + + if (this.#unsubDirectory) return; + const trigger = () => this.#scheduleDirectory(); + const unsubPane = subscribeToTerminalPaneState(trigger); + const unsubActivity = subscribeToActivity(trigger); + const hasDocument = typeof document !== 'undefined'; + if (hasDocument) { + document.addEventListener('focusin', trigger); + document.addEventListener('focusout', trigger); + } + this.#unsubDirectory = () => { + unsubPane(); + unsubActivity(); + if (hasDocument) { + document.removeEventListener('focusin', trigger); + document.removeEventListener('focusout', trigger); + } + }; + } + + #scheduleDirectory(): void { + if (this.#directorySubId === null || this.#directoryTimer) return; + this.#directoryTimer = setTimeout(() => { + this.#directoryTimer = null; + this.#emitDirectory(); + }, DIRECTORY_DEBOUNCE_MS); + } + + #emitDirectory(): void { + if (this.#directorySubId === null) return; + this.#event(this.#directorySubId, REMOTE_EVENTS.directorySnapshot, { + entries: collectDirectorySnapshot(), + }); + } + + #attach(request: RemoteRequest): void { + const params = request.params as AttachParams | undefined; + const entry = params ? registry.get(params.surfaceId) : undefined; + if (!params || !entry) { + return this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + } + // v1: one attachment per session — replace any prior stream. + this.#teardownAttachment(); + + const ptyId = entry.ptyId; + const term = entry.terminal; + const cols = clampDimension(params.cols, term.cols); + const rows = clampDimension(params.rows, term.rows); + const platform = getPlatform(); + + // Attach-is-the-resize: resizing the real xterm fires its onResize handler, + // which drives resizePty → SIGWINCH → the TUI/shell repaints, and that + // repaint is what fills the client's screen (no snapshot transfer). + if (term.cols !== cols || term.rows !== rows) { + term.resize(cols, rows); + } else { + // Same size: force one repaint with a quick rows bounce on the PTY only, + // leaving the already-correct local xterm buffer untouched. + platform.resizePty(ptyId, cols, Math.max(1, rows - 1)); + setTimeout(() => platform.resizePty(ptyId, cols, rows), FORCE_REPAINT_BOUNCE_MS); + } + + const subId = request.requestId; + const onData = (detail: { id: string; data: string }): void => { + if (detail.id !== ptyId) return; + // The PTY delivers strings on this path; be defensive about the Uint8Array + // path some adapters use. Either way it goes out as base64url PTY bytes. + const raw: unknown = detail.data; + const bytes = typeof raw === 'string' ? utf8Encode(raw) : (raw as Uint8Array); + this.#event(subId, REMOTE_EVENTS.terminalData, { bytes: toBase64Url(bytes) }); + }; + const onExit = (detail: { id: string; exitCode: number }): void => { + if (detail.id !== ptyId) return; + this.#event(subId, REMOTE_EVENTS.terminalClosed, { exitCode: detail.exitCode }); + }; + platform.onPtyData(onData); + platform.onPtyExit(onExit); + this.#attachment = { surfaceId: params.surfaceId, ptyId, subId, onData, onExit }; + + const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; + this.#ok(request, result); + } + + #detach(request: RemoteRequest): void { + this.#teardownAttachment(); + this.#ok(request, {}); + } + + #write(request: RemoteRequest): void { + const params = request.params as TerminalWriteParams | undefined; + const entry = params ? registry.get(params.surfaceId) : undefined; + if (!params || !entry) { + return this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + } + // Feed the existing PTY input path; the local echo returns via onPtyData. + getPlatform().writePty(entry.ptyId, utf8Decode(fromBase64Url(params.bytes))); + this.#ok(request, {}); + } + + #resize(request: RemoteRequest): void { + const params = request.params as TerminalResizeParams | undefined; + const entry = params ? registry.get(params.surfaceId) : undefined; + if (!params || !entry) { + return this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + } + const term = entry.terminal; + const cols = clampDimension(params.cols, term.cols); + const rows = clampDimension(params.rows, term.rows); + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); + const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; + this.#ok(request, result); + } + + #teardownAttachment(): void { + if (!this.#attachment) return; + const platform = getPlatform(); + platform.offPtyData(this.#attachment.onData); + platform.offPtyExit(this.#attachment.onExit); + this.#attachment = null; + } +} + +function clampDimension(value: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.max(1, Math.floor(value)); +} diff --git a/lib/src/remote/host/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts new file mode 100644 index 00000000..80ff0656 --- /dev/null +++ b/lib/src/remote/host/remote-host.test.ts @@ -0,0 +1,353 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + concatBytes, + ecdsaRawToDer, + generateDeviceKeyPair, + hashPasskeyPublicKey, + signDeviceChallenge, + toBase64Url, + utf8Encode, + type ConnectionRequest, + type HostAclRecord, + type PairingRequest, +} from 'server-lib-common'; +import { RemoteHost, type WebSocketLike } from './remote-host'; +import type { HostEnrollment } from './enrollment'; +import type { PendingPairing } from './pairing-approval'; + +// --- A fake `/ws/host` socket the test drives directly --- + +class FakeSocket implements WebSocketLike { + readyState = 1; + readonly sent: Array> = []; + readonly #handlers = new Map void>>(); + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#handlers.get(type) ?? []; + list.push(handler); + this.#handlers.set(type, list); + } + + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + + close(): void { + this.readyState = 3; + this.#emit('close', { code: 1000 }); + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } + + open(): void { + this.#emit('open', {}); + } + + /** Deliver a server→host frame. */ + receive(frame: unknown): void { + this.#emit('message', { data: JSON.stringify(frame) }); + } + + frames(t: string): Array> { + return this.sent.filter((frame) => frame.t === t); + } +} + +const ENROLLMENT: HostEnrollment = { + serverUrl: 'https://host.example', + hostId: 'host-1', + hostToken: 'tok', + origin: 'https://host.example', + rpId: 'host.example', +}; + +// --- Minimal faithful WebAuthn authenticator (mirrors test/harness/actors.mjs) --- + +const subtle = globalThis.crypto.subtle; + +async function sha256(bytes: Uint8Array): Promise { + return new Uint8Array(await subtle.digest('SHA-256', bytes)); +} + +async function createAuthenticator(rpId: string) { + const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const spki = new Uint8Array(await subtle.exportKey('spki', keyPair.publicKey)); + const publicKey = toBase64Url(spki); + const credentialId = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(16))); + let signCount = 0; + + async function assert(challenge: string, origin: string) { + const clientDataJSON = utf8Encode( + JSON.stringify({ type: 'webauthn.get', challenge, origin, crossOrigin: false }), + ); + const rpIdHash = await sha256(utf8Encode(rpId)); + signCount += 1; + const flags = 0x01 | 0x04; // user present + user verified + const authenticatorData = concatBytes( + rpIdHash, + Uint8Array.of(flags, (signCount >>> 24) & 0xff, (signCount >>> 16) & 0xff, (signCount >>> 8) & 0xff, signCount & 0xff), + ); + const rawSignature = new Uint8Array( + await subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.privateKey, + concatBytes(authenticatorData, await sha256(clientDataJSON)), + ), + ); + return { + credentialId, + clientDataJSON: toBase64Url(clientDataJSON), + authenticatorData: toBase64Url(authenticatorData), + signature: toBase64Url(ecdsaRawToDer(rawSignature)), + }; + } + + return { publicKey, credentialId, assert }; +} + +async function flushUntil(get: () => T | undefined, timeoutMs = 2000): Promise { + const start = Date.now(); + for (;;) { + const value = get(); + if (value !== undefined) return value; + if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for frame'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** Drive connect → connect2 and return the decision frame. */ +async function runConnect( + socket: FakeSocket, + clientId: string, + build: (challenge: string) => Promise, +): Promise> { + socket.sent.length = 0; + socket.receive({ t: 'connect', clientId }); + const challengeFrame = socket.frames('challenge')[0]!; + const request = await build(challengeFrame.challenge as string); + socket.receive({ t: 'connect2', clientId, request }); + return flushUntil(() => socket.frames('decision')[0]); +} + +describe('RemoteHost frame handling', () => { + let socket: FakeSocket; + let savedRecords: HostAclRecord[] = []; + let approvals: PendingPairing[] = []; + + function makeHost(loadAcl: () => HostAclRecord[] = () => []) { + savedRecords = []; + approvals = []; + const host = new RemoteHost({ + enrollment: ENROLLMENT, + reconnect: false, + createWebSocket: () => (socket = new FakeSocket()), + loadAcl, + saveAcl: (_hostId, records) => { + savedRecords = [...records]; + }, + requestApproval: (pending) => approvals.push(pending), + dismissApproval: () => {}, + }); + host.start(); + socket.open(); + return host; + } + + beforeEach(() => { + socket = new FakeSocket(); + }); + + it('pair → local approval → pair-result with the ACL record, and persists', () => { + makeHost(); + const request: PairingRequest = { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + }; + socket.receive({ t: 'pair', clientId: 'c1', request }); + + // No ACL write until the local user approves. + expect(socket.frames('pair-result')).toHaveLength(0); + expect(approvals).toHaveLength(1); + + approvals[0]!.approve(); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ clientId: 'c1', approved: true }); + expect((result.record as HostAclRecord).devicePublicKey).toBe('device-1'); + expect((result.record as HostAclRecord).label).toBe('iPhone Safari'); + // The approval wrote and persisted the ACL. + expect(savedRecords).toHaveLength(1); + expect(savedRecords[0]!.passkeyCredentialId).toBe('cred-1'); + }); + + it('deny → pair-result approved:false, ACL untouched', () => { + makeHost(); + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest, + }); + approvals[0]!.deny(); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ clientId: 'c1', approved: false }); + expect(result.record).toBeUndefined(); + expect(savedRecords).toEqual([]); + }); + + it('connect issues a challenge frame', () => { + makeHost(); + socket.receive({ t: 'connect', clientId: 'c1' }); + const challenge = socket.frames('challenge')[0]!; + expect(challenge.clientId).toBe('c1'); + expect(typeof challenge.challenge).toBe('string'); + expect(typeof challenge.expiresAt).toBe('number'); + }); + + it('connect2 for an unpaired device denies with failures', async () => { + makeHost(); + const authenticator = await createAuthenticator(ENROLLMENT.rpId); + const deviceKey = await generateDeviceKeyPair(); + + const decision = await runConnect(socket, 'c1', async (challenge) => ({ + accountId: 'owner', + devicePublicKey: deviceKey.devicePublicKey, + challenge, + deviceSignature: await signDeviceChallenge(deviceKey.privateKey, { + hostId: ENROLLMENT.hostId, + challenge, + devicePublicKey: deviceKey.devicePublicKey, + }), + passkey: { + publicKey: authenticator.publicKey, + assertion: await authenticator.assert(challenge, ENROLLMENT.origin), + }, + })); + + expect(decision).toMatchObject({ clientId: 'c1', allowed: false }); + expect(decision.failures).toEqual( + expect.arrayContaining(['passkey-not-paired', 'device-not-paired']), + ); + }); + + it('pair then connect2 allows and omits failures', async () => { + makeHost(); + const authenticator = await createAuthenticator(ENROLLMENT.rpId); + const deviceKey = await generateDeviceKeyPair(); + const passkeyPublicKeyHash = await hashPasskeyPublicKey(authenticator.publicKey); + + // Pair this exact (passkey, device) pair through the real ceremony. + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash, + devicePublicKey: deviceKey.devicePublicKey, + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest, + }); + approvals[0]!.approve(); + expect(socket.frames('pair-result')[0]).toMatchObject({ approved: true }); + + const decision = await runConnect(socket, 'c1', async (challenge) => ({ + accountId: 'owner', + devicePublicKey: deviceKey.devicePublicKey, + challenge, + deviceSignature: await signDeviceChallenge(deviceKey.privateKey, { + hostId: ENROLLMENT.hostId, + challenge, + devicePublicKey: deviceKey.devicePublicKey, + }), + passkey: { + publicKey: authenticator.publicKey, + assertion: await authenticator.assert(challenge, ENROLLMENT.origin), + }, + })); + + expect(decision).toMatchObject({ clientId: 'c1', allowed: true }); + // `failures` is omitted from an allowed decision. + expect('failures' in decision).toBe(false); + }); + + it('gates msg on an allowed decision and routes to a session', async () => { + const handled: unknown[] = []; + let disposed = 0; + savedRecords = []; + approvals = []; + const host = new RemoteHost({ + enrollment: ENROLLMENT, + reconnect: false, + createWebSocket: () => (socket = new FakeSocket()), + loadAcl: () => [], + requestApproval: (pending) => pending.approve(), + dismissApproval: () => {}, + createSession: () => ({ + handle: (data) => handled.push(data), + dispose: () => { + disposed += 1; + }, + }), + }); + host.start(); + socket.open(); + + // Before any allowed decision, msg is dropped (the host-side gate). + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r', method: 'hello' } }); + expect(handled).toHaveLength(0); + + // Force an allowed decision by pairing + connecting. + const authenticator = await createAuthenticator(ENROLLMENT.rpId); + const deviceKey = await generateDeviceKeyPair(); + const passkeyPublicKeyHash = await hashPasskeyPublicKey(authenticator.publicKey); + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash, + devicePublicKey: deviceKey.devicePublicKey, + requestedLabel: 'x', + } satisfies PairingRequest, + }); + await runConnect(socket, 'c1', async (challenge) => ({ + accountId: 'owner', + devicePublicKey: deviceKey.devicePublicKey, + challenge, + deviceSignature: await signDeviceChallenge(deviceKey.privateKey, { + hostId: ENROLLMENT.hostId, + challenge, + devicePublicKey: deviceKey.devicePublicKey, + }), + passkey: { + publicKey: authenticator.publicKey, + assertion: await authenticator.assert(challenge, ENROLLMENT.origin), + }, + })); + + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r', method: 'hello' } }); + expect(handled).toHaveLength(1); + + // client-gone disposes the session and re-gates. + socket.receive({ t: 'client-gone', clientId: 'c1' }); + expect(disposed).toBe(1); + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r2', method: 'hello' } }); + expect(handled).toHaveLength(1); + }); +}); diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts new file mode 100644 index 00000000..a3ee254f --- /dev/null +++ b/lib/src/remote/host/remote-host.ts @@ -0,0 +1,338 @@ +/** + * The Host controller: holds the `/ws/host` relay socket and speaks the Host + * side of the wire contract (`server-lib-common/remote/wire.ts`), mirroring the + * headless reference in `server/test/harness/fake-host.mjs`. + * + * - `pair` → begin the ceremony and surface a local approval; approval + * runs `PairingCeremony.approve` (the only ACL write), + * persists the ACL, and replies `pair-result` with the record. + * - `connect` → issue a Host challenge. + * - `connect2` → `authorizeConnection` (final authority); `failures` is + * omitted from an allowed `decision`. + * - `msg` → only for a client with an allowed decision; routed to the + * remote-api handler. + * - `client-gone` → drop that client's transient state. + * + * The remote-api handler is injected (`createSession`) so this controller has no + * dependency on the terminal registry / xterm / DOM — the wiring lives in + * `activation.ts`, and this file stays unit-testable against a fake socket. + */ + +import { + HostAcl, + HostChallengeIssuer, + PairingCeremony, + WS_ROUTES, + WS_TOKEN_PARAM, + authorizeConnection, + type ConnectionPolicy, + type ConnectionRequest, + type HostAclRecord, + type HostFrame, + type PairingRequest, + type ServerToHostFrame, +} from 'server-lib-common'; +import type { HostEnrollment } from './enrollment'; +import { loadAclRecords, saveAclRecords } from './acl'; +import { + enqueuePairingApproval, + resolvePairingApproval, + type PendingPairing, +} from './pairing-approval'; + +/** The remote-api handler this controller drives per authorized client. */ +export interface RemoteApiSessionLike { + handle(data: unknown): void; + dispose(): void; +} + +/** Minimal WebSocket surface, so tests can inject a fake. */ +export interface WebSocketLike { + send(data: string): void; + close(): void; + addEventListener(type: 'open' | 'message' | 'close' | 'error', handler: (ev: unknown) => void): void; + readyState: number; +} + +export type RemoteHostStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'stopped'; + +export interface RemoteHostOptions { + enrollment: HostEnrollment; + createWebSocket?: (url: string) => WebSocketLike; + /** Build the remote-api handler for an authorized client (see activation.ts). */ + createSession?: (opts: { + hostId: string; + send: (payload: unknown) => void; + }) => RemoteApiSessionLike; + loadAcl?: (hostId: string) => HostAclRecord[]; + saveAcl?: (hostId: string, records: readonly HostAclRecord[]) => void; + /** Surface a pairing request for local approval (default: the modal queue). */ + requestApproval?: (pending: PendingPairing) => void; + /** Dismiss a surfaced request once resolved (default: the modal queue). */ + dismissApproval?: (clientId: string) => void; + now?: () => number; + /** Auto-reconnect with backoff (default true; tests pass false). */ + reconnect?: boolean; +} + +const INITIAL_BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 30_000; + +export class RemoteHost { + readonly #enrollment: HostEnrollment; + readonly #policy: ConnectionPolicy; + readonly #acl: HostAcl; + readonly #challenges: HostChallengeIssuer; + readonly #ceremony: PairingCeremony; + + readonly #createWebSocket: (url: string) => WebSocketLike; + readonly #createSession?: RemoteHostOptions['createSession']; + readonly #saveAcl: (hostId: string, records: readonly HostAclRecord[]) => void; + readonly #requestApproval: (pending: PendingPairing) => void; + readonly #dismissApproval: (clientId: string) => void; + readonly #now: () => number; + readonly #reconnect: boolean; + + /** clientIds whose connection the Host allowed — the `msg` gate on this side. */ + readonly #established = new Set(); + /** clientId → the in-flight pairing awaiting local approval. */ + readonly #pending = new Map(); + /** clientId → its remote-api handler, created on first authorized `msg`. */ + readonly #sessions = new Map(); + + #ws: WebSocketLike | null = null; + #status: RemoteHostStatus = 'idle'; + #stopped = false; + #backoffMs = INITIAL_BACKOFF_MS; + #reconnectTimer: ReturnType | null = null; + + constructor(options: RemoteHostOptions) { + this.#enrollment = options.enrollment; + this.#policy = { rpId: options.enrollment.rpId, origin: options.enrollment.origin }; + this.#now = options.now ?? (() => Date.now()); + this.#acl = HostAcl.fromRecords( + options.enrollment.hostId, + (options.loadAcl ?? loadAclRecords)(options.enrollment.hostId), + ); + this.#challenges = new HostChallengeIssuer({ now: this.#now }); + this.#ceremony = new PairingCeremony(this.#acl, { now: this.#now }); + + this.#createWebSocket = + options.createWebSocket ?? ((url) => new WebSocket(url) as unknown as WebSocketLike); + this.#createSession = options.createSession; + this.#saveAcl = options.saveAcl ?? saveAclRecords; + this.#requestApproval = options.requestApproval ?? enqueuePairingApproval; + this.#dismissApproval = options.dismissApproval ?? resolvePairingApproval; + this.#reconnect = options.reconnect ?? true; + } + + get status(): RemoteHostStatus { + return this.#status; + } + + get hostId(): string { + return this.#enrollment.hostId; + } + + get activeRecords(): HostAclRecord[] { + return this.#acl.activeRecords(); + } + + start(): void { + this.#stopped = false; + this.#connect(); + } + + stop(): void { + this.#stopped = true; + this.#status = 'stopped'; + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + this.#dropTransientState(); + try { + this.#ws?.close(); + } catch { + // already closing + } + this.#ws = null; + } + + // --- Socket lifecycle --- + + #connect(): void { + if (this.#ws || this.#stopped) return; + this.#status = 'connecting'; + const wsBase = this.#enrollment.serverUrl.replace(/^http/, 'ws'); + const url = `${wsBase}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${encodeURIComponent(this.#enrollment.hostToken)}`; + const ws = this.#createWebSocket(url); + this.#ws = ws; + ws.addEventListener('open', () => { + this.#status = 'connected'; + this.#backoffMs = INITIAL_BACKOFF_MS; + }); + ws.addEventListener('message', (ev) => { + this.#onFrame((ev as { data?: unknown }).data); + }); + ws.addEventListener('error', () => { + // A `close` always follows; reconnection is handled there. + }); + ws.addEventListener('close', () => { + this.#ws = null; + this.#onClose(); + }); + } + + #onClose(): void { + this.#dropTransientState(); + if (this.#stopped || !this.#reconnect) { + this.#status = 'stopped'; + return; + } + this.#status = 'disconnected'; + const delay = this.#backoffMs; + this.#backoffMs = Math.min(this.#backoffMs * 2, MAX_BACKOFF_MS); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.#connect(); + }, delay); + } + + /** Connection-scoped state resets on a dropped socket (the ACL persists). */ + #dropTransientState(): void { + for (const session of this.#sessions.values()) session.dispose(); + this.#sessions.clear(); + for (const clientId of this.#pending.keys()) this.#dismissApproval(clientId); + this.#pending.clear(); + this.#established.clear(); + } + + #send(frame: HostFrame): void { + try { + this.#ws?.send(JSON.stringify(frame)); + } catch { + // socket mid-close + } + } + + // --- Frame handling (mirrors fake-host.mjs) --- + + #onFrame(raw: unknown): void { + let frame: ServerToHostFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : '') as ServerToHostFrame; + } catch { + return; + } + if ( + !frame || + typeof (frame as { t?: unknown }).t !== 'string' || + typeof (frame as { clientId?: unknown }).clientId !== 'string' + ) { + return; + } + const clientId = frame.clientId; + switch (frame.t) { + case 'pair': + return this.#onPair(clientId, frame.request); + case 'connect': + return this.#onConnect(clientId); + case 'connect2': + void this.#onConnect2(clientId, frame.request); + return; + case 'msg': + return this.#onMsg(clientId, frame.data); + case 'client-gone': + return this.#onClientGone(clientId); + default: + return; + } + } + + #onPair(clientId: string, request: PairingRequest): void { + const ticket = this.#ceremony.begin(request); + const pending: PendingPairing = { + clientId, + request, + requestedAt: this.#now(), + approve: (label) => this.#approvePairing(clientId, ticket.pairingId, label), + deny: (error) => this.#denyPairing(clientId, ticket.pairingId, error), + }; + this.#pending.set(clientId, pending); + this.#requestApproval(pending); + } + + /** The local approval — the ONLY path that writes the ACL. */ + #approvePairing(clientId: string, pairingId: string, label?: string): void { + if (!this.#pending.delete(clientId)) return; // already resolved + let record: HostAclRecord; + try { + record = this.#ceremony.approve(pairingId, { approvedBy: 'host-user', label }); + } catch { + this.#dismissApproval(clientId); + return; + } + this.#saveAcl(this.#enrollment.hostId, this.#acl.records()); + this.#send({ t: 'pair-result', clientId, approved: true, record }); + this.#dismissApproval(clientId); + } + + #denyPairing(clientId: string, pairingId: string, error = 'pairing denied by host'): void { + if (!this.#pending.delete(clientId)) return; + try { + this.#ceremony.deny(pairingId); + } catch { + // already expired/resolved — deny is still what we report. + } + this.#send({ t: 'pair-result', clientId, approved: false, error }); + this.#dismissApproval(clientId); + } + + #onConnect(clientId: string): void { + const { challenge, expiresAt } = this.#challenges.issue(); + this.#send({ t: 'challenge', clientId, challenge, expiresAt }); + } + + async #onConnect2(clientId: string, request: ConnectionRequest): Promise { + const decision = await authorizeConnection( + { + hostId: this.#enrollment.hostId, + acl: this.#acl, + challenges: this.#challenges, + policy: this.#policy, + }, + request, + ); + if (decision.allowed) this.#established.add(clientId); + // `failures` is optional on the wire; omit it on an allowed decision. + this.#send({ + t: 'decision', + clientId, + allowed: decision.allowed, + ...(decision.allowed ? {} : { failures: decision.failures }), + }); + } + + #onMsg(clientId: string, data: unknown): void { + if (!this.#established.has(clientId)) return; // never before an allowed decision + let session = this.#sessions.get(clientId); + if (!session) { + if (!this.#createSession) return; + session = this.#createSession({ + hostId: this.#enrollment.hostId, + send: (payload) => this.#send({ t: 'msg', clientId, data: payload }), + }); + this.#sessions.set(clientId, session); + } + session.handle(data); + } + + #onClientGone(clientId: string): void { + this.#established.delete(clientId); + this.#pending.delete(clientId); + this.#sessions.get(clientId)?.dispose(); + this.#sessions.delete(clientId); + this.#dismissApproval(clientId); + } +} From b49ec26715e2d2b622ed2ebbcf78c58318ca81e0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 15:00:13 -0700 Subject: [PATCH 11/52] feat(lib,server): Pocket web app and static serving (slice 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone-side Pocket app (built from `lib`, served statically by `server`), completing the selfhost remote-control POC: sign in / first-time passkey setup, a persisted IndexedDB device key, pair-then-connect against a host, pick a live terminal pane, and view + type into it over the remote-api v1 relay. - lib/src/pocket: `webauthn.ts` (navigator.credentials wrappers), `device-key.ts` (non-extractable CryptoKeys in IndexedDB, injectable store), `pocket-client.ts` (UI-free protocol client driving the exact register→signin→pair→connect→msg flow with injected fetch/webauthn/WebSocket), plus `App.tsx`/`main.tsx` and a self-contained dark mobile UI (xterm + fit addon terminal view). - Pocket build: `vite.pocket.config.ts` + `pocket/index.html` → `dist-pocket/`, script `build:pocket`; the main build is untouched. - server: serves `pocketDir` statically at `/*` (SPA fallback to index.html), new optional `pocketDir` config + `DORMOUSE_POCKET_DIR` env defaulting to `lib/dist-pocket`; API and `/ws` routes keep precedence, missing build → a stub naming the build command. - Tests: vitest for the protocol client (setup/signin, pair, connect allowed/denied, request/response + event routing by subId, injected-store device key); node:test for static serving + API precedence. Co-Authored-By: Claude --- .gitignore | 1 + lib/package.json | 2 + lib/pocket/index.html | 17 + lib/src/pocket/App.tsx | 403 +++++++++++++++++++++ lib/src/pocket/PocketTerminal.tsx | 162 +++++++++ lib/src/pocket/device-key.ts | 107 ++++++ lib/src/pocket/main.tsx | 12 + lib/src/pocket/pocket-client.test.ts | 368 +++++++++++++++++++ lib/src/pocket/pocket-client.ts | 521 +++++++++++++++++++++++++++ lib/src/pocket/pocket.css | 362 +++++++++++++++++++ lib/src/pocket/webauthn.ts | 100 +++++ lib/tsconfig.node.json | 2 +- lib/vite.pocket.config.ts | 20 + package.json | 1 + server/src/app.ts | 43 ++- server/src/index.ts | 11 +- server/test/app.test.mjs | 8 +- server/test/static.test.mjs | 65 ++++ 18 files changed, 2198 insertions(+), 7 deletions(-) create mode 100644 lib/pocket/index.html create mode 100644 lib/src/pocket/App.tsx create mode 100644 lib/src/pocket/PocketTerminal.tsx create mode 100644 lib/src/pocket/device-key.ts create mode 100644 lib/src/pocket/main.tsx create mode 100644 lib/src/pocket/pocket-client.test.ts create mode 100644 lib/src/pocket/pocket-client.ts create mode 100644 lib/src/pocket/pocket.css create mode 100644 lib/src/pocket/webauthn.ts create mode 100644 lib/vite.pocket.config.ts create mode 100644 server/test/static.test.mjs diff --git a/.gitignore b/.gitignore index 1ed41e5f..2dabb1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ dist/ dor/src/generated-version.ts lib/dist/ +lib/dist-pocket/ *.tsbuildinfo # Vite cache diff --git a/lib/package.json b/lib/package.json index 5c99ef51..533c034b 100644 --- a/lib/package.json +++ b/lib/package.json @@ -6,7 +6,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev:pocket": "vite --config vite.pocket.config.ts", "build": "tsc -b && vite build", + "build:pocket": "vite build --config vite.pocket.config.ts", "preview": "vite preview", "pretest": "pnpm --filter dor-lib-common build && pnpm --filter server-lib-common build", "test": "vitest run", diff --git a/lib/pocket/index.html b/lib/pocket/index.html new file mode 100644 index 00000000..81813798 --- /dev/null +++ b/lib/pocket/index.html @@ -0,0 +1,17 @@ + + + + + + + + Dormouse Pocket + + +
+ + + diff --git a/lib/src/pocket/App.tsx b/lib/src/pocket/App.tsx new file mode 100644 index 00000000..29e472fa --- /dev/null +++ b/lib/src/pocket/App.tsx @@ -0,0 +1,403 @@ +/** + * Dormouse Pocket — the phone-side app (docs/specs/server.md "Pocket side"). + * + * A tiny four-view flow over {@link PocketClient}: sign in (or first-time + * passkey setup) → pick a host (pair once, then connect) → pick a pane from the + * live directory → drive it in the terminal view. All protocol work lives in + * the client; this file is just state + buttons. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { DirectoryEntry } from 'server-lib-common'; +import { + PocketClient, + type ConnectDecision, + type PocketSocket, +} from './pocket-client'; +import { browserWebAuthn } from './webauthn'; +import { getOrCreateDeviceKey } from './device-key'; +import { PocketTerminal } from './PocketTerminal'; +import './pocket.css'; + +type Phase = 'auth' | 'hosts' | 'picker' | 'terminal'; + +interface HostView { + hostId: string; + label: string; + online: boolean; +} + +/** A phone-friendly default pairing label. */ +const DEVICE_LABEL = 'Dormouse Pocket'; + +export default function App(): React.ReactElement { + const client = useMemo( + () => + new PocketClient({ + wsBase: location.origin.replace(/^http/, 'ws'), + fetch: window.fetch.bind(window), + webauthn: browserWebAuthn, + createWebSocket: (url) => new WebSocket(url) as unknown as PocketSocket, + deviceKey: () => getOrCreateDeviceKey(), + }), + [], + ); + + const [phase, setPhase] = useState('auth'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [hosts, setHosts] = useState([]); + const [activeHost, setActiveHost] = useState(null); + const [entries, setEntries] = useState([]); + const [surface, setSurface] = useState<{ surfaceId: string; title: string } | null>(null); + const socketOpened = useRef(false); + + const run = useCallback(async (label: string, fn: () => Promise) => { + setError(null); + setBusy(label); + try { + await fn(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + }, []); + + const loadHosts = useCallback(async () => { + if (!socketOpened.current) { + await client.openSocket(); + socketOpened.current = true; + } + setHosts(await client.listHosts()); + setPhase('hosts'); + }, [client]); + + useEffect(() => { + client.setOnHostGone(() => { + setError('The host disconnected.'); + setSurface(null); + setEntries([]); + setPhase('hosts'); + }); + return () => client.setOnHostGone(null); + }, [client]); + + const onConnect = (host: HostView) => + run('connect', async () => { + const decision: ConnectDecision = await client.connect(host.hostId); + if (!decision.allowed) { + throw new Error(`Connection denied${decision.failures ? `: ${decision.failures.join(', ')}` : ''}`); + } + await client.hello(); + await client.watchDirectory(setEntries); + setActiveHost(host); + setPhase('picker'); + }); + + const onPair = (host: HostView) => + run('pair', async () => { + const result = await client.pair(host.hostId, DEVICE_LABEL); + if (!result.approved) throw new Error(result.error ?? 'Pairing was denied.'); + setHosts((prev) => [...prev]); // reflect the new paired state + }); + + // --- Views --------------------------------------------------------------- + + if (phase === 'auth') { + return ( + run('signin', async () => { + await client.signin(); + await loadHosts(); + })} + onSetup={(password, label) => + run('setup', async () => { + await client.setup(password, label); + await client.signin(); + await loadHosts(); + })} + /> + ); + } + + if (phase === 'hosts') { + return ( + client.isPaired(id)} + onRefresh={() => run('refresh', loadHosts)} + onPair={onPair} + onConnect={onConnect} + /> + ); + } + + if (phase === 'picker' && activeHost) { + return ( + { + setEntries([]); + setActiveHost(null); + setPhase('hosts'); + }} + onPick={(entry) => { + setSurface({ surfaceId: entry.surfaceId, title: entry.title }); + setPhase('terminal'); + }} + /> + ); + } + + if (phase === 'terminal' && surface) { + return ( +
+
+ +

{surface.title || 'Terminal'}

+
+ { + setSurface(null); + setPhase('picker'); + }} + /> +
+ ); + } + + return
; +} + +// --- SetupOrSignin --------------------------------------------------------- + +function SetupOrSignin({ + busy, + error, + onSignin, + onSetup, +}: { + busy: string | null; + error: string | null; + onSignin: () => void; + onSetup: (password: string, label: string) => void; +}): React.ReactElement { + const [showSetup, setShowSetup] = useState(false); + const [password, setPassword] = useState(''); + const [label, setLabel] = useState('My Phone'); + + return ( +
+
+

Dormouse Pocket

+
+
+
+

Welcome back

+

+ Sign in with your passkey to reach your enrolled hosts and pick up a terminal session. +

+
+ {error ?
{error}
: null} + + + + + {showSetup ? ( +
+

+ Create the account and register this device's passkey. Requires the server's setup + password. +

+
+ + setPassword(e.target.value)} + /> +
+
+ + setLabel(e.target.value)} + /> +
+ +
+ ) : null} +
+
+ ); +} + +// --- HostsView ------------------------------------------------------------- + +function HostsView({ + hosts, + busy, + error, + isPaired, + onRefresh, + onPair, + onConnect, +}: { + hosts: HostView[]; + busy: string | null; + error: string | null; + isPaired: (hostId: string) => boolean; + onRefresh: () => void; + onPair: (host: HostView) => void; + onConnect: (host: HostView) => void; +}): React.ReactElement { + return ( +
+
+

Hosts

+ +
+
+ {error ?
{error}
: null} + {hosts.length === 0 ? ( +
No hosts enrolled yet. Enroll one from your laptop.
+ ) : ( + hosts.map((host) => { + const paired = isPaired(host.hostId); + return ( +
+
+
{host.label || host.hostId}
+
{paired ? 'Paired' : 'Not paired'}
+
+ + {host.online ? 'online' : 'offline'} + +
+ {!paired ? ( + + ) : null} + +
+
+ ); + }) + )} +
+
+ ); +} + +// --- PickerView ------------------------------------------------------------ + +function PickerView({ + host, + entries, + error, + onBack, + onPick, +}: { + host: HostView; + entries: DirectoryEntry[]; + error: string | null; + onBack: () => void; + onPick: (entry: DirectoryEntry) => void; +}): React.ReactElement { + return ( +
+
+ +

+ {host.label || host.hostId} panes +

+
+
+ {error ?
{error}
: null} + {entries.length === 0 ? ( +
Waiting for panes…
+ ) : ( + entries.map((entry) => ( + + )) + )} +
+
+ ); +} diff --git a/lib/src/pocket/PocketTerminal.tsx b/lib/src/pocket/PocketTerminal.tsx new file mode 100644 index 00000000..5302d754 --- /dev/null +++ b/lib/src/pocket/PocketTerminal.tsx @@ -0,0 +1,162 @@ +/** + * The Pocket terminal view: a live xterm rendering of a remote Host pane. + * + * Attach-is-the-resize (remote-api.md): on mount we fit xterm to the phone and + * `surface.attach` with those `cols`/`rows`, which resizes the Host PTY and + * makes it repaint into our screen from the live stream — no snapshot transfer. + * Output (`terminal.data`) is base64url PTY bytes we `write` straight into + * xterm; keystrokes go back as `terminal.write`; a phone rotate/resize re-fits + * and re-sends `terminal.resize`. + */ + +import { useEffect, useRef, useState } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { fromBase64Url, toBase64Url, utf8Encode } from 'server-lib-common'; +import '@xterm/xterm/css/xterm.css'; +import type { PocketClient } from './pocket-client'; + +const TERMINAL_THEME = { + background: '#000000', + foreground: '#e6e8ec', + cursor: '#4a9eff', + selectionBackground: '#264f7860', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff', +}; + +/** Control sequences for the on-screen key bar. */ +const KEYS: Array<{ label: string; seq: string; wide?: boolean }> = [ + { label: 'esc', seq: '\x1b' }, + { label: 'tab', seq: '\x09' }, + { label: '^C', seq: '\x03' }, + { label: '↑', seq: '\x1b[A' }, + { label: '↓', seq: '\x1b[B' }, + { label: '←', seq: '\x1b[D' }, + { label: '→', seq: '\x1b[C' }, + { label: 'enter', seq: '\r', wide: true }, +]; + +export interface PocketTerminalProps { + client: PocketClient; + surfaceId: string; + onBack: () => void; +} + +export function PocketTerminal({ client, surfaceId, onBack }: PocketTerminalProps): React.ReactElement { + const hostRef = useRef(null); + const termRef = useRef(null); + const [closed, setClosed] = useState<{ exitCode?: number } | null>(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + const term = new Terminal({ + fontSize: 12, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + cursorBlink: true, + theme: TERMINAL_THEME, + scrollback: 2000, + }); + termRef.current = term; + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(host); + fit.fit(); + + let disposed = false; + let subId: string | undefined; + + const inputSub = term.onData((data) => { + void client.write(surfaceId, toBase64Url(utf8Encode(data))); + }); + + client + .attach(surfaceId, term.cols, term.rows, { + onData: (bytes) => term.write(fromBase64Url(bytes)), + onResize: (cols, rows) => term.resize(cols, rows), + onClosed: (exitCode) => setClosed({ exitCode }), + }) + .then(({ subId: id, result }) => { + if (disposed) { + void client.detach(surfaceId, id); + return; + } + subId = id; + // Sync xterm to the authoritative PTY size the Host reports back. + if (result.cols > 0 && result.rows > 0 && (result.cols !== term.cols || result.rows !== term.rows)) { + term.resize(result.cols, result.rows); + } + }) + .catch(() => setClosed({ exitCode: undefined })); + + const onResize = () => { + if (disposed) return; + fit.fit(); + void client.resize(surfaceId, term.cols, term.rows); + }; + window.addEventListener('resize', onResize); + window.addEventListener('orientationchange', onResize); + + // Bring up the on-screen keyboard by focusing xterm's helper textarea. + term.focus(); + + return () => { + disposed = true; + window.removeEventListener('resize', onResize); + window.removeEventListener('orientationchange', onResize); + inputSub.dispose(); + void client.detach(surfaceId, subId); + term.dispose(); + termRef.current = null; + }; + }, [client, surfaceId]); + + const sendKey = (seq: string) => { + void client.write(surfaceId, toBase64Url(utf8Encode(seq))); + termRef.current?.focus(); + }; + + return ( + <> +
+
termRef.current?.focus()} /> + {closed ? ( +
+ session ended{closed.exitCode !== undefined ? ` (exit ${closed.exitCode})` : ''} +
+ ) : null} +
+
+ + {KEYS.map((key) => ( + + ))} +
+ + ); +} diff --git a/lib/src/pocket/device-key.ts b/lib/src/pocket/device-key.ts new file mode 100644 index 00000000..5fd8b708 --- /dev/null +++ b/lib/src/pocket/device-key.ts @@ -0,0 +1,107 @@ +/** + * The Pocket device key: a non-extractable ECDSA P-256 keypair that is this + * browser's long-lived Client identity (docs/specs/remote-security-model.md). + * The `CryptoKey` objects are persisted directly in IndexedDB — never exported + * — so the private key material never leaves the runtime, exactly as + * `generateDeviceKeyPair`'s contract intends. + * + * The store is injected into {@link getOrCreateDeviceKey} so its logic is + * unit-testable without IndexedDB (the browser default is + * {@link indexedDbDeviceKeyStore}). + */ + +import { generateDeviceKeyPair, type DeviceKeyPair } from 'server-lib-common'; + +/** Where a {@link DeviceKeyPair} is persisted; faked in tests. */ +export interface DeviceKeyStore { + get(): Promise; + put(key: DeviceKeyPair): Promise; +} + +const DB_NAME = 'dormouse-pocket'; +const STORE_NAME = 'device-key'; +const RECORD_KEY = 'default'; + +/** + * Return this device's keypair, generating and persisting one on first run. + * The private key is non-extractable; only its base64url public point + * (`devicePublicKey`) ever crosses the wire. + */ +export async function getOrCreateDeviceKey( + store: DeviceKeyStore = indexedDbDeviceKeyStore(), +): Promise { + const existing = await store.get(); + if (existing) return existing; + const created = await generateDeviceKeyPair(); + await store.put(created); + return created; +} + +/** A tiny one-object-store IndexedDB wrapper holding the `CryptoKey` objects. */ +export function indexedDbDeviceKeyStore(): DeviceKeyStore { + return { + async get() { + const db = await openDb(); + try { + const value = await promisifyRequest( + db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(RECORD_KEY), + ); + if (!value) return null; + return { + publicKey: value.publicKey, + privateKey: value.privateKey, + devicePublicKey: value.devicePublicKey, + }; + } finally { + db.close(); + } + }, + async put(key) { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const record: StoredDeviceKey = { + publicKey: key.publicKey as CryptoKey, + privateKey: key.privateKey as CryptoKey, + devicePublicKey: key.devicePublicKey, + }; + tx.objectStore(STORE_NAME).put(record, RECORD_KEY); + await promisifyTransaction(tx); + } finally { + db.close(); + } + }, + }; +} + +interface StoredDeviceKey { + readonly publicKey: CryptoKey; + readonly privateKey: CryptoKey; + readonly devicePublicKey: string; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('failed to open IndexedDB')); + }); +} + +function promisifyRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +function promisifyTransaction(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + }); +} diff --git a/lib/src/pocket/main.tsx b/lib/src/pocket/main.tsx new file mode 100644 index 00000000..dcb8f854 --- /dev/null +++ b/lib/src/pocket/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +const root = document.getElementById('pocket-root'); +if (!root) throw new Error('#pocket-root is missing'); + +createRoot(root).render( + + + , +); diff --git a/lib/src/pocket/pocket-client.test.ts b/lib/src/pocket/pocket-client.test.ts new file mode 100644 index 00000000..6ca5433e --- /dev/null +++ b/lib/src/pocket/pocket-client.test.ts @@ -0,0 +1,368 @@ +/** + * Pocket protocol-client coverage with faked fetch / WebAuthn / WebSocket, plus + * the injected-store device-key logic. Everything crypto-touching + * (`generateDeviceKeyPair`, `signDeviceChallenge`, `hashPasskeyPublicKey`) runs + * for real against Node's WebCrypto; only the browser/network edges are faked. + * + * (fake-indexeddb is not a dependency, so the IndexedDB round-trip itself is not + * exercised here — `getOrCreateDeviceKey` is tested through an injected store.) + */ + +import { describe, expect, it } from 'vitest'; +import { + SELFHOST_ACCOUNT_ID, + generateDeviceKeyPair, + hashPasskeyPublicKey, + toBase64Url, + type DeviceKeyPair, + type PasskeyAssertion, +} from 'server-lib-common'; + +import { + PocketClient, + type PocketSocket, + type PocketStorage, + type PocketClientDeps, +} from './pocket-client'; +import { getOrCreateDeviceKey, type DeviceKeyStore } from './device-key'; +import type { PasskeyRegistration, WebAuthnClient } from './webauthn'; + +// --- Fakes ----------------------------------------------------------------- + +const CREDENTIAL_ID = 'cred-123'; +const PASSKEY_PUBLIC_KEY = 'pk-spki-b64u'; +const RP_ID = 'localhost'; + +/** A base64url string usable as a real challenge (device signing decodes it). */ +function b64uChallenge(seed: number): string { + return toBase64Url(Uint8Array.from({ length: 32 }, (_, i) => (seed + i) & 0xff)); +} + +const assertion: PasskeyAssertion = { + credentialId: CREDENTIAL_ID, + clientDataJSON: 'client-data', + authenticatorData: 'auth-data', + signature: 'sig', +}; + +const fakeWebAuthn: WebAuthnClient = { + async registerPasskey(): Promise { + return { + credentialId: CREDENTIAL_ID, + publicKey: PASSKEY_PUBLIC_KEY, + clientDataJSON: 'create-client-data', + }; + }, + async getAssertion(): Promise { + return assertion; + }, +}; + +interface FetchCall { + url: string; + method: string; + headers: Record; + body: unknown; +} + +/** A router-style fake `fetch` that records every call. */ +function makeFetch(routes: Record { status?: number; json: unknown }>) { + const calls: FetchCall[] = []; + const fetch = (async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'POST'; + const headers = (init?.headers ?? {}) as Record; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, method, headers, body }); + const path = new URL(url, 'http://test').pathname; + const handler = routes[path]; + if (!handler) throw new Error(`unexpected fetch: ${path}`); + const { status = 200, json } = handler(body); + return { + ok: status >= 200 && status < 300, + status, + json: async () => json, + } as Response; + }) as unknown as typeof fetch; + return { fetch, calls }; +} + +function memoryStorage(): PocketStorage { + const passkeys = new Map(); + const paired = new Set(); + return { + getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, + setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), + isPaired: (hostId) => paired.has(hostId), + markPaired: (hostId) => void paired.add(hostId), + }; +} + +class FakeSocket implements PocketSocket { + readyState = 0; + readonly sent: Array> = []; + readonly #handlers = new Map void>>(); + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#handlers.get(type) ?? []; + list.push(handler); + this.#handlers.set(type, list); + } + + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + + close(): void { + this.readyState = 3; + this.#emit('close', { code: 1000 }); + } + + fireOpen(): void { + this.readyState = 1; + this.#emit('open', {}); + } + + /** Simulate the server sending a frame to this client. */ + server(frame: unknown): void { + this.#emit('message', { data: JSON.stringify(frame) }); + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } +} + +/** Poll `sent` for the first frame matching `predicate`. */ +async function nextSent( + socket: FakeSocket, + predicate: (frame: Record) => boolean, +): Promise> { + for (let i = 0; i < 200; i++) { + const found = socket.sent.find(predicate); + if (found) return found; + await new Promise((r) => setTimeout(r, 2)); + } + throw new Error('expected frame was never sent'); +} + +interface Harness { + client: PocketClient; + socket: FakeSocket; + calls: FetchCall[]; + device: () => Promise; +} + +function makeClient( + routes: Record { status?: number; json: unknown }>, + overrides: Partial = {}, +): Harness { + const socket = new FakeSocket(); + const { fetch, calls } = makeFetch(routes); + let devicePair: DeviceKeyPair | undefined; + const device = async () => (devicePair ??= await generateDeviceKeyPair()); + const client = new PocketClient({ + wsBase: 'ws://test', + fetch, + webauthn: fakeWebAuthn, + createWebSocket: () => socket, + deviceKey: device, + storage: memoryStorage(), + ...overrides, + }); + return { client, socket, calls, device }; +} + +const AUTH_ROUTES = { + '/api/setup/begin': () => ({ + json: { challenge: b64uChallenge(1), rpId: RP_ID, accountId: SELFHOST_ACCOUNT_ID }, + }), + '/api/setup/finish': () => ({ + json: { accountId: SELFHOST_ACCOUNT_ID, credentialId: CREDENTIAL_ID }, + }), + '/api/signin/begin': () => ({ json: { challenge: b64uChallenge(9), rpId: RP_ID } }), + '/api/signin/finish': () => ({ + json: { sessionToken: 'tok-abc', accountId: SELFHOST_ACCOUNT_ID, expiresAt: 1 }, + }), + '/api/hosts': () => ({ json: { hosts: [{ hostId: 'h1', label: 'Laptop', online: true }] } }), +} as const; + +/** Setup + sign-in + open the relay socket, ready for pair/connect. */ +async function signedIn(overrides: Partial = {}): Promise { + const harness = makeClient({ ...AUTH_ROUTES }, overrides); + await harness.client.setup('pw', 'My Phone'); + await harness.client.signin(); + const open = harness.client.openSocket(); + harness.socket.fireOpen(); + await open; + return harness; +} + +// --- Tests ----------------------------------------------------------------- + +describe('setup + signin', () => { + it('registers, signs in, keeps the token, and sends it as a bearer', async () => { + const harness = makeClient({ ...AUTH_ROUTES }); + const setup = await harness.client.setup('pw', 'My Phone'); + expect(setup.credentialId).toBe(CREDENTIAL_ID); + + const signin = await harness.client.signin(); + expect(signin.sessionToken).toBe('tok-abc'); + expect(harness.client.sessionToken).toBe('tok-abc'); + + const hosts = await harness.client.listHosts(); + expect(hosts).toEqual([{ hostId: 'h1', label: 'Laptop', online: true }]); + const hostsCall = harness.calls.find((c) => c.url.endsWith('/api/hosts'))!; + expect(hostsCall.method).toBe('GET'); + expect(hostsCall.headers.authorization).toBe('Bearer tok-abc'); + }); + + it('rejects with the server error message on a failed request', async () => { + const harness = makeClient({ + '/api/setup/begin': () => ({ status: 401, json: { error: 'invalid setup password' } }), + }); + await expect(harness.client.setup('wrong', 'Phone')).rejects.toThrow('invalid setup password'); + }); +}); + +describe('pair', () => { + it('sends a well-formed pairing frame and resolves on pair-result', async () => { + const { client, socket } = await signedIn(); + const pairing = client.pair('h1', 'iPhone Safari'); + + const frame = await nextSent(socket, (f) => f.t === 'pair'); + expect(frame.hostId).toBe('h1'); + const request = frame.request as Record; + expect(request.accountId).toBe(SELFHOST_ACCOUNT_ID); + expect(request.passkeyCredentialId).toBe(CREDENTIAL_ID); + expect(request.passkeyPublicKeyHash).toBe(await hashPasskeyPublicKey(PASSKEY_PUBLIC_KEY)); + expect(request.requestedLabel).toBe('iPhone Safari'); + expect(typeof request.devicePublicKey).toBe('string'); + + const record = { hostId: 'h1', label: 'iPhone Safari' }; + socket.server({ t: 'pair-result', approved: true, record }); + const result = await pairing; + expect(result.approved).toBe(true); + expect(result.record).toEqual(record); + expect(client.isPaired('h1')).toBe(true); + }); + + it('surfaces a denial and does not mark the host paired', async () => { + const { client, socket } = await signedIn(); + const pairing = client.pair('h1', 'iPhone'); + await nextSent(socket, (f) => f.t === 'pair'); + socket.server({ t: 'pair-result', approved: false, error: 'denied by host' }); + const result = await pairing; + expect(result.approved).toBe(false); + expect(result.error).toBe('denied by host'); + expect(client.isPaired('h1')).toBe(false); + }); +}); + +describe('connect', () => { + it('challenge → one assertion + device signature → connect2 → allowed', async () => { + const { client, socket, device } = await signedIn(); + const connecting = client.connect('h1'); + + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + + const connect2 = await nextSent(socket, (f) => f.t === 'connect2'); + const request = connect2.request as Record; + expect(request.accountId).toBe(SELFHOST_ACCOUNT_ID); + expect(request.challenge).toBe(b64uChallenge(7)); + expect(request.devicePublicKey).toBe((await device()).devicePublicKey); + expect(typeof request.deviceSignature).toBe('string'); + expect((request.passkey as Record).publicKey).toBe(PASSKEY_PUBLIC_KEY); + expect((request.passkey as { assertion: PasskeyAssertion }).assertion.credentialId).toBe( + CREDENTIAL_ID, + ); + + socket.server({ t: 'decision', allowed: true }); + const decision = await connecting; + expect(decision.allowed).toBe(true); + expect(client.connectedHostId).toBe('h1'); + }); + + it('resolves not-allowed with failures on a denied decision', async () => { + const { client, socket } = await signedIn(); + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(3), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: false, failures: ['device-not-paired'] }); + const decision = await connecting; + expect(decision.allowed).toBe(false); + expect(decision.failures).toEqual(['device-not-paired']); + expect(client.connectedHostId).toBeNull(); + }); +}); + +describe('remote-api correlation', () => { + it('resolves a request by requestId', async () => { + const { client, socket } = await signedIn(); + const helloing = client.hello(); + const frame = await nextSent(socket, (f) => f.t === 'msg'); + const data = frame.data as { requestId: string; method: string }; + expect(data.method).toBe('hello'); + socket.server({ + t: 'msg', + data: { requestId: data.requestId, ok: true, result: { protocolVersion: 1, hostId: 'h1' } }, + }); + const result = await helloing; + expect(result.hostId).toBe('h1'); + }); + + it('rejects a request when the response is ok:false', async () => { + const { client, socket } = await signedIn(); + const req = client.request('bogus'); + const frame = await nextSent(socket, (f) => f.t === 'msg'); + const data = frame.data as { requestId: string }; + socket.server({ t: 'msg', data: { requestId: data.requestId, ok: false, error: 'nope' } }); + await expect(req).rejects.toThrow('nope'); + }); + + it('routes events by subId, and only to the matching subscription', async () => { + const { client, socket } = await signedIn(); + const snapshots: unknown[] = []; + const watching = client.watchDirectory((entries) => snapshots.push(entries)); + + const frame = await nextSent(socket, (f) => f.t === 'msg'); + const data = frame.data as { requestId: string; method: string }; + expect(data.method).toBe('directory.watch'); + // Host convention: the subId is the request's own requestId. + socket.server({ t: 'msg', data: { requestId: data.requestId, ok: true, result: { subId: data.requestId } } }); + const subId = await watching; + expect(subId).toBe(data.requestId); + + // A snapshot for our subId is delivered... + socket.server({ + t: 'msg', + data: { subId, event: 'directory.snapshot', data: { entries: [{ title: 'zsh' }] } }, + }); + // ...one for an unrelated subId is not. + socket.server({ + t: 'msg', + data: { subId: 'other', event: 'directory.snapshot', data: { entries: [{ title: 'nope' }] } }, + }); + expect(snapshots).toEqual([[{ title: 'zsh' }]]); + }); +}); + +describe('getOrCreateDeviceKey (injected store)', () => { + it('generates and persists on first call, then reuses', async () => { + let stored: DeviceKeyPair | null = null; + let puts = 0; + const store: DeviceKeyStore = { + get: async () => stored, + put: async (key) => { + stored = key; + puts++; + }, + }; + const first = await getOrCreateDeviceKey(store); + expect(puts).toBe(1); + const second = await getOrCreateDeviceKey(store); + expect(puts).toBe(1); + expect(second.devicePublicKey).toBe(first.devicePublicKey); + }); +}); diff --git a/lib/src/pocket/pocket-client.ts b/lib/src/pocket/pocket-client.ts new file mode 100644 index 00000000..8ad2953e --- /dev/null +++ b/lib/src/pocket/pocket-client.ts @@ -0,0 +1,521 @@ +/** + * The Pocket protocol client: a UI-free driver of the exact client flow the + * server's `handshake.test.mjs` exercises (register → signin → pair → connect → + * challenge → connect2 → msg), but with real `navigator.credentials` and a real + * IndexedDB device key instead of the simulated harness. + * + * Everything external is injected — `fetch`, the {@link WebAuthnClient}, the + * WebSocket factory, the device key, and localStorage-backed {@link PocketStorage} + * — so vitest can fake all of it (`pocket-client.test.ts`). + * + * Correlation follows the Host's conventions (see `remote/host/remote-api.ts`): + * a `msg` request is matched by `requestId`; events are matched by `subId`, and + * for `directory.watch` / `surface.attach` the Host reuses the request's + * `requestId` as that `subId`, so this client sends those two with + * `requestId === subId`. + */ + +import { + API_ROUTES, + REMOTE_EVENTS, + REMOTE_METHODS, + SELFHOST_ACCOUNT_ID, + WS_ROUTES, + WS_TOKEN_PARAM, + hashPasskeyPublicKey, + signDeviceChallenge, + type ClientFrame, + type ConnectionFailure, + type ConnectionRequest, + type DeviceKeyPair, + type DirectoryEntry, + type DirectorySnapshot, + type HelloResult, + type HostAclRecord, + type HostsResponse, + type PairingRequest, + type RemoteEventMsg, + type RemoteResponse, + type ServerToClientFrame, + type SetupBeginResponse, + type SetupFinishResponse, + type SigninBeginResponse, + type SigninFinishResponse, + type TerminalAttachResult, + type TerminalClosedEvent, + type TerminalDataEvent, + type TerminalResizeEvent, +} from 'server-lib-common'; +import type { WebAuthnClient } from './webauthn'; + +/** The slice of a WebSocket the client uses; a browser `WebSocket` satisfies it. */ +export interface PocketSocket { + send(data: string): void; + close(): void; + addEventListener( + type: 'open' | 'message' | 'close' | 'error', + handler: (ev: unknown) => void, + ): void; + readyState: number; +} + +/** + * Persistent per-device state. Passkey public keys are stashed at registration + * keyed by credential id, because the wire never returns a passkey's public key + * on sign-in — so pairing/connecting can only build its request on the device + * that created the passkey (a documented POC limitation). + */ +export interface PocketStorage { + getPasskeyPublicKey(credentialId: string): string | null; + setPasskeyPublicKey(credentialId: string, publicKey: string): void; + isPaired(hostId: string): boolean; + markPaired(hostId: string): void; +} + +export interface PocketClientDeps { + /** Prepended to API routes; `''` for same-origin (the served app). */ + readonly baseUrl?: string; + /** Base for the `/ws/client` URL, e.g. `wss://host`; derived from origin in the app. */ + readonly wsBase: string; + readonly fetch: typeof fetch; + readonly webauthn: WebAuthnClient; + readonly createWebSocket: (url: string) => PocketSocket; + /** This device's key; memoized after the first call. */ + readonly deviceKey: () => Promise; + readonly storage?: PocketStorage; +} + +/** Terminal stream callbacks for {@link PocketClient.attach}. */ +export interface TerminalHandlers { + /** Base64url PTY output bytes. */ + onData(bytes: string): void; + onResize?(cols: number, rows: number): void; + onClosed?(exitCode?: number): void; +} + +export interface ConnectDecision { + readonly allowed: boolean; + readonly failures?: readonly ConnectionFailure[]; +} + +export interface PairResult { + readonly approved: boolean; + readonly record?: HostAclRecord; + readonly error?: string; +} + +interface Waiter { + resolve(frame: ServerToClientFrame): void; + reject(error: Error): void; +} + +interface PendingRequest { + resolve(result: unknown): void; + reject(error: Error): void; +} + +export class PocketClient { + readonly #baseUrl: string; + readonly #wsBase: string; + readonly #fetch: typeof fetch; + readonly #webauthn: WebAuthnClient; + readonly #createWebSocket: (url: string) => PocketSocket; + readonly #deviceKeyFactory: () => Promise; + readonly #storage: PocketStorage; + + #ws: PocketSocket | null = null; + #sessionToken: string | null = null; + #rpId: string | null = null; + /** The credential id from the most recent sign-in (or registration). */ + #credentialId: string | null = null; + #connectedHostId: string | null = null; + #deviceKey: DeviceKeyPair | null = null; + #onHostGone: (() => void) | null = null; + + /** Handshake frames (`pair-result`/`challenge`/`decision`) awaited FIFO by type. */ + readonly #waiters = new Map(); + /** In-flight remote-api requests, keyed by `requestId`. */ + readonly #pending = new Map(); + /** Live event subscriptions, keyed by `subId`. */ + readonly #events = new Map void>(); + + constructor(deps: PocketClientDeps) { + this.#baseUrl = deps.baseUrl ?? ''; + this.#wsBase = deps.wsBase; + this.#fetch = deps.fetch; + this.#webauthn = deps.webauthn; + this.#createWebSocket = deps.createWebSocket; + this.#deviceKeyFactory = deps.deviceKey; + this.#storage = deps.storage ?? localStoragePocketStorage(); + } + + get sessionToken(): string | null { + return this.#sessionToken; + } + + get connectedHostId(): string | null { + return this.#connectedHostId; + } + + isPaired(hostId: string): boolean { + return this.#storage.isPaired(hostId); + } + + /** Notified when the Host drops (a `host-gone` frame or a closed socket). */ + setOnHostGone(callback: (() => void) | null): void { + this.#onHostGone = callback; + } + + // --- Account: first-time setup + sign-in --------------------------------- + + /** First-time setup: password-gated passkey registration. Follow with {@link signin}. */ + async setup(password: string, label: string): Promise { + const begin = await this.#api(API_ROUTES.setupBegin, { password }); + this.#rpId = begin.rpId; + const registration = await this.#webauthn.registerPasskey( + begin.challenge, + begin.rpId, + begin.accountId, + ); + const finish = await this.#api(API_ROUTES.setupFinish, { + password, + credentialId: registration.credentialId, + publicKey: registration.publicKey, + clientDataJSON: registration.clientDataJSON, + label, + }); + // Stash the public key so this device can later build pairing/connect + // requests (the wire never hands it back on sign-in). + this.#storage.setPasskeyPublicKey(registration.credentialId, registration.publicKey); + this.#credentialId = registration.credentialId; + return finish; + } + + /** Sign in with a discoverable passkey; keeps the session token in memory. */ + async signin(): Promise { + const begin = await this.#api(API_ROUTES.signinBegin, {}); + this.#rpId = begin.rpId; + const assertion = await this.#webauthn.getAssertion(begin.challenge, begin.rpId); + const finish = await this.#api(API_ROUTES.signinFinish, { assertion }); + this.#sessionToken = finish.sessionToken; + this.#credentialId = assertion.credentialId; + return finish; + } + + async listHosts(): Promise { + const response = await this.#api( + API_ROUTES.hosts, + undefined, + { method: 'GET', headers: { authorization: `Bearer ${this.#requireToken()}` } }, + ); + return response.hosts; + } + + // --- Relay socket -------------------------------------------------------- + + /** Open the `/ws/client` relay socket; resolves once it is open. */ + openSocket(): Promise { + const token = this.#requireToken(); + const url = `${this.#wsBase}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${encodeURIComponent(token)}`; + const ws = this.#createWebSocket(url); + this.#ws = ws; + ws.addEventListener('message', (ev) => this.#onFrame((ev as { data?: unknown }).data)); + ws.addEventListener('close', () => this.#onClose()); + return new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', () => reject(new Error('relay socket error'))); + ws.addEventListener('close', () => reject(new Error('relay socket closed before open'))); + }); + } + + // --- Pairing + connect handshake ----------------------------------------- + + /** Send a pairing request built from this device's key + passkey; awaits the Host's decision. */ + async pair(hostId: string, label: string): Promise { + const { credentialId, publicKey } = this.#passkeyForRequest(); + const device = await this.#getDeviceKey(); + const request: PairingRequest = { + accountId: SELFHOST_ACCOUNT_ID, + passkeyCredentialId: credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(publicKey), + devicePublicKey: device.devicePublicKey, + requestedLabel: label, + }; + const awaited = this.#expect('pair-result'); + this.#send({ t: 'pair', hostId, request }); + const frame = (await awaited) as Extract; + if (frame.approved) this.#storage.markPaired(hostId); + return { approved: frame.approved, record: frame.record, error: frame.error }; + } + + /** + * Connect to a paired Host: request a challenge, then produce ONE passkey + * assertion + one device-key signature over it (one biometric prompt), send + * `connect2`, and await the Host's final decision. + */ + async connect(hostId: string): Promise { + const device = await this.#getDeviceKey(); + const challengeAwaited = this.#expect('challenge'); + this.#send({ t: 'connect', hostId }); + const challengeFrame = (await challengeAwaited) as Extract< + ServerToClientFrame, + { t: 'challenge' } + >; + const challenge = challengeFrame.challenge; + + const assertion = await this.#webauthn.getAssertion(challenge, this.#requireRpId()); + const deviceSignature = await signDeviceChallenge(device.privateKey, { + hostId, + challenge, + devicePublicKey: device.devicePublicKey, + }); + const publicKey = this.#storage.getPasskeyPublicKey(assertion.credentialId); + if (!publicKey) throw new Error(PASSKEY_UNAVAILABLE_MESSAGE); + + const request: ConnectionRequest = { + accountId: SELFHOST_ACCOUNT_ID, + devicePublicKey: device.devicePublicKey, + challenge, + deviceSignature, + passkey: { publicKey, assertion }, + }; + const decisionAwaited = this.#expect('decision'); + this.#send({ t: 'connect2', hostId, request }); + const decisionFrame = (await decisionAwaited) as Extract< + ServerToClientFrame, + { t: 'decision' } + >; + if (decisionFrame.allowed) this.#connectedHostId = hostId; + return { allowed: decisionFrame.allowed, failures: decisionFrame.failures }; + } + + // --- Remote-api v1 ------------------------------------------------------- + + hello(): Promise { + return this.request(REMOTE_METHODS.hello, { protocolVersion: 1, viewer: 'phone' }); + } + + /** Subscribe to the directory; returns the `subId` (call {@link unsubscribe} to stop). */ + async watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise { + const { subId } = await this.subscribe(REMOTE_METHODS.directoryWatch, {}, (event) => { + if (event.event === REMOTE_EVENTS.directorySnapshot) { + onSnapshot((event.data as DirectorySnapshot).entries); + } + }); + return subId; + } + + /** Attach to a terminal surface with the client's size; streams via {@link TerminalHandlers}. */ + attach( + surfaceId: string, + cols: number, + rows: number, + handlers: TerminalHandlers, + ): Promise<{ subId: string; result: TerminalAttachResult }> { + return this.subscribe( + REMOTE_METHODS.surfaceAttach, + { surfaceId, cols, rows }, + (event) => { + switch (event.event) { + case REMOTE_EVENTS.terminalData: + handlers.onData((event.data as TerminalDataEvent).bytes); + return; + case REMOTE_EVENTS.terminalResize: { + const data = event.data as TerminalResizeEvent; + handlers.onResize?.(data.cols, data.rows); + return; + } + case REMOTE_EVENTS.terminalClosed: + handlers.onClosed?.((event.data as TerminalClosedEvent).exitCode); + return; + default: + return; + } + }, + ); + } + + write(surfaceId: string, bytes: string): Promise { + return this.request(REMOTE_METHODS.terminalWrite, { surfaceId, bytes }); + } + + resize(surfaceId: string, cols: number, rows: number): Promise { + return this.request(REMOTE_METHODS.terminalResize, { surfaceId, cols, rows }); + } + + detach(surfaceId: string, subId?: string): Promise { + if (subId) this.unsubscribe(subId); + return this.request(REMOTE_METHODS.surfaceDetach, { surfaceId }); + } + + /** Correlated request over a `msg` frame; resolves with `result` or rejects on `ok:false`. */ + request(method: string, params?: unknown, requestId: string = uuid()): Promise { + const promise = new Promise((resolve, reject) => { + this.#pending.set(requestId, { resolve: resolve as (r: unknown) => void, reject }); + }); + this.#send({ t: 'msg', data: { requestId, method, params } }); + return promise; + } + + /** Request that also opens an event subscription (Host reuses `requestId` as `subId`). */ + async subscribe( + method: string, + params: unknown, + onEvent: (event: RemoteEventMsg) => void, + ): Promise<{ subId: string; result: T }> { + const subId = uuid(); + this.#events.set(subId, onEvent); + try { + const result = await this.request(method, params, subId); + return { subId, result }; + } catch (error) { + this.#events.delete(subId); + throw error; + } + } + + unsubscribe(subId: string): void { + this.#events.delete(subId); + } + + close(): void { + try { + this.#ws?.close(); + } catch { + // already closing + } + this.#ws = null; + } + + // --- Internals ----------------------------------------------------------- + + async #getDeviceKey(): Promise { + if (!this.#deviceKey) this.#deviceKey = await this.#deviceKeyFactory(); + return this.#deviceKey; + } + + #passkeyForRequest(): { credentialId: string; publicKey: string } { + const credentialId = this.#credentialId; + if (!credentialId) throw new Error('sign in before pairing or connecting'); + const publicKey = this.#storage.getPasskeyPublicKey(credentialId); + if (!publicKey) throw new Error(PASSKEY_UNAVAILABLE_MESSAGE); + return { credentialId, publicKey }; + } + + #send(frame: ClientFrame): void { + if (!this.#ws) throw new Error('relay socket is not open'); + this.#ws.send(JSON.stringify(frame)); + } + + #expect(type: 'pair-result' | 'challenge' | 'decision'): Promise { + return new Promise((resolve, reject) => { + const list = this.#waiters.get(type) ?? []; + list.push({ resolve, reject }); + this.#waiters.set(type, list); + }); + } + + #onFrame(raw: unknown): void { + let frame: ServerToClientFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : '') as ServerToClientFrame; + } catch { + return; + } + if (!frame || typeof (frame as { t?: unknown }).t !== 'string') return; + switch (frame.t) { + case 'pair-result': + case 'challenge': + case 'decision': { + const waiter = this.#waiters.get(frame.t)?.shift(); + waiter?.resolve(frame); + return; + } + case 'msg': + this.#onMsg(frame.data); + return; + case 'host-gone': + this.#onHostGone?.(); + this.#rejectAll(new Error('host disconnected')); + return; + case 'error': + this.#rejectAll(new Error(frame.error)); + return; + default: + return; + } + } + + #onMsg(data: unknown): void { + const response = data as RemoteResponse; + if (response && typeof response.requestId === 'string') { + const pending = this.#pending.get(response.requestId); + if (!pending) return; + this.#pending.delete(response.requestId); + if (response.ok) pending.resolve(response.result); + else pending.reject(new Error(response.error ?? 'request failed')); + return; + } + const event = data as RemoteEventMsg; + if (event && typeof event.subId === 'string') { + this.#events.get(event.subId)?.(event); + } + } + + #onClose(): void { + this.#connectedHostId = null; + this.#rejectAll(new Error('relay socket closed')); + } + + /** Fail every awaited handshake frame and in-flight request (avoids hangs). */ + #rejectAll(error: Error): void { + for (const list of this.#waiters.values()) for (const waiter of list) waiter.reject(error); + this.#waiters.clear(); + for (const pending of this.#pending.values()) pending.reject(error); + this.#pending.clear(); + } + + async #api(route: string, body?: unknown, init?: RequestInit): Promise { + const method = init?.method ?? 'POST'; + const response = await this.#fetch(`${this.#baseUrl}${route}`, { + method, + headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + ...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }), + }); + const parsed = (await response.json().catch(() => ({}))) as T & { error?: string }; + if (!response.ok) throw new Error(parsed.error ?? `request failed (${response.status})`); + return parsed; + } + + #requireToken(): string { + if (!this.#sessionToken) throw new Error('sign in first'); + return this.#sessionToken; + } + + #requireRpId(): string { + if (!this.#rpId) throw new Error('rpId unknown — begin a sign-in or setup first'); + return this.#rpId; + } +} + +export const PASSKEY_UNAVAILABLE_MESSAGE = + "This device does not hold the passkey's public key, so it cannot pair or connect. " + + 'Pair from the device that first created the passkey (POC limitation).'; + +function uuid(): string { + return globalThis.crypto.randomUUID(); +} + +/** localStorage-backed {@link PocketStorage}; touches storage only when called. */ +export function localStoragePocketStorage(): PocketStorage { + const PASSKEY_PREFIX = 'dormouse-pocket:passkey:'; + const PAIRED_PREFIX = 'dormouse-pocket:paired:'; + return { + getPasskeyPublicKey: (credentialId) => + globalThis.localStorage.getItem(PASSKEY_PREFIX + credentialId), + setPasskeyPublicKey: (credentialId, publicKey) => + globalThis.localStorage.setItem(PASSKEY_PREFIX + credentialId, publicKey), + isPaired: (hostId) => globalThis.localStorage.getItem(PAIRED_PREFIX + hostId) === '1', + markPaired: (hostId) => globalThis.localStorage.setItem(PAIRED_PREFIX + hostId, '1'), + }; +} diff --git a/lib/src/pocket/pocket.css b/lib/src/pocket/pocket.css new file mode 100644 index 00000000..fe4de906 --- /dev/null +++ b/lib/src/pocket/pocket.css @@ -0,0 +1,362 @@ +/* + * Self-contained dark theme for Dormouse Pocket. The Pocket app is served + * standalone (not inside VSCode / the wall), so it does not depend on the + * `--vscode-*` token system the main `lib` app uses; it just mirrors that + * dark, terminal-forward look with plain CSS variables. + */ + +:root { + --pk-bg: #16181d; + --pk-surface: #1e2128; + --pk-surface-raised: #262a33; + --pk-border: #333844; + --pk-fg: #e6e8ec; + --pk-muted: #9aa1ad; + --pk-accent: #4a9eff; + --pk-accent-fg: #0b1220; + --pk-danger: #f0616d; + --pk-ok: #3fb98a; + --pk-ring: #4a9eff; + --pk-font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--pk-bg); + color: var(--pk-fg); + font-family: var(--pk-font); + -webkit-text-size-adjust: 100%; + overscroll-behavior: none; +} + +#pocket-root { + height: 100dvh; + display: flex; + flex-direction: column; +} + +.pk-app { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +/* --- Header --- */ +.pk-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + padding-top: max(12px, env(safe-area-inset-top)); + background: var(--pk-surface); + border-bottom: 1px solid var(--pk-border); + flex: 0 0 auto; +} + +.pk-header h1 { + font-size: 15px; + font-weight: 600; + margin: 0; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pk-header .pk-sub { + font-size: 12px; + color: var(--pk-muted); + font-weight: 400; +} + +/* --- Scrolling content --- */ +.pk-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 16px 14px; + padding-bottom: max(16px, env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 14px; +} + +.pk-body.pk-center { + justify-content: center; +} + +/* --- Cards / list rows --- */ +.pk-card { + background: var(--pk-surface); + border: 1px solid var(--pk-border); + border-radius: 12px; + padding: 14px; +} + +.pk-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + background: var(--pk-surface); + border: 1px solid var(--pk-border); + border-radius: 12px; + padding: 14px; + color: inherit; + font: inherit; + cursor: pointer; +} + +.pk-row:active { + background: var(--pk-surface-raised); +} + +.pk-row-main { + flex: 1; + min-width: 0; +} + +.pk-row-title { + font-size: 15px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pk-row-secondary { + font-size: 12px; + color: var(--pk-muted); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pk-row-actions { + display: flex; + gap: 8px; + flex: 0 0 auto; +} + +/* --- Badges --- */ +.pk-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--pk-border); + color: var(--pk-muted); + white-space: nowrap; +} + +.pk-badge.online { + color: var(--pk-ok); + border-color: color-mix(in srgb, var(--pk-ok) 45%, transparent); +} + +.pk-badge.offline { + color: var(--pk-muted); +} + +.pk-badge.ringing { + color: var(--pk-danger); + border-color: color-mix(in srgb, var(--pk-danger) 45%, transparent); +} + +.pk-badge.todo { + color: var(--pk-accent); + border-color: color-mix(in srgb, var(--pk-accent) 45%, transparent); +} + +.pk-badge.activity { + text-transform: capitalize; +} + +/* --- Buttons --- */ +.pk-btn { + font: inherit; + font-size: 14px; + font-weight: 600; + border-radius: 10px; + border: 1px solid var(--pk-border); + background: var(--pk-surface-raised); + color: var(--pk-fg); + padding: 10px 14px; + cursor: pointer; + min-height: 42px; +} + +.pk-btn:active { + filter: brightness(1.15); +} + +.pk-btn:disabled { + opacity: 0.5; + pointer-events: none; +} + +.pk-btn.primary { + background: var(--pk-accent); + color: var(--pk-accent-fg); + border-color: transparent; +} + +.pk-btn.ghost { + background: transparent; +} + +.pk-btn.small { + padding: 8px 12px; + min-height: 36px; + font-size: 13px; +} + +.pk-btn.block { + width: 100%; +} + +/* --- Forms --- */ +.pk-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.pk-field label { + font-size: 12px; + color: var(--pk-muted); +} + +.pk-input { + font: inherit; + font-size: 16px; /* >=16px prevents iOS zoom-on-focus */ + background: var(--pk-bg); + color: var(--pk-fg); + border: 1px solid var(--pk-border); + border-radius: 10px; + padding: 12px; + width: 100%; +} + +.pk-input:focus { + outline: 2px solid var(--pk-ring); + outline-offset: -1px; +} + +/* --- Misc text --- */ +.pk-title { + font-size: 22px; + font-weight: 700; + margin: 0 0 4px; +} + +.pk-lead { + color: var(--pk-muted); + font-size: 13px; + line-height: 1.5; + margin: 0; +} + +.pk-error { + color: var(--pk-danger); + font-size: 13px; + background: color-mix(in srgb, var(--pk-danger) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--pk-danger) 40%, transparent); + border-radius: 10px; + padding: 10px 12px; +} + +.pk-empty { + color: var(--pk-muted); + font-size: 13px; + text-align: center; + padding: 32px 12px; +} + +.pk-disclosure { + background: transparent; + border: none; + color: var(--pk-accent); + font: inherit; + font-size: 13px; + padding: 8px 0; + cursor: pointer; + text-align: left; +} + +.pk-spinner { + color: var(--pk-muted); + font-size: 13px; +} + +/* --- Terminal view --- */ +.pk-term-wrap { + flex: 1 1 auto; + min-height: 0; + position: relative; + background: #000; + overflow: hidden; +} + +.pk-term-host { + position: absolute; + inset: 0; + padding: 4px; +} + +.pk-term-closed { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--pk-muted); + font-size: 13px; + background: color-mix(in srgb, #000 70%, transparent); +} + +.pk-keybar { + flex: 0 0 auto; + display: flex; + gap: 6px; + padding: 8px; + padding-bottom: max(8px, env(safe-area-inset-bottom)); + background: var(--pk-surface); + border-top: 1px solid var(--pk-border); + overflow-x: auto; +} + +.pk-key { + font: inherit; + font-size: 13px; + flex: 0 0 auto; + min-width: 44px; + min-height: 40px; + border-radius: 8px; + border: 1px solid var(--pk-border); + background: var(--pk-surface-raised); + color: var(--pk-fg); + cursor: pointer; + padding: 0 12px; +} + +.pk-key:active { + background: var(--pk-accent); + color: var(--pk-accent-fg); +} + +.pk-key.wide { + flex: 1 0 auto; +} diff --git a/lib/src/pocket/webauthn.ts b/lib/src/pocket/webauthn.ts new file mode 100644 index 00000000..93162018 --- /dev/null +++ b/lib/src/pocket/webauthn.ts @@ -0,0 +1,100 @@ +/** + * Thin wrappers around `navigator.credentials` for the Pocket client, isolated + * here so the protocol client (`pocket-client.ts`) can be driven with a fake in + * vitest — the real thing needs a browser + a physical authenticator. + * + * Registration returns exactly what `POST /api/setup/finish` wants + * (`{ credentialId, publicKey, clientDataJSON }`, all base64url); assertions + * return the wire {@link PasskeyAssertion} shape the server and Host both + * verify with `verifyPasskeyAssertion`. + */ + +import { fromBase64Url, toBase64Url, utf8Encode, type PasskeyAssertion } from 'server-lib-common'; + +/** The result of a passkey registration, ready for `POST /api/setup/finish`. */ +export interface PasskeyRegistration { + /** `PublicKeyCredential.id` — already base64url. */ + readonly credentialId: string; + /** Base64url SPKI from `response.getPublicKey()`. */ + readonly publicKey: string; + /** Base64url `response.clientDataJSON` (type `webauthn.create`). */ + readonly clientDataJSON: string; +} + +/** The two authenticator operations the Pocket client needs; faked in tests. */ +export interface WebAuthnClient { + registerPasskey(challenge: string, rpId: string, accountId: string): Promise; + getAssertion(challenge: string, rpId: string): Promise; +} + +/** + * Create a discoverable ES256 passkey. `attestation: 'none'` keeps the server + * dependency-free (it trusts the browser-provided SPKI key); `residentKey` + * and `userVerification` are `'preferred'` so it works on the widest range of + * authenticators while still preferring a resident, verified credential. + */ +/** + * Copy into a fresh `ArrayBuffer`-backed view. WebAuthn's `BufferSource` + * parameters demand `ArrayBuffer` (not `SharedArrayBuffer`), which the generic + * `Uint8Array` from the byte helpers does not satisfy under recent TS libs. + */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +async function registerPasskey( + challenge: string, + rpId: string, + accountId: string, +): Promise { + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: toBufferSource(fromBase64Url(challenge)), + rp: { id: rpId, name: 'Dormouse' }, + user: { + id: toBufferSource(utf8Encode(accountId)), + name: accountId, + displayName: accountId, + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' }, + attestation: 'none', + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error('passkey creation was cancelled'); + const response = credential.response as AuthenticatorAttestationResponse; + const spki = response.getPublicKey(); + if (!spki) throw new Error('authenticator did not return a public key (ES256 required)'); + return { + credentialId: credential.id, + publicKey: toBase64Url(new Uint8Array(spki)), + clientDataJSON: toBase64Url(new Uint8Array(response.clientDataJSON)), + }; +} + +/** + * Get an assertion from any of the account's discoverable passkeys (empty + * `allowCredentials`), bound to `challenge`. One call feeds both the sign-in + * and the connect handshakes, so the user sees a single biometric prompt. + */ +async function getAssertion(challenge: string, rpId: string): Promise { + const credential = (await navigator.credentials.get({ + publicKey: { + challenge: toBufferSource(fromBase64Url(challenge)), + rpId, + allowCredentials: [], + userVerification: 'preferred', + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error('passkey assertion was cancelled'); + const response = credential.response as AuthenticatorAssertionResponse; + return { + credentialId: credential.id, + clientDataJSON: toBase64Url(new Uint8Array(response.clientDataJSON)), + authenticatorData: toBase64Url(new Uint8Array(response.authenticatorData)), + signature: toBase64Url(new Uint8Array(response.signature)), + }; +} + +/** The real, browser-backed implementation. */ +export const browserWebAuthn: WebAuthnClient = { registerPasskey, getAssertion }; diff --git a/lib/tsconfig.node.json b/lib/tsconfig.node.json index 8e5b203b..405fa64c 100644 --- a/lib/tsconfig.node.json +++ b/lib/tsconfig.node.json @@ -14,5 +14,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vite.pocket.config.ts"] } diff --git a/lib/vite.pocket.config.ts b/lib/vite.pocket.config.ts new file mode 100644 index 00000000..385907d3 --- /dev/null +++ b/lib/vite.pocket.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Second entry, separate from the main `lib` app (index.html): the standalone +// Pocket phone web app. Its HTML lives in `pocket/index.html` and pulls in +// `src/pocket/main.tsx`; the build lands in `dist-pocket/` for the server to +// serve statically (docs/specs/server.md "Pocket side"). No Tailwind/VSCode +// theme plumbing — Pocket ships its own self-contained CSS. +export default defineConfig({ + plugins: [react()], + root: fileURLToPath(new URL("./pocket", import.meta.url)), + resolve: { + dedupe: ["react", "react-dom"], + }, + build: { + outDir: fileURLToPath(new URL("./dist-pocket", import.meta.url)), + emptyOutDir: true, + }, +}); diff --git a/package.json b/package.json index a15cb11f..c8c601fe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:standalone": "node scripts/free-dev-port.mjs && pnpm --filter dormouse-standalone tauri dev", "dev:standalone:ab": "pnpm --filter dormouse-standalone dev:agent-browser", "dev:server": "pnpm --filter server dev", + "dev:pocket-server": "pnpm --filter dormouse-lib build:pocket && pnpm --filter server build && pnpm --filter server start", "dev:website": "pnpm --filter dormouse-website dev", "build:vscode": "pnpm --filter dormouse-lib build && pnpm --filter dormouse build:frontend && pnpm --filter dormouse build", "build:standalone": "pnpm --filter dormouse-standalone tauri build", diff --git a/server/src/app.ts b/server/src/app.ts index d16910c9..139504df 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,11 +17,15 @@ */ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; import { Hono } from 'hono'; import type { Context, MiddlewareHandler } from 'hono'; import { createNodeWebSocket } from '@hono/node-ws'; import type { NodeWebSocket } from '@hono/node-ws'; +import { serveStatic } from '@hono/node-server/serve-static'; import { API_ROUTES, HELLO_ROUTE, @@ -64,6 +68,12 @@ export interface AppConfig { readonly origin: string; /** Directory holding `account.json`. */ readonly stateDir: string; + /** + * Directory of the built Pocket web app (`lib`'s `dist-pocket`). When it + * exists it is served statically at `/*`; otherwise `GET /` is a stub telling + * you how to build it. API and `/ws` routes always take precedence. + */ + readonly pocketDir?: string; /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ readonly now?: () => number; } @@ -155,9 +165,6 @@ export function createApp(config: AppConfig): CreatedApp { // adapter is created here and `injectWebSocket` is handed back to the caller. const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); - // GET / — stub landing page; slice 5 replaces this with the Pocket web app. - app.get('/', (c) => c.text('Dormouse selfhost server')); - // Shared greeting, kept from the skeleton so `lib` and `server` stay agreed. app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); @@ -375,9 +382,39 @@ export function createApp(config: AppConfig): CreatedApp { }), ); + // --- Static Pocket app: GET /* fallback, registered LAST so every API and + // /ws route above wins. Missing build → a stub with the build command. + registerPocketServing(app, config.pocketDir); + return { app, sessions, requireSession, hub, injectWebSocket }; } +/** Message shown at `GET /` when the Pocket app has not been built yet. */ +const POCKET_MISSING_MESSAGE = + 'Dormouse selfhost server. The Pocket web app is not built yet — run ' + + '`pnpm --filter dormouse-lib build:pocket` (or set DORMOUSE_POCKET_DIR).'; + +/** + * Serve the built Pocket app from `pocketDir` at `/*`, falling back to + * `index.html` for any non-file GET (the app is a single page). When the + * directory or its `index.html` is absent, keep the old stub at `GET /`. + */ +function registerPocketServing(app: Hono, pocketDir?: string): void { + const indexHtmlPath = pocketDir ? join(pocketDir, 'index.html') : null; + if (!pocketDir || !indexHtmlPath || !existsSync(indexHtmlPath)) { + app.get('/', (c) => c.text(POCKET_MISSING_MESSAGE)); + return; + } + // `serveStatic` joins its `root` onto the request path relative to cwd, so a + // path relative to cwd is the portable way to point it at an arbitrary dir. + const root = relative(process.cwd(), pocketDir) || '.'; + app.get('/*', serveStatic({ root })); + app.get('*', async (c) => { + const html = await readFile(indexHtmlPath, 'utf8').catch(() => null); + return html ? c.html(html) : c.notFound(); + }); +} + // --------------------------------------------------------------------------- // Helpers diff --git a/server/src/index.ts b/server/src/index.ts index a0fd864b..9ac080b4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,9 @@ * `app.ts` so the app itself stays testable without touching env or the network. */ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { serve } from '@hono/node-server'; import { createApp } from './app.js'; @@ -21,7 +24,13 @@ if (!setupPassword) { const origin = process.env.DORMOUSE_ORIGIN ?? `http://localhost:${port}`; const stateDir = process.env.DORMOUSE_STATE_DIR ?? './data'; -const { app, injectWebSocket } = createApp({ setupPassword, origin, stateDir }); +// Default to `lib/dist-pocket` resolved from this compiled file's location +// (server/dist/index.js → repo root two levels up), so it works regardless of +// the process's cwd. Override with DORMOUSE_POCKET_DIR. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pocketDir = process.env.DORMOUSE_POCKET_DIR ?? join(repoRoot, 'lib', 'dist-pocket'); + +const { app, injectWebSocket } = createApp({ setupPassword, origin, stateDir, pocketDir }); const server = serve({ fetch: app.fetch, port }, (info) => { console.log(`server listening on http://localhost:${info.port} (origin ${origin})`); diff --git a/server/test/app.test.mjs b/server/test/app.test.mjs index d10e6eca..3e75d67b 100644 --- a/server/test/app.test.mjs +++ b/server/test/app.test.mjs @@ -5,11 +5,15 @@ import { HELLO_ROUTE } from 'server-lib-common'; import { freshApp } from './helpers.mjs'; -test('GET / serves the stub landing page', async () => { +test('GET / serves the stub landing page when the Pocket app is not built', async () => { + // freshApp configures no `pocketDir`, so this is always the stub (slice 5 + // serves the real Pocket build here when `pocketDir` points at one). const { app } = await freshApp(); const res = await app.request('/'); assert.equal(res.status, 200); - assert.equal(await res.text(), 'Dormouse selfhost server'); + const body = await res.text(); + assert.match(body, /^Dormouse selfhost server/); + assert.match(body, /build:pocket/); }); test(`GET ${HELLO_ROUTE} returns the shared greeting`, async () => { diff --git a/server/test/static.test.mjs b/server/test/static.test.mjs new file mode 100644 index 00000000..07dd49fe --- /dev/null +++ b/server/test/static.test.mjs @@ -0,0 +1,65 @@ +/** + * Slice 5: the server serves the built Pocket app statically at `/*`, while the + * API and `/ws` routes keep precedence. The build itself is not needed here — + * a temp dir with an `index.html` stands in for `lib/dist-pocket`. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createApp } from '../dist/app.js'; + +const PASSWORD = 'correct horse battery staple'; +const ORIGIN = 'http://localhost:3000'; + +async function makePocketDir() { + const dir = await mkdtemp(join(tmpdir(), 'dormouse-pocket-')); + await writeFile(join(dir, 'index.html'), 'Pocket
'); + await writeFile(join(dir, 'app.js'), 'console.log("pocket");'); + return dir; +} + +function app(config = {}) { + return createApp({ setupPassword: PASSWORD, origin: ORIGIN, stateDir: config.stateDir ?? '.', ...config }); +} + +test('serves index.html at / when the Pocket build is present', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/'); + assert.equal(res.status, 200); + const body = await res.text(); + assert.match(body, /pocket-root/); +}); + +test('serves built asset files', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/app.js'); + assert.equal(res.status, 200); + assert.match(await res.text(), /console\.log/); +}); + +test('SPA fallback returns index.html for an unknown non-file path', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/some/deep/link'); + assert.equal(res.status, 200); + assert.match(await res.text(), /pocket-root/); +}); + +test('API routes still win over static serving', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + // No bearer token → the session-gated API route answers, not the static app. + const res = await hono.request('/api/hosts'); + assert.equal(res.status, 401); + const body = await res.json(); + assert.equal(body.error, 'unauthorized'); +}); + +test('falls back to the build-instructions stub when no Pocket build exists', async () => { + const { app: hono } = app({ pocketDir: join(tmpdir(), 'dormouse-nonexistent-pocket-dir') }); + const res = await hono.request('/'); + assert.equal(res.status, 200); + assert.match(await res.text(), /build:pocket/); +}); From 412bd59885a2692dab380e315c245a685bd825e9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 15:04:01 -0700 Subject: [PATCH 12/52] docs(specs): running-the-POC instructions after all five slices landed Verified end to end against the production entrypoint: server boots from env vars, serves the Pocket build, and a scripted phone (real WebAuthn assertions via the test authenticator) registered, signed in, paired with the fake host, passed the connect handshake, and exchanged remote-api messages over the bridged relay. Co-Authored-By: Claude Fable 5 --- docs/specs/server.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/specs/server.md b/docs/specs/server.md index 7f414bb5..e26c6256 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -214,3 +214,44 @@ Slices 1–3 are pure Node with full automated coverage; browsers only enter at slice 4. After slice 5 the POC is in daily-use territory, and everything after that (browser surfaces, in-flight replay, thumbnails, WebRTC) is already staged in remote-api.md as additive follow-ups. + +--- + +# Running the POC + +All five slices are implemented. To test end to end: + +**1. Server + Pocket** (one terminal): + +```sh +DORMOUSE_SETUP_PASSWORD=hunter2 pnpm dev:pocket-server +``` + +Builds the Pocket app (`lib/dist-pocket`) and the server, then serves both on +`:3000`. Other env vars per Configuration above; for a real phone set +`DORMOUSE_ORIGIN` to your TLS origin (e.g. via `tailscale serve`) — WebAuthn +needs a secure context, and only `localhost` is exempt. + +**2. Host** (the laptop being controlled): `pnpm dev:standalone`, then enroll +once from the devtools console of the standalone webview: + +```js +await window.dormouseRemoteHost.enroll('http://localhost:3000', 'hunter2', 'My Laptop') +``` + +Enrollment persists in localStorage; on later launches the host connects by +itself. (`status()` / `clearEnrollment()` on the same object.) For a headless +stand-in host instead: +`DORMOUSE_SETUP_PASSWORD=hunter2 node server/scripts/fake-host.mjs http://localhost:3000` +(auto-approves pairing; answers `hello` only — no real terminals). + +**3. Phone** (or any other browser profile): open the server origin → +First-time setup (password + label) creates the passkey and signs you in → +Hosts → **Pair** → approve in the modal on the laptop → **Connect** (one +biometric prompt) → pick a pane → type. + +POC limitations to know about: pair/connect only works from the browser that +registered the passkey (the passkey public key is stored client-side at +registration); clearing site data destroys the device key → re-pair, per the +security model; a dropped WebSocket sends you back to the Hosts view — +reconnect by tapping Connect again. From de385daff6bbdf9e6b21ab1131b37566f24feb6a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 15:25:13 -0700 Subject: [PATCH 13/52] fix(server): CORS on /api/* so the standalone webview can enroll The Host enrolls from the webview's own origin (the vite dev port), so the JSON POST triggers an OPTIONS preflight that previously 404'd and blocked the fetch. Permissive CORS is safe here: every endpoint is gated by the setup password or a bearer token and nothing rides on cookies. Verified with a live preflight against the production entrypoint; three regression tests added. Co-Authored-By: Claude Fable 5 --- server/src/app.ts | 7 ++++++ server/test/cors.test.mjs | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 server/test/cors.test.mjs diff --git a/server/src/app.ts b/server/src/app.ts index 139504df..349029a6 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -22,6 +22,7 @@ import { readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { Hono } from 'hono'; +import { cors } from 'hono/cors'; import type { Context, MiddlewareHandler } from 'hono'; import { createNodeWebSocket } from '@hono/node-ws'; import type { NodeWebSocket } from '@hono/node-ws'; @@ -165,6 +166,12 @@ export function createApp(config: AppConfig): CreatedApp { // adapter is created here and `injectWebSocket` is handed back to the caller. const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); + // The Host (standalone webview) and dev Pocket builds call the API from + // other origins, so preflights must succeed. Permissive CORS is safe here: + // every endpoint is gated by the setup password or a bearer token, and no + // cookies exist for a foreign origin to ride on. + app.use('/api/*', cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization'] })); + // Shared greeting, kept from the skeleton so `lib` and `server` stay agreed. app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); diff --git a/server/test/cors.test.mjs b/server/test/cors.test.mjs new file mode 100644 index 00000000..ee1e2934 --- /dev/null +++ b/server/test/cors.test.mjs @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { API_ROUTES } from 'server-lib-common'; +import { freshApp } from './helpers.mjs'; + +// The standalone webview (Host) enrolls from a different origin than the +// server, so the JSON POST triggers a CORS preflight. Without an OPTIONS +// handler the preflight 404s and the fetch never happens. +test('preflight for host enrollment succeeds cross-origin', async () => { + const { app } = await freshApp(); + const res = await app.request(API_ROUTES.hostEnroll, { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:1420', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type', + }, + }); + assert.equal(res.status, 204); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + assert.match(res.headers.get('access-control-allow-headers') ?? '', /content-type/i); +}); + +test('cross-origin API responses carry the allow-origin header', async () => { + const { app } = await freshApp(); + const res = await app.request(API_ROUTES.signinBegin, { + method: 'POST', + headers: { origin: 'http://localhost:1420', 'content-type': 'application/json' }, + body: '{}', + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); +}); + +test('bearer-authed routes allow the Authorization header in preflight', async () => { + const { app } = await freshApp(); + const res = await app.request(API_ROUTES.hosts, { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:1420', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'authorization', + }, + }); + assert.equal(res.status, 204); + assert.match(res.headers.get('access-control-allow-headers') ?? '', /authorization/i); +}); From b4d57850ba349605e0ceb760ed8d307f45ea8586 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 16:18:32 -0700 Subject: [PATCH 14/52] =?UTF-8?q?docs(specs):=20pocket=20app=20architectur?= =?UTF-8?q?e=20=E2=80=94=20the=20remote=20session=20is=20a=20platform=20ad?= =?UTF-8?q?apter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One lib-owned Pocket: auth screens + MobileTerminalUi/MobileWall on a RemotePtyAdapter, the same composition the website playground proves with FakePtyAdapter. Always served same-origin with its API (WebAuthn origin binding + Chrome PNA both demand it): selfhost Node server now, CloudFlare static + routed /api,/ws for SaaS later. Co-Authored-By: Claude Fable 5 --- docs/specs/pocket-app.md | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/specs/pocket-app.md diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md new file mode 100644 index 00000000..f75797b5 --- /dev/null +++ b/docs/specs/pocket-app.md @@ -0,0 +1,86 @@ +# Pocket App Architecture + +How the phone client (Dormouse Pocket) is structured and deployed. Companion +to [remote-api.md](./remote-api.md) (the protocol) and +[server.md](./server.md) (the selfhost server). + +# The seam: the remote session is a platform adapter + +`lib` renders every Dormouse surface through a `PlatformAdapter` +(`lib/src/lib/platform/types.ts`). The adapter's PTY core — `writePty`, +`resizePty`, `onPtyData`, `onPtyExit`, plus the `requestInit`/`onPtyList`/ +`onPtyReplay` resume path built for VS Code webview reloads — maps one-to-one +onto the remote-api v1 terminal protocol: + +| PlatformAdapter | remote-api | +| ------------------------ | --------------------------------------- | +| `onPtyList` | `directory.snapshot` | +| attach semantics | `surface.attach` (attach-is-the-resize) | +| `onPtyData` | `terminal.data` | +| `writePty` | `terminal.write` | +| `resizePty` | `terminal.resize` | +| `onPtyExit` | `terminal.closed` | + +So the Pocket app is NOT a bespoke terminal UI. It is: + +> auth screens + `MobileTerminalUi`/`MobileWall` + **`RemotePtyAdapter`** + +— the exact composition the website playground already proves out with +`FakePtyAdapter` (`website/src/components/PocketTerminalExperience.tsx`). +One mobile terminal experience in `lib`, three consumers: the website +playground (fake adapter), the real Pocket (remote adapter), and whatever +comes later. Everything not in the PTY core no-ops or is absent — the +interface is designed for capability degradation. + +Adapter-specific extras (the same pattern as `FakePtyAdapter`'s scenario +controls): the concrete `RemotePtyAdapter` exposes `setActivePane(id)` — the +v1 protocol allows one attachment per session, so pane switching is +detach → attach, and the attach repaint (resize) redraws the screen. Badges +for non-attached panes come from `directory.watch` without attaching. + +# Module layout + +``` +lib/src/remote/ + client/ the phone side + pocket-client.ts UI-free protocol client (auth, pair, connect, msg) + device-key.ts non-extractable device key in IndexedDB + webauthn.ts navigator.credentials wrappers + remote-adapter.ts RemotePtyAdapter (PlatformAdapter over pocket-client) + host/ the laptop side (enrollment, approval modal, ACL, bridge) + pocket-app/ the app shell: auth views + the mobile wall composition +``` + +The server (`server/`) stays the only dynamic code: accounts, relay, and +static serving of the built Pocket bundle. + +# Deployment: same-origin, always + +WebAuthn binds passkeys to the serving origin, and Chrome's Private Network +Access rules are progressively blocking public-site → private-network fetches. +Both point the same way: **the Pocket app is always served same-origin with +its API.** One lib-owned bundle, two deployments: + +* **Selfhost (now):** the Node server serves the bundle (`lib/dist-pocket`). + Selfhost auth never depends on dormouse.dev existing. +* **SaaS (later):** CloudFlare serves the static site and routes `/api/*` and + `/ws/*` to the dynamic backend (CloudFlare proxies WebSockets). The same + bundle mounts at the site origin; rpId is the site's. The dynamic surface + is two path prefixes — everything else stays static. + +The website keeps its playground and marketing pages fully static in both +worlds and shares all terminal UI through `lib`; it never duplicates Pocket +code. + +# Phases + +1. **RemotePtyAdapter + real Pocket UI** — move the protocol modules to + `remote/client/`, add the adapter, rebuild the Pocket entry as + auth views + `MobileTerminalUi`/`MobileWall`, delete the bespoke terminal + UI from the POC. Protocol and server untouched. The fake host harness + grows a synthetic directory + echo terminal so the adapter is testable + end-to-end without a real host. +2. **Dedupe the composition** — extract the thin wiring shared by the + website's `PocketTerminalExperience` and the Pocket shell so the two + cannot drift. +3. **CloudFlare routing** — deferred until SaaS; nothing in 1–2 needs rework. From 19895fd82352a2a1c2d5552544b0029a7bf74ee2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 16:30:38 -0700 Subject: [PATCH 15/52] refactor(lib): move pocket protocol modules to remote/client and add RemotePtyAdapter (phase 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the UI-free Pocket protocol modules (pocket-client, device-key, webauthn + test) from lib/src/pocket to lib/src/remote/client with git mv, and fix the POC UI's imports (App.tsx, PocketTerminal.tsx) — phase 1b replaces that shell. Add RemotePtyAdapter: a PlatformAdapter backed by a connected PocketClient so the mobile terminal UI can render a real remote Host over remote-api v1. directory.snapshot → onPtyList + getDirectoryEntries/subscribeDirectory; setActivePane drives the one-attachment-per-session detach→attach; terminal.data → onPtyData (base64url utf8 → string); writePty/resizePty reach only the attached pane; terminal.closed → onPtyExit. Everything outside the PTY core no-ops or is absent. Extend the fake-host harness with a synthetic directory + two echo shells so the adapter is testable end to end without a real Host, and add coverage: a lib vitest suite against a network-free fake client, and a server node --test that drives the real wire (SimAuthenticator + device key through the relay). Co-Authored-By: Claude --- lib/src/pocket/App.tsx | 6 +- lib/src/pocket/PocketTerminal.tsx | 2 +- .../{pocket => remote/client}/device-key.ts | 0 .../client}/pocket-client.test.ts | 0 .../client}/pocket-client.ts | 0 lib/src/remote/client/remote-adapter.test.ts | 238 +++++++++++ lib/src/remote/client/remote-adapter.ts | 370 ++++++++++++++++++ lib/src/{pocket => remote/client}/webauthn.ts | 0 server/test/handshake.test.mjs | 95 +++++ server/test/harness/fake-host.mjs | 154 +++++++- 10 files changed, 845 insertions(+), 20 deletions(-) rename lib/src/{pocket => remote/client}/device-key.ts (100%) rename lib/src/{pocket => remote/client}/pocket-client.test.ts (100%) rename lib/src/{pocket => remote/client}/pocket-client.ts (100%) create mode 100644 lib/src/remote/client/remote-adapter.test.ts create mode 100644 lib/src/remote/client/remote-adapter.ts rename lib/src/{pocket => remote/client}/webauthn.ts (100%) diff --git a/lib/src/pocket/App.tsx b/lib/src/pocket/App.tsx index 29e472fa..224b68fd 100644 --- a/lib/src/pocket/App.tsx +++ b/lib/src/pocket/App.tsx @@ -13,9 +13,9 @@ import { PocketClient, type ConnectDecision, type PocketSocket, -} from './pocket-client'; -import { browserWebAuthn } from './webauthn'; -import { getOrCreateDeviceKey } from './device-key'; +} from '../remote/client/pocket-client'; +import { browserWebAuthn } from '../remote/client/webauthn'; +import { getOrCreateDeviceKey } from '../remote/client/device-key'; import { PocketTerminal } from './PocketTerminal'; import './pocket.css'; diff --git a/lib/src/pocket/PocketTerminal.tsx b/lib/src/pocket/PocketTerminal.tsx index 5302d754..8284e4bf 100644 --- a/lib/src/pocket/PocketTerminal.tsx +++ b/lib/src/pocket/PocketTerminal.tsx @@ -14,7 +14,7 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { fromBase64Url, toBase64Url, utf8Encode } from 'server-lib-common'; import '@xterm/xterm/css/xterm.css'; -import type { PocketClient } from './pocket-client'; +import type { PocketClient } from '../remote/client/pocket-client'; const TERMINAL_THEME = { background: '#000000', diff --git a/lib/src/pocket/device-key.ts b/lib/src/remote/client/device-key.ts similarity index 100% rename from lib/src/pocket/device-key.ts rename to lib/src/remote/client/device-key.ts diff --git a/lib/src/pocket/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts similarity index 100% rename from lib/src/pocket/pocket-client.test.ts rename to lib/src/remote/client/pocket-client.test.ts diff --git a/lib/src/pocket/pocket-client.ts b/lib/src/remote/client/pocket-client.ts similarity index 100% rename from lib/src/pocket/pocket-client.ts rename to lib/src/remote/client/pocket-client.ts diff --git a/lib/src/remote/client/remote-adapter.test.ts b/lib/src/remote/client/remote-adapter.test.ts new file mode 100644 index 00000000..69ffec1a --- /dev/null +++ b/lib/src/remote/client/remote-adapter.test.ts @@ -0,0 +1,238 @@ +/** + * `RemotePtyAdapter` against a network-free fake {@link RemoteAdapterClient}: + * directory snapshots become `onPtyList` + `getDirectoryEntries`, `setActivePane` + * drives the one-attachment-per-session detach→attach dance, `terminal.data` + * round-trips to `onPtyData`, write/resize reach only the attached pane, + * `terminal.closed` fires `onPtyExit`, and `dispose` cleans up. + */ + +import { describe, expect, it } from 'vitest'; +import { toBase64Url, utf8Encode, type DirectoryEntry } from 'server-lib-common'; + +import { RemotePtyAdapter, type RemoteAdapterClient } from './remote-adapter'; +import type { TerminalHandlers } from './pocket-client'; +import type { PtyInfo } from '../../lib/platform/types'; + +interface AttachCall { + surfaceId: string; + cols: number; + rows: number; + handlers: TerminalHandlers; + subId: string; +} + +class FakeClient implements RemoteAdapterClient { + snapshotListener: ((entries: DirectoryEntry[]) => void) | null = null; + readonly directorySubId = 'dir-sub'; + readonly attaches: AttachCall[] = []; + readonly writes: Array<{ surfaceId: string; bytes: string }> = []; + readonly resizes: Array<{ surfaceId: string; cols: number; rows: number }> = []; + readonly detaches: Array<{ surfaceId: string; subId?: string }> = []; + readonly unsubscribes: string[] = []; + #attachCounter = 0; + + async watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise { + this.snapshotListener = onSnapshot; + return this.directorySubId; + } + + async attach( + surfaceId: string, + cols: number, + rows: number, + handlers: TerminalHandlers, + ): Promise<{ subId: string; result: { cols: number; rows: number } }> { + const subId = `attach-${++this.#attachCounter}`; + this.attaches.push({ surfaceId, cols, rows, handlers, subId }); + return { subId, result: { cols, rows } }; + } + + async write(surfaceId: string, bytes: string): Promise { + this.writes.push({ surfaceId, bytes }); + } + + async resize(surfaceId: string, cols: number, rows: number): Promise { + this.resizes.push({ surfaceId, cols, rows }); + } + + async detach(surfaceId: string, subId?: string): Promise { + this.detaches.push({ surfaceId, subId }); + } + + unsubscribe(subId: string): void { + this.unsubscribes.push(subId); + } + + // --- test drivers --- + pushSnapshot(entries: DirectoryEntry[]): void { + this.snapshotListener?.(entries); + } + + lastAttach(): AttachCall { + const call = this.attaches.at(-1); + if (!call) throw new Error('no attach recorded'); + return call; + } +} + +function entry(surfaceId: string, over: Partial = {}): DirectoryEntry { + return { + paneRef: surfaceId, + surfaceId, + type: 'terminal', + title: surfaceId, + focused: false, + ringing: false, + hasTODO: false, + ...over, + }; +} + +describe('RemotePtyAdapter directory', () => { + it('turns a snapshot into onPtyList (alive from exitCode) and getDirectoryEntries', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const lists: PtyInfo[][] = []; + adapter.onPtyList(({ ptys }) => lists.push(ptys)); + + await adapter.init(); + client.pushSnapshot([entry('s1', { title: 'zsh' }), entry('s2', { exitCode: 0 })]); + + expect(lists).toEqual([ + [ + { id: 's1', alive: true }, + { id: 's2', alive: false, exitCode: 0 }, + ], + ]); + expect(adapter.getDirectoryEntries().map((e) => e.surfaceId)).toEqual(['s1', 's2']); + expect(adapter.getDirectoryEntries()[0].title).toBe('zsh'); + expect(adapter.getPaneEntry('s2')?.exitCode).toBe(0); + }); + + it('notifies subscribeDirectory listeners until they unsubscribe', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.init(); + + const seen: DirectoryEntry[][] = []; + const unsub = adapter.subscribeDirectory((entries) => seen.push(entries)); + client.pushSnapshot([entry('s1')]); + expect(seen).toHaveLength(1); + + unsub(); + client.pushSnapshot([entry('s1'), entry('s2')]); + expect(seen).toHaveLength(1); + }); + + it('requestInit re-emits the cached list without re-watching', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.init(); + client.pushSnapshot([entry('s1')]); + + const lists: PtyInfo[][] = []; + adapter.onPtyList(({ ptys }) => lists.push(ptys)); + adapter.requestInit(); + + expect(lists).toEqual([[{ id: 's1', alive: true }]]); + expect(client.attaches).toHaveLength(0); + }); +}); + +describe('RemotePtyAdapter attach / active pane', () => { + it('attaches on setActivePane and detaches the previous when switching', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + + await adapter.setActivePane('s1', 80, 24); + expect(client.attaches).toHaveLength(1); + expect(client.attaches[0]).toMatchObject({ surfaceId: 's1', cols: 80, rows: 24 }); + expect(adapter.activeSurfaceId).toBe('s1'); + expect(client.detaches).toHaveLength(0); + + await adapter.setActivePane('s2', 100, 30); + expect(client.detaches).toEqual([{ surfaceId: 's1', subId: 'attach-1' }]); + expect(client.attaches).toHaveLength(2); + expect(client.attaches[1]).toMatchObject({ surfaceId: 's2', cols: 100, rows: 30 }); + expect(adapter.activeSurfaceId).toBe('s2'); + }); + + it('re-activating the same pane resizes rather than re-attaching', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + + await adapter.setActivePane('s1', 80, 24); + await adapter.setActivePane('s1', 120, 40); + + expect(client.attaches).toHaveLength(1); + expect(client.detaches).toHaveLength(0); + expect(client.resizes).toEqual([{ surfaceId: 's1', cols: 120, rows: 40 }]); + }); + + it('decodes terminal.data (base64url utf8) into an onPtyData string', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const data: Array<{ id: string; data: string }> = []; + adapter.onPtyData((d) => data.push(d)); + + await adapter.setActivePane('s1', 80, 24); + client.lastAttach().handlers.onData(toBase64Url(utf8Encode('héllo ▲'))); + + expect(data).toEqual([{ id: 's1', data: 'héllo ▲' }]); + }); + + it('routes write and resize only to the attached pane', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.setActivePane('s1', 80, 24); + + adapter.writePty('s1', 'ls\r'); + adapter.writePty('s2', 'ignored'); // not attached → dropped + expect(client.writes).toEqual([{ surfaceId: 's1', bytes: toBase64Url(utf8Encode('ls\r')) }]); + + adapter.resizePty('s1', 90, 20); + adapter.resizePty('s2', 10, 10); // not attached → dropped + expect(client.resizes).toEqual([{ surfaceId: 's1', cols: 90, rows: 20 }]); + }); + + it('spawnPty / killPty are no-ops (panes are Host-owned)', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + adapter.spawnPty(); + adapter.killPty(); + expect(client.attaches).toHaveLength(0); + expect(client.detaches).toHaveLength(0); + }); + + it('terminal.closed fires onPtyExit and clears the attachment', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((d) => exits.push(d)); + + await adapter.setActivePane('s1', 80, 24); + client.lastAttach().handlers.onClosed?.(3); + + expect(exits).toEqual([{ id: 's1', exitCode: 3 }]); + expect(adapter.activeSurfaceId).toBeNull(); + + // Once closed the pane is no longer attached, so writes are dropped. + adapter.writePty('s1', 'x'); + expect(client.writes).toHaveLength(0); + }); +}); + +describe('RemotePtyAdapter dispose', () => { + it('detaches the live surface and unsubscribes the directory', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.init(); + await adapter.setActivePane('s1', 80, 24); + + await adapter.dispose(); + + expect(client.unsubscribes).toContain('dir-sub'); + expect(client.detaches).toContainEqual({ surfaceId: 's1', subId: 'attach-1' }); + expect(adapter.activeSurfaceId).toBeNull(); + }); +}); diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts new file mode 100644 index 00000000..10e5deea --- /dev/null +++ b/lib/src/remote/client/remote-adapter.ts @@ -0,0 +1,370 @@ +/** + * `RemotePtyAdapter` — a {@link PlatformAdapter} backed by a connected + * {@link PocketClient} session, so the exact mobile terminal UI the website + * proves out with `FakePtyAdapter` (`PocketTerminalExperience`) can render a + * real remote Host over the remote-api v1 wire (docs/specs/pocket-app.md). The + * adapter mapping table is the spec: + * + * onPtyList ← directory.snapshot (id = surfaceId) + * attach semantics ← surface.attach (one attachment per session) + * onPtyData ← terminal.data (base64url utf8 → string) + * writePty → terminal.write (string → base64url utf8) + * resizePty → terminal.resize + * onPtyExit ← terminal.closed + * + * Everything outside that PTY core no-ops or is absent — the interface is built + * for capability degradation (getCwd/getScrollback → null, getOpenPorts → [], + * shells/clipboard empty, alerts no-op; alert/TODO/ringing badges instead ride + * the directory snapshot and are read via {@link getDirectoryEntries}). + * + * ── What phase 1b needs to know about terminal-registry (terminal-lifecycle.ts) + * + * The registry binds a pane purely by string id: `getOrCreateTerminal(id)` + * creates an xterm, registers `onPtyData`/`onPtyExit` handlers that filter on + * `detail.id === id`, and writes matching data straight into that xterm. So the + * ONLY contract this adapter must honor for the data pump is: emit + * `onPtyData({ id: surfaceId, data })` / `onPtyExit({ id: surfaceId, ... })` and + * mount each pane's xterm under a session id equal to its `surfaceId`. + * + * `getOrCreateTerminal` also calls `spawnPty(id, {cols,rows})` and, on xterm + * fit/resize, `resizePty(id, cols, rows)`, and on keystrokes `writePty(id, ..)`. + * `spawnPty` being a no-op here does NOT break session creation — the registry + * never waits on a spawn ack; it just wires listeners and calls spawn for the + * local-PTY adapters. (FakePtyAdapter's `spawnPty` fires an `onPtySpawn` extra + * the playground's shell registry listens to; there is no such shell registry + * on the remote side — the Host owns the shell — so we emit nothing on spawn.) + * + * The catch phase 1b must handle: nothing is streaming until the pane is + * ATTACHED. v1 allows one attachment per session, so the UI must call + * {@link setActivePane}(surfaceId, cols, rows) whenever the active pane changes + * (detach-old → attach-new). Until then `writePty`/`resizePty` for a + * non-attached pane are dropped (the Host would reject them anyway), and the + * attach repaint — not a snapshot transfer — is what fills the client screen. + * The registry's own `onResize → resizePty` path keeps the attached pane sized; + * `setActivePane` seeds the first size. Host-initiated `terminal.resize` events + * are ignored (the PlatformAdapter interface has no inbound-resize channel). + */ + +import { + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, + type DirectoryEntry, + type TerminalAttachResult, +} from 'server-lib-common'; +import type { PlatformAdapter, PtyInfo, OpenPort } from '../../lib/platform/types'; +import type { TerminalHandlers } from './pocket-client'; + +/** + * The slice of {@link PocketClient} the adapter drives. A connected + * `PocketClient` satisfies it structurally; tests pass a network-free fake. + */ +export interface RemoteAdapterClient { + watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise; + attach( + surfaceId: string, + cols: number, + rows: number, + handlers: TerminalHandlers, + ): Promise<{ subId: string; result: TerminalAttachResult }>; + write(surfaceId: string, bytes: string): Promise; + resize(surfaceId: string, cols: number, rows: number): Promise; + detach(surfaceId: string, subId?: string): Promise; + unsubscribe(subId: string): void; +} + +interface Attachment { + surfaceId: string; + subId: string; +} + +interface Size { + cols: number; + rows: number; +} + +const DEFAULT_SIZE: Size = { cols: 80, rows: 24 }; + +type DataHandler = (detail: { id: string; data: string }) => void; +type ExitHandler = (detail: { id: string; exitCode: number }) => void; +type ListHandler = (detail: { ptys: PtyInfo[] }) => void; +type DirectoryListener = (entries: DirectoryEntry[]) => void; + +export class RemotePtyAdapter implements PlatformAdapter { + readonly #client: RemoteAdapterClient; + + readonly #dataHandlers = new Set(); + readonly #exitHandlers = new Set(); + readonly #listHandlers = new Set(); + readonly #directoryListeners = new Set(); + + /** Latest directory snapshot, in Host order. */ + #entries: DirectoryEntry[] = []; + /** surfaceId → entry, for O(1) lookups. */ + #panes = new Map(); + + /** Memoized directory.watch start; also the "started" guard. */ + #watchPromise: Promise | null = null; + #directorySubId: string | null = null; + + /** The one attached surface (v1: one attachment per session), or null. */ + #attached: Attachment | null = null; + /** Bumped on every setActivePane so a superseded async attach can bail. */ + #activeGeneration = 0; + /** Last size seen, so a re-attach can reuse it if the caller omits one. */ + #lastSize: Size = DEFAULT_SIZE; + + #savedState: unknown = null; + + constructor(client: RemoteAdapterClient) { + this.#client = client; + } + + // --- Lifecycle ----------------------------------------------------------- + + async init(): Promise { + await this.#ensureDirectoryWatch(); + } + + shutdown(): void { + void this.dispose(); + } + + /** Detach the live surface and stop watching the directory. */ + async dispose(): Promise { + const attached = this.#attached; + this.#attached = null; + this.#activeGeneration++; + if (this.#directorySubId) { + this.#client.unsubscribe(this.#directorySubId); + this.#directorySubId = null; + } + this.#directoryListeners.clear(); + if (attached) { + try { + await this.#client.detach(attached.surfaceId, attached.subId); + } catch { + // best effort — the socket may already be gone + } + } + } + + // --- Directory (onPtyList + adapter-specific getters) -------------------- + + requestInit(): void { + void this.#ensureDirectoryWatch(); + // Give a resuming UI the latest known list immediately. + if (this.#entries.length > 0) this.#emitPtyList(); + } + + onPtyList(handler: ListHandler): void { + this.#listHandlers.add(handler); + } + + offPtyList(handler: ListHandler): void { + this.#listHandlers.delete(handler); + } + + /** The full directory snapshot (titles/activity/ringing/hasTODO) without attaching. */ + getDirectoryEntries(): DirectoryEntry[] { + return [...this.#entries]; + } + + /** The directory entry for a surface, or undefined. */ + getPaneEntry(surfaceId: string): DirectoryEntry | undefined { + return this.#panes.get(surfaceId); + } + + /** Subscribe to directory snapshots; returns an unsubscribe fn. */ + subscribeDirectory(listener: DirectoryListener): () => void { + this.#directoryListeners.add(listener); + return () => { + this.#directoryListeners.delete(listener); + }; + } + + #ensureDirectoryWatch(): Promise { + if (!this.#watchPromise) { + this.#watchPromise = this.#client + .watchDirectory((entries) => this.#onSnapshot(entries)) + .then((subId) => { + this.#directorySubId = subId; + }); + } + return this.#watchPromise; + } + + #onSnapshot(entries: DirectoryEntry[]): void { + this.#entries = entries; + this.#panes = new Map(entries.map((entry) => [entry.surfaceId, entry])); + this.#emitPtyList(); + for (const listener of this.#directoryListeners) listener(entries); + } + + #emitPtyList(): void { + const ptys: PtyInfo[] = this.#entries.map((entry) => ({ + id: entry.surfaceId, + alive: entry.exitCode === undefined, + ...(entry.exitCode === undefined ? {} : { exitCode: entry.exitCode }), + })); + for (const handler of this.#listHandlers) handler({ ptys }); + } + + // --- Attach / active pane (adapter-specific extra) ----------------------- + + /** + * Make `id` the single attached surface: detach the previous one, then + * `surface.attach` with `cols`/`rows`. Its `terminal.data` becomes + * `onPtyData`, `terminal.closed` becomes `onPtyExit`. + */ + async setActivePane(id: string, cols?: number, rows?: number): Promise { + const size = normalizeSize(cols, rows, this.#lastSize); + this.#lastSize = size; + + if (this.#attached?.surfaceId === id) { + // Already the active surface — a size change is just a resize. + void this.#client.resize(id, size.cols, size.rows); + return; + } + + const generation = ++this.#activeGeneration; + const prev = this.#attached; + this.#attached = null; + if (prev) { + try { + await this.#client.detach(prev.surfaceId, prev.subId); + } catch { + // best effort + } + } + if (generation !== this.#activeGeneration) return; // superseded mid-detach + + const handlers: TerminalHandlers = { + onData: (bytes) => this.#emitData(id, bytes), + onClosed: (exitCode) => this.#emitExit(id, exitCode), + }; + const { subId } = await this.#client.attach(id, size.cols, size.rows, handlers); + if (generation !== this.#activeGeneration) { + // A newer setActivePane won the race — undo this stale attach. + this.#client.unsubscribe(subId); + void this.#client.detach(id, subId).catch(() => {}); + return; + } + this.#attached = { surfaceId: id, subId }; + } + + /** The currently attached surfaceId, or null. */ + get activeSurfaceId(): string | null { + return this.#attached?.surfaceId ?? null; + } + + // --- PTY core ------------------------------------------------------------ + + writePty(id: string, data: string): void { + if (this.#attached?.surfaceId !== id) return; // Host only accepts the attached pane + void this.#client.write(id, toBase64Url(utf8Encode(data))); + } + + resizePty(id: string, cols: number, rows: number): void { + if (this.#attached?.surfaceId !== id) return; + this.#lastSize = { cols, rows }; + void this.#client.resize(id, cols, rows); + } + + // Panes are Host-owned: the phone never spawns or kills them. + spawnPty(): void {} + killPty(): void {} + + onPtyData(handler: DataHandler): void { + this.#dataHandlers.add(handler); + } + + offPtyData(handler: DataHandler): void { + this.#dataHandlers.delete(handler); + } + + onPtyExit(handler: ExitHandler): void { + this.#exitHandlers.add(handler); + } + + offPtyExit(handler: ExitHandler): void { + this.#exitHandlers.delete(handler); + } + + #emitData(id: string, bytes: string): void { + const data = utf8Decode(fromBase64Url(bytes)); + for (const handler of this.#dataHandlers) handler({ id, data }); + } + + #emitExit(id: string, exitCode?: number): void { + if (this.#attached?.surfaceId === id) this.#attached = null; + for (const handler of this.#exitHandlers) handler({ id, exitCode: exitCode ?? 0 }); + } + + // --- Degraded capabilities (absent PTY features) ------------------------- + + async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> { + return []; + } + + async getCwd(): Promise { + return null; + } + + async getScrollback(): Promise { + return null; + } + + async getOpenPorts(): Promise { + return []; + } + + async readClipboardFilePaths(): Promise { + return null; + } + + async readClipboardImageAsFilePath(): Promise { + return null; + } + + // Resume-path replay: the Host has no per-pane replay buffer in v1, so ignore. + onPtyReplay(): void {} + offPtyReplay(): void {} + + // Host-initiated persistence flush: not driven from the phone. + onRequestSessionFlush(): void {} + offRequestSessionFlush(): void {} + notifySessionFlushComplete(): void {} + + // Alerts are Host-authoritative (surfaced via the directory snapshot), so the + // phone-side alert controls are inert. + alertRemove(): void {} + alertToggle(): void {} + alertDisable(): void {} + alertDismiss(): void {} + alertDismissOrToggle(): void {} + alertAttend(): void {} + alertResize(): void {} + alertClearAttention(): void {} + alertToggleTodo(): void {} + alertMarkTodo(): void {} + alertClearTodo(): void {} + onAlertState(): void {} + offAlertState(): void {} + + saveState(state: unknown): void { + this.#savedState = state; + } + + getState(): unknown { + return this.#savedState; + } +} + +/** Coerce a requested size to positive integers, falling back to `fallback`. */ +function normalizeSize(cols: number | undefined, rows: number | undefined, fallback: Size): Size { + const c = cols !== undefined && Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : fallback.cols; + const r = rows !== undefined && Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : fallback.rows; + return { cols: c, rows: r }; +} diff --git a/lib/src/pocket/webauthn.ts b/lib/src/remote/client/webauthn.ts similarity index 100% rename from lib/src/pocket/webauthn.ts rename to lib/src/remote/client/webauthn.ts diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index 46d12556..cef416d5 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -17,13 +17,18 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + REMOTE_EVENTS, + REMOTE_METHODS, SELFHOST_ACCOUNT_ID, WS_ROUTES, WS_TOKEN_PARAM, + fromBase64Url, generateDeviceKeyPair, hashPasskeyPublicKey, signDeviceChallenge, toBase64Url, + utf8Decode, + utf8Encode, } from 'server-lib-common'; import { @@ -385,6 +390,96 @@ test('a Host restart invalidates an established session (host-gone, then msg blo } }); +// --- Synthetic directory + echo terminal over the real wire ----------------- + +/** Pair + connect an established phone session, ready for `msg` traffic. */ +async function establish(p, hostId) { + assert.equal((await pair(p, hostId)).approved, true); + const challengeFrame = await connect(p, hostId); + assert.equal((await connect2(p, hostId, challengeFrame.challenge)).decision.allowed, true); +} + +/** Send a remote-api request over a `msg` frame. */ +function remote(p, requestId, method, params) { + p.socket.send({ t: 'msg', data: { requestId, method, params } }); +} + +/** Decode a `terminal.data` event frame's base64url utf8 PTY bytes to a string. */ +function eventText(frame) { + return utf8Decode(fromBase64Url(frame.data.data.bytes)); +} + +test('remote terminal: directory snapshot, attach banner, echo write, resize, detach silences', async () => { + const { app, server, host, close } = await boot(); + try { + const p = await phone(app, server); + await establish(p, host.hostId); + + // directory.watch → ack (subId === requestId) then a snapshot event. + remote(p, 'dw', REMOTE_METHODS.directoryWatch, {}); + const watchAck = await p.socket.take(); + assert.equal(watchAck.data.requestId, 'dw'); + assert.equal(watchAck.data.ok, true); + assert.equal(watchAck.data.result.subId, 'dw'); + + const snapshot = await p.socket.take(); + assert.equal(snapshot.data.subId, 'dw'); + assert.equal(snapshot.data.event, REMOTE_EVENTS.directorySnapshot); + const entries = snapshot.data.data.entries; + assert.equal(entries.length, 2); + assert.equal(entries[0].type, 'terminal'); + const surfaceId = entries[0].surfaceId; + + // surface.attach → authoritative size + a banner terminal.data event. + remote(p, 'at', REMOTE_METHODS.surfaceAttach, { surfaceId, cols: 100, rows: 40 }); + const attachAck = await p.socket.take(); + assert.equal(attachAck.data.requestId, 'at'); + assert.equal(attachAck.data.ok, true); + assert.deepEqual(attachAck.data.result, { cols: 100, rows: 40 }); + + const banner = await p.socket.take(); + assert.equal(banner.data.subId, 'at'); + assert.equal(banner.data.event, REMOTE_EVENTS.terminalData); + const bannerText = eventText(banner); + assert.match(bannerText, /attached/); + assert.match(bannerText, /100x40/); + + // terminal.write → ack + the bytes echoed back (with a redrawn prompt). + remote(p, 'wr', REMOTE_METHODS.terminalWrite, { + surfaceId, + bytes: toBase64Url(utf8Encode('echo hi\r')), + }); + assert.equal((await p.socket.take()).data.ok, true); + const echo = await p.socket.take(); + assert.equal(echo.data.event, REMOTE_EVENTS.terminalData); + const echoText = eventText(echo); + assert.match(echoText, /echo hi/); + assert.ok(echoText.endsWith('$ '), 'the \\r redraws a fake prompt'); + + // terminal.resize → ack + a size-note terminal.data event. + remote(p, 'rs', REMOTE_METHODS.terminalResize, { surfaceId, cols: 120, rows: 50 }); + const resizeAck = await p.socket.take(); + assert.equal(resizeAck.data.ok, true); + assert.deepEqual(resizeAck.data.result, { cols: 120, rows: 50 }); + assert.match(eventText(await p.socket.take()), /resized to 120x50/); + + // surface.detach → ack, then a write is acked but produces no more data. + remote(p, 'dt', REMOTE_METHODS.surfaceDetach, { surfaceId }); + assert.equal((await p.socket.take()).data.ok, true); + + remote(p, 'wr2', REMOTE_METHODS.terminalWrite, { + surfaceId, + bytes: toBase64Url(utf8Encode('ping\r')), + }); + const writeAck2 = await p.socket.take(); + assert.equal(writeAck2.data.requestId, 'wr2'); + assert.equal(writeAck2.data.ok, true); + assert.ok(await p.socket.quiet(), 'detach silences the stream: no terminal.data after detach'); + } finally { + await close(); + } +}); + // A short settle so no test leaves an in-flight frame racing teardown. test.after?.(async () => { await sleep(10); diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs index 67bef3ba..3d615aad 100644 --- a/server/test/harness/fake-host.mjs +++ b/server/test/harness/fake-host.mjs @@ -24,9 +24,15 @@ import { HostAcl, HostChallengeIssuer, PairingCeremony, + REMOTE_EVENTS, + REMOTE_METHODS, WS_ROUTES, WS_TOKEN_PARAM, authorizeConnection, + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, } from 'server-lib-common'; export class FakeHost extends EventEmitter { @@ -42,6 +48,18 @@ export class FakeHost extends EventEmitter { this.established = new Set(); /** clientId → pairingId awaiting a manual approve/deny (autoApprove off). */ this.pending = new Map(); + /** + * A tiny synthetic terminal directory so the remote adapter is testable + * without a real Host: two in-memory "echo shells" addressable by surfaceId. + */ + this.surfaces = [ + { surfaceId: 'srf-zsh', paneRef: 'pane-zsh', title: 'zsh', cols: 80, rows: 24 }, + { surfaceId: 'srf-vim', paneRef: 'pane-vim', title: 'vim', cols: 80, rows: 24 }, + ]; + /** clientId → directory-watch subId (the request id it was opened with). */ + this.directorySubs = new Map(); + /** clientId → { surfaceId, subId } for the one attached surface, if any. */ + this.attachments = new Map(); const wsBase = serverUrl.replace(/^http/, 'ws'); this.ws = new WebSocket(`${wsBase}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${hostToken}`); @@ -115,6 +133,8 @@ export class FakeHost extends EventEmitter { case 'client-gone': { this.established.delete(clientId); this.pending.delete(clientId); + this.directorySubs.delete(clientId); + this.attachments.delete(clientId); this.emit('client-gone', { clientId }); return; } @@ -123,28 +143,125 @@ export class FakeHost extends EventEmitter { } } - /** Minimal remote-api v1: answer `hello`, refuse everything else with ok:false. */ + /** + * Remote-api v1 with a synthetic directory + echo terminal. `hello` answers + * capabilities; `directory.watch` snapshots the fake surfaces; `surface.attach` + * streams a size banner; `terminal.write` echoes bytes back (treating `\r` as a + * newline and re-drawing a prompt); `terminal.resize` notes the new size; + * `surface.detach` silences the stream. Unknown methods echo ok:false. + */ #handleRemoteApi(clientId, data) { const request = data; if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { return; } - let response; - if (request.method === 'hello') { - response = { - requestId: request.requestId, - ok: true, - result: { protocolVersion: 1, hostId: this.hostId, grants: { input: true, layout: true } }, - }; - } else { - response = { - requestId: request.requestId, - ok: false, - error: `unknown method: ${request.method}`, - }; + const { requestId, method, params } = request; + + const respond = (response) => { + this.emit('msg', { clientId, request, response }); + this.#send({ t: 'msg', clientId, data: response }); + }; + const ok = (result = {}) => respond({ requestId, ok: true, result }); + const fail = (error) => respond({ requestId, ok: false, error }); + + switch (method) { + case REMOTE_METHODS.hello: + ok({ protocolVersion: 1, hostId: this.hostId, grants: { input: true, layout: true } }); + return; + + case REMOTE_METHODS.directoryWatch: { + // Host convention: the subscription id is the request's own requestId. + this.directorySubs.set(clientId, requestId); + ok({ subId: requestId }); + this.#event(clientId, requestId, REMOTE_EVENTS.directorySnapshot, { + entries: this.#directoryEntries(), + }); + return; + } + + case REMOTE_METHODS.surfaceAttach: { + const surface = this.#surface(params?.surfaceId); + if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + surface.cols = clampInt(params.cols, surface.cols); + surface.rows = clampInt(params.rows, surface.rows); + this.attachments.set(clientId, { surfaceId: surface.surfaceId, subId: requestId }); + ok({ cols: surface.cols, rows: surface.rows }); + this.#emitData( + clientId, + requestId, + `\r\n[fake-host] attached ${surface.title} (${surface.cols}x${surface.rows})\r\n$ `, + ); + return; + } + + case REMOTE_METHODS.terminalWrite: { + const surface = this.#surface(params?.surfaceId); + if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + ok(); + const attachment = this.attachments.get(clientId); + if (!attachment || attachment.surfaceId !== surface.surfaceId) return; // detached → silent + const input = utf8Decode(fromBase64Url(params.bytes)); + const echoed = input.includes('\r') ? `${input.replace(/\r/g, '\r\n')}$ ` : input; + this.#emitData(clientId, attachment.subId, echoed); + return; + } + + case REMOTE_METHODS.terminalResize: { + const surface = this.#surface(params?.surfaceId); + if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + surface.cols = clampInt(params.cols, surface.cols); + surface.rows = clampInt(params.rows, surface.rows); + ok({ cols: surface.cols, rows: surface.rows }); + const attachment = this.attachments.get(clientId); + if (!attachment || attachment.surfaceId !== surface.surfaceId) return; + this.#emitData( + clientId, + attachment.subId, + `\r\n[fake-host] resized to ${surface.cols}x${surface.rows}\r\n`, + ); + return; + } + + case REMOTE_METHODS.surfaceDetach: { + ok(); + this.attachments.delete(clientId); // stops any further terminal.data + return; + } + + default: + fail(`unknown method: ${method}`); + return; } - this.emit('msg', { clientId, request, response }); - this.#send({ t: 'msg', clientId, data: response }); + } + + /** A directory snapshot of the synthetic surfaces. */ + #directoryEntries() { + return this.surfaces.map((surface, index) => ({ + paneRef: surface.paneRef, + surfaceId: surface.surfaceId, + type: 'terminal', + title: surface.title, + focused: index === 0, + activity: 'prompt', + ringing: false, + hasTODO: false, + })); + } + + #surface(surfaceId) { + return this.surfaces.find((surface) => surface.surfaceId === surfaceId); + } + + /** Send a remote-api event to a client, wrapped in a `msg` relay frame. */ + #event(clientId, subId, event, eventData) { + this.#send({ t: 'msg', clientId, data: { subId, event, data: eventData } }); + } + + /** Emit a `terminal.data` event with `text` as base64url utf8 PTY bytes. */ + #emitData(clientId, subId, text) { + this.#event(clientId, subId, REMOTE_EVENTS.terminalData, { + bytes: toBase64Url(utf8Encode(text)), + }); } /** Local approval on the Host: the only path that writes to the ACL. */ @@ -176,3 +293,8 @@ export class FakeHost extends EventEmitter { } } } + +/** Coerce a requested terminal dimension to a positive integer, else `fallback`. */ +function clampInt(value, fallback) { + return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback; +} From 8bc86bdda8bdda5883f1b634f452f4d29367153b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 16:45:46 -0700 Subject: [PATCH 16/52] feat(lib): Pocket app on MobileTerminalUi + RemotePtyAdapter (phase 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke POC terminal UI with the real mobile experience. The Pocket shell now lives in lib/src/remote/pocket-app/: the proven auth flow (setup/signin -> hosts -> pair/connect) followed by MobileTerminalUi + MobileWall driven by a RemotePtyAdapter over the connected PocketClient — the same composition the website playground proves out with FakePtyAdapter. On connect: construct the adapter, setPlatform, dispose stale sessions + initAlertStateReceiver, then watch the directory. Sessions come from the directory snapshot (id = surfaceId); ringing/hasTODO/cwd map onto the existing session-item badge affordances. Active-pane changes funnel through adapter.setActivePane (one attachment per session); writes/paste target the active pane. Socket drop disposes the adapter and returns to Hosts. Delete PocketTerminal.tsx and the bespoke xterm handling; retarget the pocket vite entry (build:pocket / dist-pocket unchanged) and add Tailwind + theme plumbing so the shared terminal UI is styled. Co-Authored-By: Claude --- lib/pocket/index.html | 2 +- lib/src/pocket/PocketTerminal.tsx | 162 ----------------- lib/src/{pocket => remote/pocket-app}/App.tsx | 170 ++++++------------ lib/src/remote/pocket-app/PocketWall.tsx | 148 +++++++++++++++ .../{pocket => remote/pocket-app}/main.tsx | 0 .../{pocket => remote/pocket-app}/pocket.css | 99 ++-------- lib/src/remote/pocket-app/wall-model.test.ts | 124 +++++++++++++ lib/src/remote/pocket-app/wall-model.ts | 86 +++++++++ lib/vite.pocket.config.ts | 11 +- 9 files changed, 435 insertions(+), 367 deletions(-) delete mode 100644 lib/src/pocket/PocketTerminal.tsx rename lib/src/{pocket => remote/pocket-app}/App.tsx (69%) create mode 100644 lib/src/remote/pocket-app/PocketWall.tsx rename lib/src/{pocket => remote/pocket-app}/main.tsx (100%) rename lib/src/{pocket => remote/pocket-app}/pocket.css (74%) create mode 100644 lib/src/remote/pocket-app/wall-model.test.ts create mode 100644 lib/src/remote/pocket-app/wall-model.ts diff --git a/lib/pocket/index.html b/lib/pocket/index.html index 81813798..b4ff8071 100644 --- a/lib/pocket/index.html +++ b/lib/pocket/index.html @@ -12,6 +12,6 @@
- + diff --git a/lib/src/pocket/PocketTerminal.tsx b/lib/src/pocket/PocketTerminal.tsx deleted file mode 100644 index 8284e4bf..00000000 --- a/lib/src/pocket/PocketTerminal.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * The Pocket terminal view: a live xterm rendering of a remote Host pane. - * - * Attach-is-the-resize (remote-api.md): on mount we fit xterm to the phone and - * `surface.attach` with those `cols`/`rows`, which resizes the Host PTY and - * makes it repaint into our screen from the live stream — no snapshot transfer. - * Output (`terminal.data`) is base64url PTY bytes we `write` straight into - * xterm; keystrokes go back as `terminal.write`; a phone rotate/resize re-fits - * and re-sends `terminal.resize`. - */ - -import { useEffect, useRef, useState } from 'react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { fromBase64Url, toBase64Url, utf8Encode } from 'server-lib-common'; -import '@xterm/xterm/css/xterm.css'; -import type { PocketClient } from '../remote/client/pocket-client'; - -const TERMINAL_THEME = { - background: '#000000', - foreground: '#e6e8ec', - cursor: '#4a9eff', - selectionBackground: '#264f7860', - black: '#000000', - red: '#cd3131', - green: '#0dbc79', - yellow: '#e5e510', - blue: '#2472c8', - magenta: '#bc3fbc', - cyan: '#11a8cd', - white: '#e5e5e5', - brightBlack: '#666666', - brightRed: '#f14c4c', - brightGreen: '#23d18b', - brightYellow: '#f5f543', - brightBlue: '#3b8eea', - brightMagenta: '#d670d6', - brightCyan: '#29b8db', - brightWhite: '#ffffff', -}; - -/** Control sequences for the on-screen key bar. */ -const KEYS: Array<{ label: string; seq: string; wide?: boolean }> = [ - { label: 'esc', seq: '\x1b' }, - { label: 'tab', seq: '\x09' }, - { label: '^C', seq: '\x03' }, - { label: '↑', seq: '\x1b[A' }, - { label: '↓', seq: '\x1b[B' }, - { label: '←', seq: '\x1b[D' }, - { label: '→', seq: '\x1b[C' }, - { label: 'enter', seq: '\r', wide: true }, -]; - -export interface PocketTerminalProps { - client: PocketClient; - surfaceId: string; - onBack: () => void; -} - -export function PocketTerminal({ client, surfaceId, onBack }: PocketTerminalProps): React.ReactElement { - const hostRef = useRef(null); - const termRef = useRef(null); - const [closed, setClosed] = useState<{ exitCode?: number } | null>(null); - - useEffect(() => { - const host = hostRef.current; - if (!host) return; - - const term = new Terminal({ - fontSize: 12, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', - cursorBlink: true, - theme: TERMINAL_THEME, - scrollback: 2000, - }); - termRef.current = term; - const fit = new FitAddon(); - term.loadAddon(fit); - term.open(host); - fit.fit(); - - let disposed = false; - let subId: string | undefined; - - const inputSub = term.onData((data) => { - void client.write(surfaceId, toBase64Url(utf8Encode(data))); - }); - - client - .attach(surfaceId, term.cols, term.rows, { - onData: (bytes) => term.write(fromBase64Url(bytes)), - onResize: (cols, rows) => term.resize(cols, rows), - onClosed: (exitCode) => setClosed({ exitCode }), - }) - .then(({ subId: id, result }) => { - if (disposed) { - void client.detach(surfaceId, id); - return; - } - subId = id; - // Sync xterm to the authoritative PTY size the Host reports back. - if (result.cols > 0 && result.rows > 0 && (result.cols !== term.cols || result.rows !== term.rows)) { - term.resize(result.cols, result.rows); - } - }) - .catch(() => setClosed({ exitCode: undefined })); - - const onResize = () => { - if (disposed) return; - fit.fit(); - void client.resize(surfaceId, term.cols, term.rows); - }; - window.addEventListener('resize', onResize); - window.addEventListener('orientationchange', onResize); - - // Bring up the on-screen keyboard by focusing xterm's helper textarea. - term.focus(); - - return () => { - disposed = true; - window.removeEventListener('resize', onResize); - window.removeEventListener('orientationchange', onResize); - inputSub.dispose(); - void client.detach(surfaceId, subId); - term.dispose(); - termRef.current = null; - }; - }, [client, surfaceId]); - - const sendKey = (seq: string) => { - void client.write(surfaceId, toBase64Url(utf8Encode(seq))); - termRef.current?.focus(); - }; - - return ( - <> -
-
termRef.current?.focus()} /> - {closed ? ( -
- session ended{closed.exitCode !== undefined ? ` (exit ${closed.exitCode})` : ''} -
- ) : null} -
-
- - {KEYS.map((key) => ( - - ))} -
- - ); -} diff --git a/lib/src/pocket/App.tsx b/lib/src/remote/pocket-app/App.tsx similarity index 69% rename from lib/src/pocket/App.tsx rename to lib/src/remote/pocket-app/App.tsx index 224b68fd..01641fba 100644 --- a/lib/src/pocket/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -1,25 +1,29 @@ /** - * Dormouse Pocket — the phone-side app (docs/specs/server.md "Pocket side"). + * Dormouse Pocket — the phone-side app (docs/specs/pocket-app.md). * - * A tiny four-view flow over {@link PocketClient}: sign in (or first-time - * passkey setup) → pick a host (pair once, then connect) → pick a pane from the - * live directory → drive it in the terminal view. All protocol work lives in - * the client; this file is just state + buttons. + * Auth screens over {@link PocketClient} — sign in (or first-time passkey setup) + * → pick a host (pair once, then connect) — then, on a successful connect, the + * real mobile experience: a {@link RemotePtyAdapter} over the session drives + * `MobileTerminalUi`/`MobileWall` (the same composition the website playground + * proves out with `FakePtyAdapter`). No bespoke terminal UI. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { DirectoryEntry } from 'server-lib-common'; import { PocketClient, type ConnectDecision, type PocketSocket, -} from '../remote/client/pocket-client'; -import { browserWebAuthn } from '../remote/client/webauthn'; -import { getOrCreateDeviceKey } from '../remote/client/device-key'; -import { PocketTerminal } from './PocketTerminal'; +} from '../client/pocket-client'; +import { browserWebAuthn } from '../client/webauthn'; +import { getOrCreateDeviceKey } from '../client/device-key'; +import { RemotePtyAdapter } from '../client/remote-adapter'; +import { setPlatform } from '../../lib/platform'; +import { disposeAllSessions, initAlertStateReceiver } from '../../lib/terminal-registry'; +import { PocketWall } from './PocketWall'; +import '../../index.css'; import './pocket.css'; -type Phase = 'auth' | 'hosts' | 'picker' | 'terminal'; +type Phase = 'auth' | 'hosts' | 'wall'; interface HostView { hostId: string; @@ -48,8 +52,7 @@ export default function App(): React.ReactElement { const [busy, setBusy] = useState(null); const [hosts, setHosts] = useState([]); const [activeHost, setActiveHost] = useState(null); - const [entries, setEntries] = useState([]); - const [surface, setSurface] = useState<{ surfaceId: string; title: string } | null>(null); + const adapterRef = useRef(null); const socketOpened = useRef(false); const run = useCallback(async (label: string, fn: () => Promise) => { @@ -73,15 +76,23 @@ export default function App(): React.ReactElement { setPhase('hosts'); }, [client]); + /** Tear down the live session and return to the hosts list. */ + const teardownAdapter = useCallback(() => { + void adapterRef.current?.dispose(); + adapterRef.current = null; + disposeAllSessions(); + }, []); + + // Socket drop / host-gone: dispose the adapter and fall back to Hosts. useEffect(() => { client.setOnHostGone(() => { + teardownAdapter(); setError('The host disconnected.'); - setSurface(null); - setEntries([]); + setActiveHost(null); setPhase('hosts'); }); return () => client.setOnHostGone(null); - }, [client]); + }, [client, teardownAdapter]); const onConnect = (host: HostView) => run('connect', async () => { @@ -90,9 +101,18 @@ export default function App(): React.ReactElement { throw new Error(`Connection denied${decision.failures ? `: ${decision.failures.join(', ')}` : ''}`); } await client.hello(); - await client.watchDirectory(setEntries); + + // Stand up the remote adapter as the platform, prep a clean registry, + // then start watching the directory before the wall renders. + const adapter = new RemotePtyAdapter(client); + adapterRef.current = adapter; + setPlatform(adapter); + disposeAllSessions(); + initAlertStateReceiver(); + await adapter.init(); + setActiveHost(host); - setPhase('picker'); + setPhase('wall'); }); const onPair = (host: HostView) => @@ -102,6 +122,12 @@ export default function App(): React.ReactElement { setHosts((prev) => [...prev]); // reflect the new paired state }); + const leaveWall = () => { + teardownAdapter(); + setActiveHost(null); + setPhase('hosts'); + }; + // --- Views --------------------------------------------------------------- if (phase === 'auth') { @@ -109,10 +135,11 @@ export default function App(): React.ReactElement { run('signin', async () => { - await client.signin(); - await loadHosts(); - })} + onSignin={() => + run('signin', async () => { + await client.signin(); + await loadHosts(); + })} onSetup={(password, label) => run('setup', async () => { await client.setup(password, label); @@ -137,49 +164,18 @@ export default function App(): React.ReactElement { ); } - if (phase === 'picker' && activeHost) { - return ( - { - setEntries([]); - setActiveHost(null); - setPhase('hosts'); - }} - onPick={(entry) => { - setSurface({ surfaceId: entry.surfaceId, title: entry.title }); - setPhase('terminal'); - }} - /> - ); - } - - if (phase === 'terminal' && surface) { + if (phase === 'wall' && activeHost && adapterRef.current) { return (
- -

{surface.title || 'Terminal'}

+

{activeHost.label || activeHost.hostId}

- { - setSurface(null); - setPhase('picker'); - }} - /> +
+ +
); } @@ -347,57 +343,3 @@ function HostsView({
); } - -// --- PickerView ------------------------------------------------------------ - -function PickerView({ - host, - entries, - error, - onBack, - onPick, -}: { - host: HostView; - entries: DirectoryEntry[]; - error: string | null; - onBack: () => void; - onPick: (entry: DirectoryEntry) => void; -}): React.ReactElement { - return ( -
-
- -

- {host.label || host.hostId} panes -

-
-
- {error ?
{error}
: null} - {entries.length === 0 ? ( -
Waiting for panes…
- ) : ( - entries.map((entry) => ( - - )) - )} -
-
- ); -} diff --git a/lib/src/remote/pocket-app/PocketWall.tsx b/lib/src/remote/pocket-app/PocketWall.tsx new file mode 100644 index 00000000..847ec89a --- /dev/null +++ b/lib/src/remote/pocket-app/PocketWall.tsx @@ -0,0 +1,148 @@ +/** + * The Pocket wall experience: `MobileTerminalUi` + `MobileWall` driven by a + * connected {@link RemotePtyAdapter}. Same composition the website playground + * proves out with `FakePtyAdapter` (`PocketTerminalExperience`), minus the + * tutorial/shell-registry machinery — the Host owns the shells here. + * + * Sessions come straight from the adapter's directory snapshot (id = surfaceId). + * v1 allows one attachment per session, so every active-pane change funnels + * through {@link RemotePtyAdapter.setActivePane}; the registry's own resize path + * keeps the attached pane sized. Writes and paste target the active pane. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import type { DirectoryEntry } from 'server-lib-common'; +import { + MobileTerminalUi, + type MobileTerminalKeyboardMode, + type MobileTerminalTouchMode, +} from '../../components/MobileTerminalUi'; +import { MobileWall } from '../../components/MobileWall'; +import { restoreActiveTheme } from '../../lib/themes'; +import { + getMouseSelectionSnapshot, + setOverride as setMouseOverride, + subscribeToMouseSelection, +} from '../../lib/mouse-selection'; +import { getTerminalInstance, refitSession } from '../../lib/terminal-registry'; +import { doPaste } from '../../lib/clipboard'; +import type { RemotePtyAdapter } from '../client/remote-adapter'; +import { activatePane, directorySessionItems, directoryWallSessions } from './wall-model'; + +/** Same default theme the website playground restores, unless the user picked one. */ +const POCKET_THEME_ID = 'vscode.theme-kimbie-dark.kimbie-dark'; + +const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +function usePocketTheme() { + const restoredRef = useRef(false); + if (!restoredRef.current) { + restoreActiveTheme(POCKET_THEME_ID); + restoredRef.current = true; + } + // Repeat after hydration so MobileWall reads real theme variables even if + // React reconciled away render-time body styles. + useBrowserLayoutEffect(() => { + restoreActiveTheme(POCKET_THEME_ID); + }, []); +} + +export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.ReactElement { + usePocketTheme(); + const [entries, setEntries] = useState(() => adapter.getDirectoryEntries()); + const [activePaneId, setActivePaneId] = useState(null); + const [touchMode, setTouchMode] = useState('gestures'); + const [keyboardMode, setKeyboardMode] = useState('type'); + + // Track the live directory. Re-read on subscribe in case a snapshot landed + // between the initial render and this effect. + useEffect(() => { + setEntries(adapter.getDirectoryEntries()); + return adapter.subscribeDirectory(setEntries); + }, [adapter]); + + // Default to (and stay on a valid) pane as the directory changes. + useEffect(() => { + if (activePaneId && entries.some((entry) => entry.surfaceId === activePaneId)) return; + setActivePaneId(entries[0]?.surfaceId ?? null); + }, [entries, activePaneId]); + + // One attachment at a time: on every active-pane change, attach it with the + // pane's current xterm dims (if it exists yet), then refit through the now- + // valid attached resize path. + useEffect(() => { + if (!activePaneId) return; + const term = getTerminalInstance(activePaneId); + const dims = term ? { cols: term.cols, rows: term.rows } : null; + void activatePane(adapter, activePaneId, dims, refitSession); + }, [adapter, activePaneId]); + + const wallSessions = useMemo(() => directoryWallSessions(entries), [entries]); + const sessionItems = useMemo( + () => directorySessionItems(entries, activePaneId), + [entries, activePaneId], + ); + + const mouseStates = useSyncExternalStore( + subscribeToMouseSelection, + getMouseSelectionSnapshot, + getMouseSelectionSnapshot, + ); + const activeMouseState = activePaneId ? mouseStates.get(activePaneId) : undefined; + const cursorTouchAvailable = + activeMouseState?.mouseReporting !== undefined && activeMouseState.mouseReporting !== 'none'; + + // Touch mode × each pane's own reporting decides its mouse override — configure + // every pane so one switched away from isn't left in a stale override. + useEffect(() => { + for (const entry of entries) { + const reporting = mouseStates.get(entry.surfaceId)?.mouseReporting ?? 'none'; + const override = touchMode === 'selection' && reporting !== 'none' ? 'permanent' : 'off'; + setMouseOverride(entry.surfaceId, override); + } + }, [entries, mouseStates, touchMode]); + + const handleSendInput = useCallback( + (data: string) => { + if (activePaneId) adapter.writePty(activePaneId, data); + }, + [adapter, activePaneId], + ); + + const handlePaste = useCallback(async () => { + if (!activePaneId) return; + await doPaste(activePaneId); + }, [activePaneId]); + + return ( + setKeyboardMode('sessions')} + /> + } + interactive + activeTouchMode={touchMode} + onTouchModeChange={setTouchMode} + activeKeyboardMode={keyboardMode} + onKeyboardModeChange={setKeyboardMode} + cursorTouchAvailable={cursorTouchAvailable} + sessions={sessionItems} + onSessionSelect={setActivePaneId} + onSendInput={handleSendInput} + onPaste={handlePaste} + /> + ); +} diff --git a/lib/src/pocket/main.tsx b/lib/src/remote/pocket-app/main.tsx similarity index 100% rename from lib/src/pocket/main.tsx rename to lib/src/remote/pocket-app/main.tsx diff --git a/lib/src/pocket/pocket.css b/lib/src/remote/pocket-app/pocket.css similarity index 74% rename from lib/src/pocket/pocket.css rename to lib/src/remote/pocket-app/pocket.css index fe4de906..e64d1f65 100644 --- a/lib/src/pocket/pocket.css +++ b/lib/src/remote/pocket-app/pocket.css @@ -1,8 +1,9 @@ /* - * Self-contained dark theme for Dormouse Pocket. The Pocket app is served - * standalone (not inside VSCode / the wall), so it does not depend on the - * `--vscode-*` token system the main `lib` app uses; it just mirrors that - * dark, terminal-forward look with plain CSS variables. + * Dark, phone-first styling for the Dormouse Pocket auth views (setup/signin → + * hosts). Self-contained plain-CSS variables, independent of the `--vscode-*` + * token system the wall uses — the two never overlap (auth uses `pk-*`, the wall + * lives inside `MobileTerminalUi`). Imported after `index.css`, so these + * unlayered rules win over Tailwind's layered preflight. */ :root { @@ -21,10 +22,6 @@ color-scheme: dark; } -* { - box-sizing: border-box; -} - html, body { margin: 0; @@ -95,6 +92,14 @@ body { justify-content: center; } +/* --- Wall host: the flex slot MobileTerminalUi fills below the app header --- */ +.pk-wall-host { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + /* --- Cards / list rows --- */ .pk-card { background: var(--pk-surface); @@ -170,20 +175,6 @@ body { color: var(--pk-muted); } -.pk-badge.ringing { - color: var(--pk-danger); - border-color: color-mix(in srgb, var(--pk-danger) 45%, transparent); -} - -.pk-badge.todo { - color: var(--pk-accent); - border-color: color-mix(in srgb, var(--pk-accent) 45%, transparent); -} - -.pk-badge.activity { - text-transform: capitalize; -} - /* --- Buttons --- */ .pk-btn { font: inherit; @@ -296,67 +287,3 @@ body { cursor: pointer; text-align: left; } - -.pk-spinner { - color: var(--pk-muted); - font-size: 13px; -} - -/* --- Terminal view --- */ -.pk-term-wrap { - flex: 1 1 auto; - min-height: 0; - position: relative; - background: #000; - overflow: hidden; -} - -.pk-term-host { - position: absolute; - inset: 0; - padding: 4px; -} - -.pk-term-closed { - position: absolute; - inset: 0; - display: grid; - place-items: center; - color: var(--pk-muted); - font-size: 13px; - background: color-mix(in srgb, #000 70%, transparent); -} - -.pk-keybar { - flex: 0 0 auto; - display: flex; - gap: 6px; - padding: 8px; - padding-bottom: max(8px, env(safe-area-inset-bottom)); - background: var(--pk-surface); - border-top: 1px solid var(--pk-border); - overflow-x: auto; -} - -.pk-key { - font: inherit; - font-size: 13px; - flex: 0 0 auto; - min-width: 44px; - min-height: 40px; - border-radius: 8px; - border: 1px solid var(--pk-border); - background: var(--pk-surface-raised); - color: var(--pk-fg); - cursor: pointer; - padding: 0 12px; -} - -.pk-key:active { - background: var(--pk-accent); - color: var(--pk-accent-fg); -} - -.pk-key.wide { - flex: 1 0 auto; -} diff --git a/lib/src/remote/pocket-app/wall-model.test.ts b/lib/src/remote/pocket-app/wall-model.test.ts new file mode 100644 index 00000000..20e7393c --- /dev/null +++ b/lib/src/remote/pocket-app/wall-model.test.ts @@ -0,0 +1,124 @@ +/** + * Pure wall-model logic: directory snapshots → MobileWall/MobileTerminalUi + * session shapes, and the active-pane-change → setActivePane sequencing the + * wall performs (exercised against a recording fake adapter). + */ + +import { describe, expect, it } from 'vitest'; +import type { DirectoryEntry } from 'server-lib-common'; + +import { + activatePane, + directorySessionItems, + directoryWallSessions, + type PaneActivator, +} from './wall-model'; + +function entry(surfaceId: string, over: Partial = {}): DirectoryEntry { + return { + paneRef: surfaceId, + surfaceId, + type: 'terminal', + title: surfaceId, + focused: false, + ringing: false, + hasTODO: false, + ...over, + }; +} + +describe('directoryWallSessions', () => { + it('maps id = surfaceId and title from the entry, in Host order', () => { + const sessions = directoryWallSessions([entry('s1', { title: 'zsh' }), entry('s2', { title: 'vim' })]); + expect(sessions).toEqual([ + { id: 's1', title: 'zsh' }, + { id: 's2', title: 'vim' }, + ]); + }); + + it('falls back to a default title when the Host sends an empty one', () => { + expect(directoryWallSessions([entry('s1', { title: '' })])).toEqual([{ id: 's1', title: 'Terminal' }]); + }); +}); + +describe('directorySessionItems', () => { + it('marks the active pane and carries title/secondary', () => { + const items = directorySessionItems( + [entry('s1', { title: 'zsh', cwd: '/home/me' }), entry('s2', { title: 'vim' })], + 's2', + ); + expect(items).toEqual([ + { id: 's1', title: 'zsh', secondary: '/home/me', active: false, status: undefined, todo: false }, + { id: 's2', title: 'vim', secondary: null, active: true, status: undefined, todo: false }, + ]); + }); + + it('maps ringing → ALERT_RINGING status and hasTODO → todo pill', () => { + const [ringingItem, todoItem] = directorySessionItems( + [entry('s1', { ringing: true }), entry('s2', { hasTODO: true })], + null, + ); + expect(ringingItem.status).toBe('ALERT_RINGING'); + expect(ringingItem.todo).toBe(false); + expect(todoItem.status).toBeUndefined(); + expect(todoItem.todo).toBe(true); + }); + + it('uses activity as the secondary line when there is no cwd', () => { + const [item] = directorySessionItems([entry('s1', { activity: 'running' })], null); + expect(item.secondary).toBe('running'); + }); + + it('prefers cwd over activity for the secondary line', () => { + const [item] = directorySessionItems([entry('s1', { activity: 'running', cwd: '/tmp' })], null); + expect(item.secondary).toBe('/tmp'); + }); +}); + +describe('activatePane', () => { + class RecordingAdapter implements PaneActivator { + readonly calls: Array<{ id: string; cols?: number; rows?: number }> = []; + async setActivePane(id: string, cols?: number, rows?: number): Promise { + this.calls.push({ id, cols, rows }); + } + } + + it('forwards the pane dims and runs onAttached after the attach resolves', async () => { + const adapter = new RecordingAdapter(); + const order: string[] = []; + await activatePane(adapter, 's1', { cols: 40, rows: 60 }, (id) => order.push(`attached:${id}`)); + + expect(adapter.calls).toEqual([{ id: 's1', cols: 40, rows: 60 }]); + expect(order).toEqual(['attached:s1']); + }); + + it('passes undefined dims when none are known (adapter default + registry corrects)', async () => { + const adapter = new RecordingAdapter(); + await activatePane(adapter, 's1', null); + expect(adapter.calls).toEqual([{ id: 's1', cols: undefined, rows: undefined }]); + }); + + it('issues one setActivePane per active-pane change, in order', async () => { + const adapter = new RecordingAdapter(); + await activatePane(adapter, 's1', { cols: 80, rows: 24 }); + await activatePane(adapter, 's2', { cols: 100, rows: 30 }); + expect(adapter.calls).toEqual([ + { id: 's1', cols: 80, rows: 24 }, + { id: 's2', cols: 100, rows: 30 }, + ]); + }); + + it('waits for an async attach before signaling onAttached', async () => { + let release: (() => void) | null = null; + const adapter: PaneActivator = { + setActivePane: () => new Promise((resolve) => { release = resolve; }), + }; + const order: string[] = []; + const done = activatePane(adapter, 's1', null, () => order.push('attached')); + + expect(order).toEqual([]); // still attaching + release?.(); + await done; + expect(order).toEqual(['attached']); + }); +}); diff --git a/lib/src/remote/pocket-app/wall-model.ts b/lib/src/remote/pocket-app/wall-model.ts new file mode 100644 index 00000000..3a5ca65f --- /dev/null +++ b/lib/src/remote/pocket-app/wall-model.ts @@ -0,0 +1,86 @@ +/** + * Pure glue between a remote `directory.snapshot` and the mobile wall UI. Kept + * free of React and the terminal registry so it is unit-testable in isolation + * (`wall-model.test.ts`). + * + * Two shapes come out of one directory snapshot: + * - {@link directoryWallSessions} — the `{id,title}` list `MobileWall` renders + * (which pane is live + its header title). + * - {@link directorySessionItems} — the badge-carrying items the session list + * in `MobileTerminalUi` shows. On the remote side, badge state is + * Host-authoritative and rides the directory (not local terminal parsing), + * so this replaces `useMobileWallSessionItems` rather than layering on it. + */ + +import type { DirectoryEntry } from 'server-lib-common'; +import type { MobileWallSession } from '../../components/MobileWall'; +import type { MobileTerminalSessionItem } from '../../components/MobileTerminalUi'; +import type { SessionStatus } from '../../lib/terminal-registry'; + +const DEFAULT_TITLE = 'Terminal'; + +/** Title for a surface, falling back to a friendly default when the Host sends none. */ +function paneTitle(entry: DirectoryEntry): string { + return entry.title || DEFAULT_TITLE; +} + +/** The `{id,title}` sessions `MobileWall` mounts, in Host order. */ +export function directoryWallSessions(entries: DirectoryEntry[]): MobileWallSession[] { + return entries.map((entry) => ({ id: entry.surfaceId, title: paneTitle(entry) })); +} + +/** + * Map the directory snapshot onto the affordances a {@link MobileTerminalSessionItem} + * exposes: `ringing` → `ALERT_RINGING` (the only status the session list renders + * a bell for), `hasTODO` → the TODO pill, and `cwd`/`activity` → the secondary + * line. `id` is the surfaceId so the registry binds each pane's xterm by it. + */ +export function directorySessionItems( + entries: DirectoryEntry[], + activeSurfaceId: string | null, +): MobileTerminalSessionItem[] { + return entries.map((entry) => ({ + id: entry.surfaceId, + title: paneTitle(entry), + secondary: secondaryLine(entry), + active: entry.surfaceId === activeSurfaceId, + status: statusFor(entry), + todo: entry.hasTODO, + })); +} + +function statusFor(entry: DirectoryEntry): SessionStatus | undefined { + return entry.ringing ? 'ALERT_RINGING' : undefined; +} + +function secondaryLine(entry: DirectoryEntry): string | null { + if (entry.cwd) return entry.cwd; + if (entry.activity && entry.activity !== 'unknown') return entry.activity; + return null; +} + +export interface PaneDims { + cols: number; + rows: number; +} + +/** The slice of {@link RemotePtyAdapter} the wall drives on an active-pane change. */ +export interface PaneActivator { + setActivePane(id: string, cols?: number, rows?: number): Promise | void; +} + +/** + * Attach `id` as the active pane, forwarding the pane's current dims when known + * (else the adapter defaults and the registry's resize path corrects it), then + * run `onAttached` — the wall uses it to refit xterm through the now-valid, + * attached resize path. Awaiting keeps the refit strictly after the attach. + */ +export async function activatePane( + adapter: PaneActivator, + id: string, + dims: PaneDims | null, + onAttached?: (id: string) => void, +): Promise { + await Promise.resolve(adapter.setActivePane(id, dims?.cols, dims?.rows)); + onAttached?.(id); +} diff --git a/lib/vite.pocket.config.ts b/lib/vite.pocket.config.ts index 385907d3..a5fb72ab 100644 --- a/lib/vite.pocket.config.ts +++ b/lib/vite.pocket.config.ts @@ -1,14 +1,17 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; // Second entry, separate from the main `lib` app (index.html): the standalone // Pocket phone web app. Its HTML lives in `pocket/index.html` and pulls in -// `src/pocket/main.tsx`; the build lands in `dist-pocket/` for the server to -// serve statically (docs/specs/server.md "Pocket side"). No Tailwind/VSCode -// theme plumbing — Pocket ships its own self-contained CSS. +// `src/remote/pocket-app/main.tsx`; the build lands in `dist-pocket/` for the +// server to serve statically (docs/specs/pocket-app.md). It shares the full +// terminal UI (`MobileTerminalUi`/`MobileWall`) with the main app, so it needs +// the same Tailwind + `--vscode-*` theme plumbing (`src/index.css`); the auth +// views layer their own self-contained `pocket.css` on top. export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], root: fileURLToPath(new URL("./pocket", import.meta.url)), resolve: { dedupe: ["react", "react-dom"], From 1efb176c2745ea718e35841543c1111d1b229a1f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 16:46:58 -0700 Subject: [PATCH 17/52] chore(server): gitignore the runtime state dir server/data holds the live account.json and hosts.json (passkey public keys and bearer host tokens) whenever the server runs with the default DORMOUSE_STATE_DIR; it must never be committable. Co-Authored-By: Claude Fable 5 --- server/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 server/.gitignore diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..8be0fc58 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +# Selfhost server runtime state (accounts, passkeys, host tokens) — never commit. +data/ From 5c2220b81a0aef7f7f373312e6de68f6a5ee7ef3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 19:03:19 -0700 Subject: [PATCH 18/52] refactor(remote,server): consolidate duplicated helpers behind shared contracts - clampTerminalDimension in wire.ts replaces three per-file clamps (host remote-api, client adapter, fake-host harness) - RemoteWebSocket in lib/src/remote/ws.ts unifies the identical PocketSocket and WebSocketLike interfaces - JsonFileStore base class extracts the atomic-write + mutex machinery shared by AccountStore and HostStore - readPasswordGated dedupes the three password-gated routes; relay.ts hoists the per-case client guard; RemoteApiSession gains #resolveSurface; directory-collect stops double-fetching pane state; loadHostAcl is injectable and owns the fallback-to-empty path Review fixes folded in: the SPA deep-link fallback re-reads index.html per request (a cached copy would point at deleted content-hashed assets after an in-place Pocket rebuild, and a read failure now degrades to a 404 instead of crashing createApp at startup), and loadHostAcl's fallback logs a console.warn so a dropped ACL is explicable rather than silently de-pairing every client. Co-Authored-By: Claude Fable 5 --- lib/src/remote/client/pocket-client.ts | 11 +-- lib/src/remote/client/remote-adapter.ts | 13 ++- lib/src/remote/client/webauthn.ts | 12 +-- lib/src/remote/host/acl.ts | 18 +++- lib/src/remote/host/activation.ts | 2 +- lib/src/remote/host/directory-collect.ts | 16 +-- lib/src/remote/host/remote-api.ts | 56 ++++++----- lib/src/remote/host/remote-host.ts | 15 +-- lib/src/remote/ws.ts | 14 +++ server-lib-common/src/remote/wire.ts | 11 +++ server/src/app.ts | 39 +++++--- server/src/relay.ts | 54 +++++----- server/src/state.ts | 121 ++++++++++++----------- server/test/harness/fake-host.mjs | 13 +-- 14 files changed, 212 insertions(+), 183 deletions(-) create mode 100644 lib/src/remote/ws.ts diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 8ad2953e..e5355366 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -47,17 +47,10 @@ import { type TerminalResizeEvent, } from 'server-lib-common'; import type { WebAuthnClient } from './webauthn'; +import type { RemoteWebSocket } from '../ws'; /** The slice of a WebSocket the client uses; a browser `WebSocket` satisfies it. */ -export interface PocketSocket { - send(data: string): void; - close(): void; - addEventListener( - type: 'open' | 'message' | 'close' | 'error', - handler: (ev: unknown) => void, - ): void; - readyState: number; -} +export type PocketSocket = RemoteWebSocket; /** * Persistent per-device state. Passkey public keys are stashed at registration diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index 10e5deea..64be464b 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -46,6 +46,7 @@ */ import { + clampTerminalDimension, fromBase64Url, toBase64Url, utf8Decode, @@ -101,8 +102,6 @@ export class RemotePtyAdapter implements PlatformAdapter { /** Latest directory snapshot, in Host order. */ #entries: DirectoryEntry[] = []; - /** surfaceId → entry, for O(1) lookups. */ - #panes = new Map(); /** Memoized directory.watch start; also the "started" guard. */ #watchPromise: Promise | null = null; @@ -173,7 +172,7 @@ export class RemotePtyAdapter implements PlatformAdapter { /** The directory entry for a surface, or undefined. */ getPaneEntry(surfaceId: string): DirectoryEntry | undefined { - return this.#panes.get(surfaceId); + return this.#entries.find((entry) => entry.surfaceId === surfaceId); } /** Subscribe to directory snapshots; returns an unsubscribe fn. */ @@ -197,7 +196,6 @@ export class RemotePtyAdapter implements PlatformAdapter { #onSnapshot(entries: DirectoryEntry[]): void { this.#entries = entries; - this.#panes = new Map(entries.map((entry) => [entry.surfaceId, entry])); this.#emitPtyList(); for (const listener of this.#directoryListeners) listener(entries); } @@ -364,7 +362,8 @@ export class RemotePtyAdapter implements PlatformAdapter { /** Coerce a requested size to positive integers, falling back to `fallback`. */ function normalizeSize(cols: number | undefined, rows: number | undefined, fallback: Size): Size { - const c = cols !== undefined && Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : fallback.cols; - const r = rows !== undefined && Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : fallback.rows; - return { cols: c, rows: r }; + return { + cols: clampTerminalDimension(cols, fallback.cols), + rows: clampTerminalDimension(rows, fallback.rows), + }; } diff --git a/lib/src/remote/client/webauthn.ts b/lib/src/remote/client/webauthn.ts index 93162018..80df14b7 100644 --- a/lib/src/remote/client/webauthn.ts +++ b/lib/src/remote/client/webauthn.ts @@ -27,12 +27,6 @@ export interface WebAuthnClient { getAssertion(challenge: string, rpId: string): Promise; } -/** - * Create a discoverable ES256 passkey. `attestation: 'none'` keeps the server - * dependency-free (it trusts the browser-provided SPKI key); `residentKey` - * and `userVerification` are `'preferred'` so it works on the widest range of - * authenticators while still preferring a resident, verified credential. - */ /** * Copy into a fresh `ArrayBuffer`-backed view. WebAuthn's `BufferSource` * parameters demand `ArrayBuffer` (not `SharedArrayBuffer`), which the generic @@ -42,6 +36,12 @@ function toBufferSource(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; } +/** + * Create a discoverable ES256 passkey. `attestation: 'none'` keeps the server + * dependency-free (it trusts the browser-provided SPKI key); `residentKey` + * and `userVerification` are `'preferred'` so it works on the widest range of + * authenticators while still preferring a resident, verified credential. + */ async function registerPasskey( challenge: string, rpId: string, diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts index 230e330c..fa2bde5c 100644 --- a/lib/src/remote/host/acl.ts +++ b/lib/src/remote/host/acl.ts @@ -41,11 +41,21 @@ export function saveAclRecords(hostId: string, records: readonly HostAclRecord[] } } -/** Rehydrate a live `HostAcl` from persisted records. */ -export function loadHostAcl(hostId: string): HostAcl { +/** + * Rehydrate a live `HostAcl` from persisted records, falling back to an empty + * ACL if the stored records cannot be reconciled with `hostId`. `loadRecords` + * is injectable so callers (and tests) can supply their own source. + */ +export function loadHostAcl( + hostId: string, + loadRecords: (hostId: string) => HostAclRecord[] = loadAclRecords, +): HostAcl { try { - return HostAcl.fromRecords(hostId, loadAclRecords(hostId)); - } catch { + return HostAcl.fromRecords(hostId, loadRecords(hostId)); + } catch (error) { + // Fail closed but loudly: an empty ACL silently de-pairs every client, so + // "all my devices vanished" must at least be explicable from the console. + console.warn(`remote-host: could not load ACL for ${hostId}; starting empty`, error); return new HostAcl(hostId); } } diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index af3314bb..ee875f57 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -27,7 +27,7 @@ function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { new RemoteApiSession({ hostId: opts.hostId, // The controller sends the untyped remote-api payload inside a `msg`. - send: (payload) => opts.send(payload), + send: opts.send, }), }); host.start(); diff --git a/lib/src/remote/host/directory-collect.ts b/lib/src/remote/host/directory-collect.ts index 976fdba0..56f3e3b7 100644 --- a/lib/src/remote/host/directory-collect.ts +++ b/lib/src/remote/host/directory-collect.ts @@ -10,7 +10,6 @@ import type { DirectoryEntry } from 'server-lib-common'; import { buildAppTitleResolver, deriveHeader, - getActivity, getActivitySnapshot, getTerminalPaneState, getTerminalPaneStateSnapshot, @@ -26,13 +25,16 @@ export function collectDirectorySnapshot(): DirectoryEntry[] { const ids = [...registry.keys()]; // The wall derives titles across all visible panes so duplicates disambiguate; - // feed it the same set here. + // feed it the same set here. Reuse these per-pane states below rather than + // re-fetching (each miss would allocate a fresh default twice). const allPanes = ids.map((id) => getTerminalPaneState(id)); const active = typeof document !== 'undefined' ? document.activeElement : null; - const inputs: DirectoryPaneInput[] = ids.map((id) => { - const pane = getTerminalPaneState(id); - const activity = getActivity(id); + const inputs: DirectoryPaneInput[] = ids.map((id, i) => { + const pane = allPanes[i]!; + // Every registry id is present in the activity snapshot (a live pane always + // reads non-null), so this is the same object `getActivity(id)` would build. + const activity = activityStates.get(id); const element = registry.get(id)?.element ?? null; const focused = !!element && !!active && element.contains(active); const title = resolveDisplayPrimary( @@ -45,8 +47,8 @@ export function collectDirectorySnapshot(): DirectoryEntry[] { title, focused, pane, - ringing: activity.status === 'ALERT_RINGING', - hasTODO: activity.todo === true, + ringing: activity?.status === 'ALERT_RINGING', + hasTODO: activity?.todo === true, }; }); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 41c112b4..0156f085 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -20,6 +20,7 @@ import { REMOTE_EVENTS, REMOTE_METHODS, + clampTerminalDimension, fromBase64Url, toBase64Url, utf8Decode, @@ -35,6 +36,7 @@ import { } from 'server-lib-common'; import { getPlatform } from '../../lib/platform'; import { registry } from '../../lib/terminal-store'; +import type { TerminalEntry } from '../../lib/terminal-store'; import { subscribeToActivity } from '../../lib/session-activity-store'; import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; import { collectDirectorySnapshot } from './directory-collect'; @@ -127,6 +129,23 @@ export class RemoteApiSession { this.#send({ subId, event, data }); } + /** + * Resolve the live terminal a `surface.*` request targets, or fail the request + * (and return null) if the params or the surface are missing. Shared by + * attach/write/resize so the not-found contract lives in one place. + */ + #resolveSurface

( + request: RemoteRequest, + ): { params: P; entry: TerminalEntry } | null { + const params = request.params as P | undefined; + const entry = params ? registry.get(params.surfaceId) : undefined; + if (!params || !entry) { + this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + return null; + } + return { params, entry }; + } + // --- Methods --- #hello(request: RemoteRequest): void { @@ -180,18 +199,16 @@ export class RemoteApiSession { } #attach(request: RemoteRequest): void { - const params = request.params as AttachParams | undefined; - const entry = params ? registry.get(params.surfaceId) : undefined; - if (!params || !entry) { - return this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); - } + const resolved = this.#resolveSurface(request); + if (!resolved) return; + const { params, entry } = resolved; // v1: one attachment per session — replace any prior stream. this.#teardownAttachment(); const ptyId = entry.ptyId; const term = entry.terminal; - const cols = clampDimension(params.cols, term.cols); - const rows = clampDimension(params.rows, term.rows); + const cols = clampTerminalDimension(params.cols, term.cols); + const rows = clampTerminalDimension(params.rows, term.rows); const platform = getPlatform(); // Attach-is-the-resize: resizing the real xterm fires its onResize handler, @@ -233,25 +250,21 @@ export class RemoteApiSession { } #write(request: RemoteRequest): void { - const params = request.params as TerminalWriteParams | undefined; - const entry = params ? registry.get(params.surfaceId) : undefined; - if (!params || !entry) { - return this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); - } + const resolved = this.#resolveSurface(request); + if (!resolved) return; + const { params, entry } = resolved; // Feed the existing PTY input path; the local echo returns via onPtyData. getPlatform().writePty(entry.ptyId, utf8Decode(fromBase64Url(params.bytes))); this.#ok(request, {}); } #resize(request: RemoteRequest): void { - const params = request.params as TerminalResizeParams | undefined; - const entry = params ? registry.get(params.surfaceId) : undefined; - if (!params || !entry) { - return this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); - } + const resolved = this.#resolveSurface(request); + if (!resolved) return; + const { params, entry } = resolved; const term = entry.terminal; - const cols = clampDimension(params.cols, term.cols); - const rows = clampDimension(params.rows, term.rows); + const cols = clampTerminalDimension(params.cols, term.cols); + const rows = clampTerminalDimension(params.rows, term.rows); if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; this.#ok(request, result); @@ -265,8 +278,3 @@ export class RemoteApiSession { this.#attachment = null; } } - -function clampDimension(value: number, fallback: number): number { - if (!Number.isFinite(value)) return fallback; - return Math.max(1, Math.floor(value)); -} diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts index a3ee254f..cdb95d9a 100644 --- a/lib/src/remote/host/remote-host.ts +++ b/lib/src/remote/host/remote-host.ts @@ -33,7 +33,8 @@ import { type ServerToHostFrame, } from 'server-lib-common'; import type { HostEnrollment } from './enrollment'; -import { loadAclRecords, saveAclRecords } from './acl'; +import type { RemoteWebSocket } from '../ws'; +import { loadHostAcl, saveAclRecords } from './acl'; import { enqueuePairingApproval, resolvePairingApproval, @@ -47,12 +48,7 @@ export interface RemoteApiSessionLike { } /** Minimal WebSocket surface, so tests can inject a fake. */ -export interface WebSocketLike { - send(data: string): void; - close(): void; - addEventListener(type: 'open' | 'message' | 'close' | 'error', handler: (ev: unknown) => void): void; - readyState: number; -} +export type WebSocketLike = RemoteWebSocket; export type RemoteHostStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'stopped'; @@ -110,10 +106,7 @@ export class RemoteHost { this.#enrollment = options.enrollment; this.#policy = { rpId: options.enrollment.rpId, origin: options.enrollment.origin }; this.#now = options.now ?? (() => Date.now()); - this.#acl = HostAcl.fromRecords( - options.enrollment.hostId, - (options.loadAcl ?? loadAclRecords)(options.enrollment.hostId), - ); + this.#acl = loadHostAcl(options.enrollment.hostId, options.loadAcl); this.#challenges = new HostChallengeIssuer({ now: this.#now }); this.#ceremony = new PairingCeremony(this.#acl, { now: this.#now }); diff --git a/lib/src/remote/ws.ts b/lib/src/remote/ws.ts new file mode 100644 index 00000000..92a74a12 --- /dev/null +++ b/lib/src/remote/ws.ts @@ -0,0 +1,14 @@ +/** + * The minimal WebSocket surface the remote client and host actually use — just + * enough to send, close, and listen, so tests can inject a fake in place of a + * real browser `WebSocket`. Shared by both sides so the contract cannot drift. + */ +export interface RemoteWebSocket { + send(data: string): void; + close(): void; + addEventListener( + type: 'open' | 'message' | 'close' | 'error', + handler: (ev: unknown) => void, + ): void; + readyState: number; +} diff --git a/server-lib-common/src/remote/wire.ts b/server-lib-common/src/remote/wire.ts index ccf1687b..bcada9f1 100644 --- a/server-lib-common/src/remote/wire.ts +++ b/server-lib-common/src/remote/wire.ts @@ -224,3 +224,14 @@ export interface TerminalResizeParams { cols: number; rows: number; } + +/** + * Coerce a requested terminal dimension (cols or rows) to a positive integer, + * falling back to `fallback` when the value is absent or not finite. Shared so + * the Host api, the client adapter, and the test harness all sanitize sizes the + * same way. + */ +export function clampTerminalDimension(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.max(1, Math.floor(value)); +} diff --git a/server/src/app.ts b/server/src/app.ts index 349029a6..6eeb4e68 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -161,6 +161,20 @@ export function createApp(config: AppConfig): CreatedApp { const passwordOk = (provided: unknown): boolean => typeof provided === 'string' && timingSafeEqual(sha256(provided), expectedPasswordHash); + // Read a JSON body and enforce the setup password. Returns the parsed body, or + // a ready 401 `Response` (after the standard failure delay) the caller returns + // as-is — so the three password-gated routes share one policy. + async function readPasswordGated( + c: Context, + ): Promise { + const body = await readJson(c); + if (!body || !passwordOk(body.password)) { + await delay(PASSWORD_FAILURE_DELAY_MS); + return c.json({ error: 'invalid setup password' }, 401); + } + return body; + } + const app = new Hono(); // The WS relay routes need the http server that `serve()` builds later, so the // adapter is created here and `injectWebSocket` is handed back to the caller. @@ -178,22 +192,16 @@ export function createApp(config: AppConfig): CreatedApp { // --- Setup: password-gated passkey registration ------------------------- app.post(API_ROUTES.setupBegin, async (c) => { - const body = await readJson(c); - if (!body || !passwordOk(body.password)) { - await delay(PASSWORD_FAILURE_DELAY_MS); - return c.json({ error: 'invalid setup password' }, 401); - } + const body = await readPasswordGated(c); + if (body instanceof Response) return body; const { challenge } = setupChallenges.issue(); const res: SetupBeginResponse = { challenge, rpId, accountId: SELFHOST_ACCOUNT_ID }; return c.json(res); }); app.post(API_ROUTES.setupFinish, async (c) => { - const body = await readJson(c); - if (!body || !passwordOk(body.password)) { - await delay(PASSWORD_FAILURE_DELAY_MS); - return c.json({ error: 'invalid setup password' }, 401); - } + const body = await readPasswordGated(c); + if (body instanceof Response) return body; // Decode and sanity-check clientDataJSON — we do NOT parse attestation // (attestation: 'none'); the browser already handed us the public key. @@ -285,11 +293,8 @@ export function createApp(config: AppConfig): CreatedApp { // --- Host enrollment: password-gated, appends to hosts.json -------------- app.post(API_ROUTES.hostEnroll, async (c) => { - const body = await readJson(c); - if (!body || !passwordOk(body.password)) { - await delay(PASSWORD_FAILURE_DELAY_MS); - return c.json({ error: 'invalid setup password' }, 401); - } + const body = await readPasswordGated(c); + if (body instanceof Response) return body; const label = typeof body.label === 'string' ? body.label : ''; const host = await hostStore.enroll(label); // The Host enforces `origin`/`rpId` as its ConnectionPolicy (server.md). @@ -416,6 +421,10 @@ function registerPocketServing(app: Hono, pocketDir?: string): void { // path relative to cwd is the portable way to point it at an arbitrary dir. const root = relative(process.cwd(), pocketDir) || '.'; app.get('/*', serveStatic({ root })); + // Re-read the SPA shell per deep-link fallback: a Pocket rebuild swaps in an + // index.html referencing new content-hashed assets, and a cached copy would + // keep pointing at deleted files until the server restarts. The fallback is + // not a hot path, and a read failure degrades to a 404 instead of a crash. app.get('*', async (c) => { const html = await readFile(indexHtmlPath, 'utf8').catch(() => null); return html ? c.html(html) : c.notFound(); diff --git a/server/src/relay.ts b/server/src/relay.ts index e708b9f9..6d86a009 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -105,56 +105,48 @@ export class RelayHub { onHostFrame(host: HostConn, raw: string): void { const frame = parseFrame(raw); if (!frame || typeof frame.t !== 'string' || typeof frame.clientId !== 'string') return; + // Every host frame addresses a specific client; if it has already gone, + // there is nothing to route (and no session to establish). const client = this.#clients.get(frame.clientId); + if (!client) return; switch (frame.t) { case 'pair-result': - if (client) { - this.#toClient(client, { - t: 'pair-result', - approved: frame.approved, - record: frame.record, - error: frame.error, - }); - } + this.#toClient(client, { + t: 'pair-result', + approved: frame.approved, + record: frame.record, + error: frame.error, + }); return; case 'challenge': // The client's `challenge` frame carries the originating hostId (the // host frame does not — the hub knows it from the socket). The server // also remembers the challenge so it can do its half of `connect2` // freshness validation before forwarding. - if (client) { - this.#gate.observeChallenge( - client.clientId, - host.hostId, - frame.challenge, - frame.expiresAt, - ); - this.#toClient(client, { - t: 'challenge', - hostId: host.hostId, - challenge: frame.challenge, - expiresAt: frame.expiresAt, - }); - } + this.#gate.observeChallenge(client.clientId, host.hostId, frame.challenge, frame.expiresAt); + this.#toClient(client, { + t: 'challenge', + hostId: host.hostId, + challenge: frame.challenge, + expiresAt: frame.expiresAt, + }); return; case 'decision': // The Host is the final authority: an allowed decision is what // establishes the (host, client) session and unblocks `msg`. - if (frame.allowed && client) { + if (frame.allowed) { client.hostId = host.hostId; client.established = true; } - if (client) { - this.#toClient(client, { - t: 'decision', - allowed: frame.allowed, - failures: frame.failures, - }); - } + this.#toClient(client, { + t: 'decision', + allowed: frame.allowed, + failures: frame.failures, + }); return; case 'msg': // Blocked unless this exact host/client pair has an established session. - if (client && client.established && client.hostId === host.hostId) { + if (client.established && client.hostId === host.hostId) { this.#toClient(client, { t: 'msg', data: frame.data }); } return; diff --git a/server/src/state.ts b/server/src/state.ts index e4d95e84..46ddf90a 100644 --- a/server/src/state.ts +++ b/server/src/state.ts @@ -41,29 +41,68 @@ export class DuplicateCredentialError extends Error { } } -export class AccountStore { +/** + * A tiny JSON-file store: the whole file is one JSON value, written through a + * temp-file-plus-rename so a crash mid-write can never leave a half-written + * (unparseable) file, with mutations serialized through a promise chain so two + * concurrent read-modify-writes cannot clobber each other. Subclasses layer + * their find/append logic on top. Deliberately not a database (see the module + * header). + */ +abstract class JsonFileStore { readonly #stateDir: string; readonly #path: string; - readonly #now: () => number; - /** Serializes mutations so overlapping appends do not lose writes. */ + /** Wall clock, injectable for deterministic tests. */ + protected readonly now: () => number; + /** Serializes mutations so overlapping writes do not lose each other. */ #tail: Promise = Promise.resolve(); - constructor(stateDir: string, now: () => number = () => Date.now()) { + constructor(stateDir: string, fileName: string, now: () => number) { this.#stateDir = stateDir; - this.#path = join(stateDir, 'account.json'); - this.#now = now; + this.#path = join(stateDir, fileName); + this.now = now; } - /** Read `account.json`, or `null` if the account has not been created yet. */ - async load(): Promise { + /** Read and parse the file, or `fallback` if it does not exist yet. */ + protected async read(fallback: T): Promise { let raw: string; try { raw = await readFile(this.#path, 'utf8'); } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback; throw err; } - return JSON.parse(raw) as Account; + return JSON.parse(raw) as T; + } + + /** Overwrite the whole file atomically (temp file + rename). */ + protected async writeAtomic(value: unknown): Promise { + await mkdir(this.#stateDir, { recursive: true }); + const tmp = `${this.#path}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + await rename(tmp, this.#path); + } + + /** + * Run `mutate` under the mutex. It is chained onto the tail regardless of + * whether the previous op resolved or rejected, so one failure cannot wedge + * the queue. + */ + protected mutate(mutate: () => Promise): Promise { + const result = this.#tail.then(mutate, mutate); + this.#tail = result.catch(() => undefined); + return result; + } +} + +export class AccountStore extends JsonFileStore { + constructor(stateDir: string, now: () => number = () => Date.now()) { + super(stateDir, 'account.json', now); + } + + /** Read `account.json`, or `null` if the account has not been created yet. */ + load(): Promise { + return this.read(null); } /** Look up a stored passkey by its base64url credential id. */ @@ -78,7 +117,7 @@ export class AccountStore { * credential id already exists. Runs under the mutex. */ appendPasskey(passkey: Omit): Promise { - const run = async (): Promise => { + return this.mutate(async () => { const account: Account = (await this.load()) ?? { accountId: SELFHOST_ACCOUNT_ID, passkeys: [], @@ -86,22 +125,10 @@ export class AccountStore { if (account.passkeys.some((p) => p.credentialId === passkey.credentialId)) { throw new DuplicateCredentialError(passkey.credentialId); } - account.passkeys.push({ ...passkey, createdAt: this.#now() }); - await this.#writeAtomic(account); + account.passkeys.push({ ...passkey, createdAt: this.now() }); + await this.writeAtomic(account); return account; - }; - // Chain onto the tail regardless of whether the previous op resolved or - // rejected, so one failed append does not wedge the queue. - const result = this.#tail.then(run, run); - this.#tail = result.catch(() => undefined); - return result; - } - - async #writeAtomic(account: Account): Promise { - await mkdir(this.#stateDir, { recursive: true }); - const tmp = `${this.#path}.${randomUUID()}.tmp`; - await writeFile(tmp, `${JSON.stringify(account, null, 2)}\n`, 'utf8'); - await rename(tmp, this.#path); + }); } } @@ -118,29 +145,14 @@ export interface StoredHost { * append-only JSON array, atomic writes, and a mutex so concurrent enrollments * cannot lose a write. Revocation is deleting a line by hand (POC guardrail). */ -export class HostStore { - readonly #stateDir: string; - readonly #path: string; - readonly #now: () => number; - /** Serializes mutations so overlapping enrollments do not lose writes. */ - #tail: Promise = Promise.resolve(); - +export class HostStore extends JsonFileStore { constructor(stateDir: string, now: () => number = () => Date.now()) { - this.#stateDir = stateDir; - this.#path = join(stateDir, 'hosts.json'); - this.#now = now; + super(stateDir, 'hosts.json', now); } /** Read `hosts.json`, or `[]` if no host has been enrolled yet. */ - async list(): Promise { - let raw: string; - try { - raw = await readFile(this.#path, 'utf8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; - throw err; - } - return JSON.parse(raw) as StoredHost[]; + list(): Promise { + return this.read([]); } /** Look up an enrolled host by its bearer token (the `/ws/host` credential). */ @@ -155,28 +167,17 @@ export class HostStore { * the mutex. */ enroll(label: string): Promise { - const run = async (): Promise => { + return this.mutate(async () => { const hosts = await this.list(); const host: StoredHost = { hostId: toBase64Url(randomBytes(16)), hostToken: toBase64Url(randomBytes(32)), label, - enrolledAt: this.#now(), + enrolledAt: this.now(), }; hosts.push(host); - await this.#writeAtomic(hosts); + await this.writeAtomic(hosts); return host; - }; - // Chain regardless of prior resolve/reject so one failure cannot wedge the queue. - const result = this.#tail.then(run, run); - this.#tail = result.catch(() => undefined); - return result; - } - - async #writeAtomic(hosts: StoredHost[]): Promise { - await mkdir(this.#stateDir, { recursive: true }); - const tmp = `${this.#path}.${randomUUID()}.tmp`; - await writeFile(tmp, `${JSON.stringify(hosts, null, 2)}\n`, 'utf8'); - await rename(tmp, this.#path); + }); } } diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs index 3d615aad..c312dc22 100644 --- a/server/test/harness/fake-host.mjs +++ b/server/test/harness/fake-host.mjs @@ -29,6 +29,7 @@ import { WS_ROUTES, WS_TOKEN_PARAM, authorizeConnection, + clampTerminalDimension, fromBase64Url, toBase64Url, utf8Decode, @@ -182,8 +183,8 @@ export class FakeHost extends EventEmitter { case REMOTE_METHODS.surfaceAttach: { const surface = this.#surface(params?.surfaceId); if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); - surface.cols = clampInt(params.cols, surface.cols); - surface.rows = clampInt(params.rows, surface.rows); + surface.cols = clampTerminalDimension(params.cols, surface.cols); + surface.rows = clampTerminalDimension(params.rows, surface.rows); this.attachments.set(clientId, { surfaceId: surface.surfaceId, subId: requestId }); ok({ cols: surface.cols, rows: surface.rows }); this.#emitData( @@ -209,8 +210,8 @@ export class FakeHost extends EventEmitter { case REMOTE_METHODS.terminalResize: { const surface = this.#surface(params?.surfaceId); if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); - surface.cols = clampInt(params.cols, surface.cols); - surface.rows = clampInt(params.rows, surface.rows); + surface.cols = clampTerminalDimension(params.cols, surface.cols); + surface.rows = clampTerminalDimension(params.rows, surface.rows); ok({ cols: surface.cols, rows: surface.rows }); const attachment = this.attachments.get(clientId); if (!attachment || attachment.surfaceId !== surface.surfaceId) return; @@ -294,7 +295,3 @@ export class FakeHost extends EventEmitter { } } -/** Coerce a requested terminal dimension to a positive integer, else `fallback`. */ -function clampInt(value, fallback) { - return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback; -} From c9e4ecd7da81850388987e76e585926d1eeec7dd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 19:23:18 -0700 Subject: [PATCH 19/52] docs: index the remote-control specs and packages in AGENTS.md Adds server/ and server-lib-common/ to the architecture and structure sections, lib/src/remote/ to the structure list, and spec entries for remote-security-model.md (read first for anything remote), remote-api.md, server.md, and pocket-app.md with read-this-when-touching file lists matching the existing format. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4e4206af..33f66bc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ pnpm build # build lib, vscode extension, and website - **standalone/** — Tauri desktop app with Node.js sidecar for native PTY via node-pty - **vscode-ext/** — VS Code extension wrapping the lib in a webview with native PTY backend - **website/** — Marketing website bundling part of the lib as an interactive demo +- **server/** — Hono coordinating server for remote control: selfhost accounts + passkey auth, WebSocket relay between Pocket clients and Hosts, serves the built Pocket app +- **server-lib-common/** — Runtime-agnostic security primitives + wire contract shared by `server`, the Host module in `lib`, and the Pocket app ## Project Structure @@ -25,6 +27,9 @@ pnpm build # build lib, vscode extension, and website - `standalone/src-tauri/` — Rust backend that bridges webview ↔ Node.js sidecar - `vscode-ext/` — VS Code extension (esbuild, node-pty via forked child process) - `website/` — Marketing site (Vite, uses FakePtyAdapter for demo) +- `server/` — Selfhost server (Hono; accounts in local JSON files, no database) +- `server-lib-common/` — Shared security + wire contract (bare ES2022 — no DOM or Node types) +- `lib/src/remote/` — Remote control: `host/` (laptop side), `client/` (phone-side protocol + `RemotePtyAdapter`), `pocket-app/` (Pocket shell), `ws.ts` (shared socket surface) ## Specs @@ -42,6 +47,10 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `dormouse-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/PlaygroundDesktop.tsx`, `website/src/pages/PocketPlayground.tsx`, `website/src/components/PocketTerminalExperience.tsx`, `website/src/lib/playground-routing.ts`, the `website/src/pages/Playground.tsx` and `website/src/pages/Pocket.tsx` redirect dispatchers, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), bracketed paste, smart URL/path extension, mouse-reporting override UI (icon + banner), and the state matrix for which layer owns mouse events. Read this when touching: `lib/src/lib/mouse-selection.ts`, `lib/src/lib/mouse-mode-observer.ts`, `lib/src/lib/clipboard.ts`, `lib/src/lib/rewrap.ts`, `lib/src/lib/selection-text.ts`, `lib/src/lib/smart-token.ts`, `lib/src/components/SelectionOverlay.tsx`, `lib/src/components/SelectionPopup.tsx`, the mouse icon / override banner / Cmd+C-V handling in `lib/src/components/Wall.tsx`, or the parser hooks + mouse listeners in `lib/src/lib/terminal-registry.ts`. +- **`docs/specs/remote-security-model.md`** — The trust model for remote control, built on two independent primitives: passkeys prove fresh user presence (user credentials, never device identities — they sync), and per-browser non-extractable device keys prove long-lived Client identity. The Host's local ACL authorizes the *pair* of a passkey credential and a device key; the pairing ceremony (local approval on the Host) is the only path into it, and the Host — never the Server — makes the final access decision. Covers the connection requirements (all layers must agree), storage durability (iOS PWA guidance), and device-key-loss recovery. Read this first for anything remote; the other three remote specs build on it. Read this when touching: `server-lib-common/src/security/` (deviceKey, challenge, passkey, acl, pairing, connection), `server/src/handshake.ts`, `lib/src/remote/host/{acl,pairing-approval,remote-host}.ts`, or `lib/src/remote/client/{device-key,webauthn}.ts`. +- **`docs/specs/remote-api.md`** — The protocol a Client speaks after `authorizeConnection`: one protocol at two consumption depths (phone = directory picker + one attached surface; VR = full wall, future). Defines the v1 scope (terminal-only decisions live in server.md), the directory (snapshot-only, `ringing`/`hasTODO`), attach-is-the-resize (the SIGWINCH repaint is the screen transfer — no snapshot format), last-attach-wins size authority with the "tethering to \" display, and the staged future work (in-flight replay, semantic command scrollback, thumbnails, grants, VR wall, WebRTC). Read this when touching: `server-lib-common/src/remote/wire.ts` (the fixed wire contract), `lib/src/remote/host/remote-api.ts`, `lib/src/remote/client/{pocket-client,remote-adapter}.ts`, or `server/test/harness/fake-host.mjs`. +- **`docs/specs/server.md`** — The selfhost coordinating server: env config (`DORMOUSE_SETUP_PASSWORD`, `DORMOUSE_ORIGIN`, …), two-JSON-file state (no database), "WebAuthn without a WebAuthn library" (registration via `response.getPublicKey()`, assertions via the same `verifyPasskeyAssertion` the Host uses), the HTTP API, the relay frame flow (pairing and connect sequence diagrams — one host challenge feeds both the passkey assertion and the device signature, so connecting costs one biometric prompt), the Host and Pocket side responsibilities, and "Running the POC" instructions. Read this when touching: `server/src/` (app, relay, handshake, state), `lib/src/remote/host/enrollment.ts`, or the `dev:pocket-server` flow. +- **`docs/specs/pocket-app.md`** — Pocket app architecture: the remote session is a `PlatformAdapter` (`RemotePtyAdapter` maps the PTY core onto remote-api one-to-one), so Pocket is auth screens + `MobileTerminalUi`/`MobileWall` — the same composition the website playground runs on `FakePtyAdapter`. Covers the `lib/src/remote/` module layout and the same-origin deployment rule (WebAuthn origin binding + Chrome PNA: selfhost server serves the bundle now; CloudFlare static + routed `/api`+`/ws` for SaaS later). Read this when touching: `lib/src/remote/client/remote-adapter.ts`, `lib/src/remote/pocket-app/`, `lib/vite.pocket.config.ts`, or the Pocket static serving in `server/src/app.ts`. When updating code covered by a spec, update the spec to match. When the two specs overlap (e.g. pane header elements appear in both), layout.md documents placement and sizing while alert.md documents behavior and visual states. From f827026e47b3c1ff835ce72e4ee846983d0f7c2e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 19:24:56 -0700 Subject: [PATCH 20/52] docs(specs): add the glossary callout to remote-api.md Per the AGENTS.md convention, specs using Pane/Surface vocabulary lead with a glossary blockquote; the Terminology section now defers to glossary.md as canonical and keeps only remote-specific usage. Co-Authored-By: Claude Fable 5 --- docs/specs/remote-api.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 4bdbbf8b..1609a81b 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -1,5 +1,7 @@ # Remote Surface API +> See `docs/specs/glossary.md` for the canonical Pane / Surface / Session model; this spec uses that vocabulary and adds only remote-specific terms (Viewer, and the wire-level `DirectoryEntry` projection of a pane). + This spec sketches the API a Client uses to view and control a Host's surfaces after a session has been authorized by the [remote security model](./remote-security-model.md). Nothing here weakens that @@ -65,15 +67,14 @@ nothing in the v1 protocol changes shape when it lands. # Terminology -Reuses the existing surface model (`dor/src/protocol.ts`, -`dor/src/commands/types.ts`): +`docs/specs/glossary.md` is canonical for **Pane** and **Surface**; the wire +shapes reuse the existing surface model (`dor/src/protocol.ts`, +`dor/src/commands/types.ts`). Remote-specific usage: -* **Surface** — the unit of content: `terminal`, `agent-browser`, or `iframe`. - Identified by `surfaceId`; carries `ref`, `paneRef`, `title`, `focused`, - `indexInPane`, `selectedInPane`. -* **Pane** — a tile on the wall holding one or more surfaces (one selected). - The phone's picker lists panes; attaching to a pane means attaching to its - selected surface. +* **Surface** — identified on the wire by `surfaceId`; carries `ref`, + `paneRef`, `title`, `focused`, `indexInPane`, `selectedInPane`. +* **Pane** — the phone's picker lists panes; attaching to a pane means + attaching to its selected surface. * **Wall** — the full layout tree (workspaces → windows → panes) plus geometry. Only VR consumes this. * **Viewer** — one connected Client session. Multiple viewers may coexist; the From 21c98e1710fd4805d3a04d83de4afda020990db0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 19:45:47 -0700 Subject: [PATCH 21/52] fix(standalone): resolve server-lib-common from source in tsc --noEmit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone `tsc --noEmit` typechecks lib SOURCE through the `dormouse-lib/* → ../lib/src/*` path map. Phase 1b wired `RemotePairingModalHost` into the shared `Wall`, so that graph now reaches `lib/src/remote/host/*`, which import `server-lib-common`. As a bare specifier that resolves to `server-lib-common`'s package `exports` (`./dist/index.d.ts`), which the standalone smoketest job never builds — so tsc reports TS2307 "Cannot find module 'server-lib-common'". Map it to source like `dormouse-lib` and `dor` so the typecheck needs no prior build. Verified with both sibling `dist/` dirs removed (the CI state): `npx tsc --noEmit` in `standalone` exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- standalone/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/standalone/tsconfig.json b/standalone/tsconfig.json index 0d3bef15..29485e94 100644 --- a/standalone/tsconfig.json +++ b/standalone/tsconfig.json @@ -17,7 +17,8 @@ "noFallthroughCasesInSwitch": true, "paths": { "dormouse-lib/*": ["../lib/src/*"], - "dor/*": ["../dor/src/*"] + "dor/*": ["../dor/src/*"], + "server-lib-common": ["../server-lib-common/src/index.ts"] } }, "include": ["src"] From 9078aeb20284726881c89da3dea6e8d6ad192784 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 20:05:05 -0700 Subject: [PATCH 22/52] fix(lib): alias server-lib-common to source in Storybook build The Chromatic "Visual Regression Tests" job runs `build-storybook` in `lib` after `pnpm install`, without building `server-lib-common`. A Wall story pulls `Wall -> RemotePairingModalHost -> remote/host/* -> server-lib-common`, whose package `exports` resolve to an unbuilt `dist/index.js`, so Rolldown fails: "failed to resolve import 'server-lib-common' from './src/remote/host/remote-api.ts'". Alias the bare specifier to source in `.storybook/main.ts`, exactly as `dor` and `dormouse-lib` already are (Storybook's Vite ignores tsconfig paths). Verified by removing `server-lib-common/dist` (the CI state): `build-storybook` then completes successfully. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/.storybook/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index ca06c880..c460fe9e 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -25,6 +25,11 @@ const config: StorybookConfig = { // any Wall-importing story fails with "Failed to resolve import 'dor/…'". // Safe next to `dormouse-lib`: a string alias only matches `dor` or `dor/…`. dor: path.resolve(here, '..', '..', 'dor', 'src'), + // Same reason: `Wall` → `RemotePairingModalHost` pulls in the remote host + // modules, which import `server-lib-common`. Its package `exports` point + // at a `dist` the Storybook/Chromatic job never builds, so alias the bare + // specifier to source too. + 'server-lib-common': path.resolve(here, '..', '..', 'server-lib-common', 'src'), }; return config; }, From 0c518213d246ddb6b6ab06b7226c2abe46be1ce3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 20:15:19 -0700 Subject: [PATCH 23/52] fix(website): alias server-lib-common to source in the Vite build The Cloudflare Pages build runs `pnpm build:website` (`react-router build`) after a fresh `pnpm install`, without building `server-lib-common`. The desktop playground (`PlaygroundDesktop.tsx`) bundles `Wall`, which now pulls in `RemotePairingModalHost -> remote/host/* -> server-lib-common`. That package's `exports` resolve to an unbuilt `dist/index.js`, so Rolldown fails: "failed to resolve import 'server-lib-common' from '.../lib/src/remote/host/remote-host.ts'". Alias the bare specifier to source in `vite.config.ts`, exactly like the existing `dormouse-lib` alias. Verified by removing `server-lib-common/dist` (the Cloudflare state): `pnpm build:website` then completes successfully (client + SSR bundles). Co-Authored-By: Claude Opus 4.8 (1M context) --- website/vite.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/vite.config.ts b/website/vite.config.ts index 94d92fc0..3a9ddcd3 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -11,6 +11,11 @@ export default defineConfig(({ mode }) => ({ resolve: { alias: { "dormouse-lib": path.resolve(__dirname, "../lib/src"), + // The desktop playground bundles `Wall`, which pulls in the remote host + // modules (`RemotePairingModalHost` → remote/host/*); those import + // `server-lib-common`, whose package `exports` resolve to a `dist` this + // build never compiles. Alias it to source, exactly like `dormouse-lib`. + "server-lib-common": path.resolve(__dirname, "../server-lib-common/src"), "ascii-splash-internal": path.resolve( __dirname, "node_modules/ascii-splash/dist", From 81a8692059dcdd4efe7cac15d73e8b4d3e02df4d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 22:40:33 -0700 Subject: [PATCH 24/52] fix(server): ignore frames from displaced host sockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerHost displaces the old socket and invalidates its clients' sessions, but the displaced socket's handler could still deliver queued frames — a late 'decision allowed' would re-establish a session the replacement just dropped, and 'msg' data from the dead host process would route as if current. onHostFrame now only routes frames from the socket the hub's map points at. Unit tests drive RelayHub directly with fake sockets: displaced decision/msg/challenge/pair-result frames are all ignored, the replacement socket works end to end, and a stale socket cannot speak for a host that has since gone offline. Co-Authored-By: Claude Fable 5 --- server/src/relay.ts | 5 ++ server/test/relay-displaced.test.mjs | 109 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 server/test/relay-displaced.test.mjs diff --git a/server/src/relay.ts b/server/src/relay.ts index 6d86a009..2cec5e7d 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -103,6 +103,11 @@ export class RelayHub { /** Handle one raw frame from a Host socket. Unknown/malformed frames are ignored. */ onHostFrame(host: HostConn, raw: string): void { + // Only the socket the map points at speaks for a hostId: a socket displaced + // by registerHost can still deliver queued frames, and treating them as + // current would resurrect sessions the replacement just invalidated (a late + // `decision` re-establishing, or `msg` data from the dead host process). + if (this.#hosts.get(host.hostId) !== host) return; const frame = parseFrame(raw); if (!frame || typeof frame.t !== 'string' || typeof frame.clientId !== 'string') return; // Every host frame addresses a specific client; if it has already gone, diff --git a/server/test/relay-displaced.test.mjs b/server/test/relay-displaced.test.mjs new file mode 100644 index 00000000..c637931c --- /dev/null +++ b/server/test/relay-displaced.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { RelayHub } from '../dist/relay.js'; + +/** + * Unit tests for the displaced-host-socket guard, driving RelayHub directly + * with fake sockets: a socket replaced by a host reconnect may still deliver + * queued frames, and those must never re-establish or feed sessions the + * replacement invalidated. + */ + +/** A HandshakeGate that approves everything — routing is what's under test. */ +const openGate = { + observeChallenge() {}, + forgetClient() {}, + checkPair: async () => ({ ok: true }), + checkConnect2: async () => ({ ok: true }), +}; + +function fakeSocket() { + return { + sent: [], + closed: false, + send(data) { + this.sent.push(JSON.parse(data)); + }, + close() { + this.closed = true; + }, + }; +} + +/** Register a client, bind it to `hostId`, and establish via a host decision. */ +async function establishedClient(hub, hostConn, hostId) { + const socket = fakeSocket(); + const client = hub.registerClient(socket); + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId })); + hub.onHostFrame(hostConn, JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true })); + assert.equal(client.established, true, 'precondition: session established'); + return { socket, client }; +} + +test('frames from a displaced host socket are ignored', async () => { + const hub = new RelayHub(openGate); + const oldSocket = fakeSocket(); + const oldConn = hub.registerHost('h1', oldSocket); + const { socket: clientSocket, client } = await establishedClient(hub, oldConn, 'h1'); + + // The host reconnects: the old socket is displaced and the session dropped. + const newSocket = fakeSocket(); + const newConn = hub.registerHost('h1', newSocket); + assert.equal(oldSocket.closed, true); + assert.equal(client.established, false); + assert.ok(clientSocket.sent.some((f) => f.t === 'host-gone')); + + const sentBefore = clientSocket.sent.length; + + // A late decision from the displaced socket must not resurrect the session. + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + assert.equal(client.established, false, 'displaced decision must not establish'); + + // Late msg/challenge/pair-result from the displaced socket must not reach the client. + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'msg', clientId: client.clientId, data: { stale: true } }), + ); + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'challenge', clientId: client.clientId, challenge: 'x', expiresAt: 9e15 }), + ); + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'pair-result', clientId: client.clientId, approved: true }), + ); + assert.equal(clientSocket.sent.length, sentBefore, 'no frames routed from the displaced socket'); + + // The replacement socket still works end to end. + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h1' })); + assert.ok(newSocket.sent.some((f) => f.t === 'connect')); + hub.onHostFrame( + newConn, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + assert.equal(client.established, true); + hub.onHostFrame( + newConn, + JSON.stringify({ t: 'msg', clientId: client.clientId, data: { live: true } }), + ); + assert.ok(clientSocket.sent.some((f) => f.t === 'msg' && f.data?.live === true)); +}); + +test('a displaced socket is also ignored after the replacement disconnects', async () => { + const hub = new RelayHub(openGate); + const oldConn = hub.registerHost('h1', fakeSocket()); + const { client } = await establishedClient(hub, oldConn, 'h1'); + + const newConn = hub.registerHost('h1', fakeSocket()); + hub.unregisterHost(newConn); // host fully offline now + + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + assert.equal(client.established, false, 'stale socket cannot speak for an offline host'); +}); From faad4ad9e798ade1f80ecb6117785fd1766f79a5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 22:40:49 -0700 Subject: [PATCH 25/52] fix(lib): resolve server-lib-common from source in the Pocket build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pocket entry imports the remote modules, which import server-lib-common; its package exports point at a dist that a clean checkout has not built (this vite-only build has no tsc -b step), so build:pocket — the FIRST step of dev:pocket-server — failed before the server build could generate it. Alias to source, the same idiom the website, Storybook, and standalone configs already use. Verified by deleting server-lib-common/dist and building. Co-Authored-By: Claude Fable 5 --- lib/vite.pocket.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/vite.pocket.config.ts b/lib/vite.pocket.config.ts index a5fb72ab..9d1a1c1c 100644 --- a/lib/vite.pocket.config.ts +++ b/lib/vite.pocket.config.ts @@ -15,6 +15,14 @@ export default defineConfig({ root: fileURLToPath(new URL("./pocket", import.meta.url)), resolve: { dedupe: ["react", "react-dom"], + alias: { + // The Pocket app imports the remote modules, which import + // `server-lib-common`; its package `exports` resolve to a `dist` that a + // clean checkout has not built yet (this vite-only build has no `tsc -b` + // step to generate it). Alias to source, same as the website and + // Storybook configs. + "server-lib-common": fileURLToPath(new URL("../server-lib-common/src", import.meta.url)), + }, }, build: { outDir: fileURLToPath(new URL("./dist-pocket", import.meta.url)), From 19ef5d08834c07bb01b9c9ca99e0c6649bb0375f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 22:40:49 -0700 Subject: [PATCH 26/52] =?UTF-8?q?fix(lib,server):=20remote=20session=20lif?= =?UTF-8?q?ecycle=20=E2=80=94=20stale=20detach,=20resident=20passkeys,=20s?= =?UTF-8?q?ocket=20loss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lifecycle fixes from PROBLEMS.md: - surface.detach now names its surface: a stale detach for a pane the client already switched away from is an idempotent no-op instead of tearing down the newer attachment (host module + fake-host parity, covered by a wire-level rapid-switch test) - passkey registration requires a resident/discoverable credential (residentKey 'required' + requireResidentKey): sign-in discovers with an empty allowCredentials, so a non-resident credential — which 'preferred' can silently produce — would register fine and then never be able to sign in; failing at setup is the recoverable place - an unexpected relay socket close is now host loss: pocket-client fires onHostGone for an established session (matching its own doc comment), nulls the socket so it can never be reused, and clears connectedHostId on host-gone frames; PocketClient.close() nulls the socket first so an intentional close never reads as a drop; the app reopens via the new socketOpen getter instead of a stale ref, so a server restart or network drop self-heals without a page reload Co-Authored-By: Claude Fable 5 --- lib/src/remote/client/pocket-client.test.ts | 65 +++++++++++++++++++++ lib/src/remote/client/pocket-client.ts | 24 +++++++- lib/src/remote/client/webauthn.ts | 15 +++-- lib/src/remote/host/remote-api.ts | 8 ++- lib/src/remote/pocket-app/App.tsx | 6 +- server/test/handshake.test.mjs | 47 +++++++++++++++ server/test/harness/fake-host.mjs | 7 ++- 7 files changed, 161 insertions(+), 11 deletions(-) diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 6ca5433e..639d68ee 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -117,6 +117,12 @@ class FakeSocket implements PocketSocket { this.#emit('close', { code: 1000 }); } + /** Simulate the server/network dropping the connection (no client `close()`). */ + drop(): void { + this.readyState = 3; + this.#emit('close', { code: 1006 }); + } + fireOpen(): void { this.readyState = 1; this.#emit('open', {}); @@ -297,6 +303,65 @@ describe('connect', () => { }); }); +/** Drive the full connect dance until the session is established. */ +async function connectEstablished(harness: Harness): Promise { + const { client, socket } = harness; + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: true }); + await connecting; +} + +describe('socket lifecycle', () => { + it('an unexpected close fires host-gone for an established session and resets the socket', async () => { + const harness = await signedIn(); + await connectEstablished(harness); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.socket.drop(); + expect(hostGone).toBe(1); + expect(harness.client.socketOpen).toBe(false); + expect(harness.client.connectedHostId).toBeNull(); + }); + + it('an intentional close() does not fire host-gone', async () => { + const harness = await signedIn(); + await connectEstablished(harness); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.client.close(); + expect(hostGone).toBe(0); + expect(harness.client.socketOpen).toBe(false); + }); + + it('a host-gone frame followed by a socket close fires host-gone exactly once', async () => { + const harness = await signedIn(); + await connectEstablished(harness); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.socket.server({ t: 'host-gone' }); + expect(hostGone).toBe(1); + expect(harness.client.connectedHostId).toBeNull(); + harness.socket.drop(); + expect(hostGone).toBe(1); + }); + + it('an unexpected close without an established session resets state silently', async () => { + const harness = await signedIn(); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.socket.drop(); + expect(hostGone).toBe(0); + expect(harness.client.socketOpen).toBe(false); + }); +}); + describe('remote-api correlation', () => { it('resolves a request by requestId', async () => { const { client, socket } = await signedIn(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index e5355366..42423383 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -206,6 +206,11 @@ export class PocketClient { // --- Relay socket -------------------------------------------------------- + /** True while a live relay socket exists; false after any close. */ + get socketOpen(): boolean { + return this.#ws !== null; + } + /** Open the `/ws/client` relay socket; resolves once it is open. */ openSocket(): Promise { const token = this.#requireToken(); @@ -372,12 +377,17 @@ export class PocketClient { } close(): void { + const ws = this.#ws; + // Null BEFORE closing: #onClose reads `#ws === null` as "intentional + // close", and while real sockets emit their close event asynchronously, + // test fakes may emit synchronously from within close(). + this.#ws = null; + this.#connectedHostId = null; try { - this.#ws?.close(); + ws?.close(); } catch { // already closing } - this.#ws = null; } // --- Internals ----------------------------------------------------------- @@ -428,6 +438,7 @@ export class PocketClient { this.#onMsg(frame.data); return; case 'host-gone': + this.#connectedHostId = null; this.#onHostGone?.(); this.#rejectAll(new Error('host disconnected')); return; @@ -456,8 +467,17 @@ export class PocketClient { } #onClose(): void { + // `close()` nulls #ws before the event fires, so a non-null #ws here means + // the socket died on us (server restart, network drop) rather than being + // closed intentionally. + const unexpected = this.#ws !== null; + const hadSession = this.#connectedHostId !== null; + this.#ws = null; // never reuse a closed socket; openSocket() makes a fresh one this.#connectedHostId = null; this.#rejectAll(new Error('relay socket closed')); + // A close without a `host-gone` frame is still host loss for an established + // session — the app must leave the wall instead of idling on a dead stream. + if (unexpected && hadSession) this.#onHostGone?.(); } /** Fail every awaited handshake frame and in-flight request (avoids hangs). */ diff --git a/lib/src/remote/client/webauthn.ts b/lib/src/remote/client/webauthn.ts index 80df14b7..48d55755 100644 --- a/lib/src/remote/client/webauthn.ts +++ b/lib/src/remote/client/webauthn.ts @@ -38,9 +38,11 @@ function toBufferSource(bytes: Uint8Array): ArrayBuffer { /** * Create a discoverable ES256 passkey. `attestation: 'none'` keeps the server - * dependency-free (it trusts the browser-provided SPKI key); `residentKey` - * and `userVerification` are `'preferred'` so it works on the widest range of - * authenticators while still preferring a resident, verified credential. + * dependency-free (it trusts the browser-provided SPKI key). `residentKey` + * is `'required'`: sign-in discovers credentials with an empty + * `allowCredentials`, so a non-resident credential (which `'preferred'` can + * silently produce) would register fine and then never be able to sign in — + * better to fail here, where re-running setup is a one-tap recovery. */ async function registerPasskey( challenge: string, @@ -57,7 +59,12 @@ async function registerPasskey( displayName: accountId, }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' }, + authenticatorSelection: { + residentKey: 'required', + // WebAuthn L1 authenticators ignore `residentKey`; this is its spelling. + requireResidentKey: true, + userVerification: 'preferred', + }, attestation: 'none', }, })) as PublicKeyCredential | null; diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 0156f085..b5494865 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -245,7 +245,13 @@ export class RemoteApiSession { } #detach(request: RemoteRequest): void { - this.#teardownAttachment(); + // Detach names its surface: a stale detach for a pane the client already + // switched away from must not kill the newer attachment. Detaching a + // surface that is not the current attachment is an idempotent no-op. + const params = request.params as { surfaceId?: string } | undefined; + if (this.#attachment && this.#attachment.surfaceId === params?.surfaceId) { + this.#teardownAttachment(); + } this.#ok(request, {}); } diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 01641fba..8ecd9ebb 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -53,7 +53,6 @@ export default function App(): React.ReactElement { const [hosts, setHosts] = useState([]); const [activeHost, setActiveHost] = useState(null); const adapterRef = useRef(null); - const socketOpened = useRef(false); const run = useCallback(async (label: string, fn: () => Promise) => { setError(null); @@ -68,9 +67,10 @@ export default function App(): React.ReactElement { }, []); const loadHosts = useCallback(async () => { - if (!socketOpened.current) { + // The client nulls its socket on any close, so this self-heals after a + // server restart or network drop instead of reusing a dead socket. + if (!client.socketOpen) { await client.openSocket(); - socketOpened.current = true; } setHosts(await client.listHosts()); setPhase('hosts'); diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index cef416d5..ec326397 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -480,6 +480,53 @@ test('remote terminal: directory snapshot, attach banner, echo write, resize, de } }); +test('remote terminal: a stale detach for the previous surface does not kill the new attachment', async () => { + const { app, server, host, close } = await boot(); + try { + const p = await phone(app, server); + await establish(p, host.hostId); + + remote(p, 'dw', REMOTE_METHODS.directoryWatch, {}); + await p.socket.take(); // watch ack + const snapshot = await p.socket.take(); + const [a, b] = snapshot.data.data.entries; + + // Rapid pane switch: attach A, then attach B (replacing A). + remote(p, 'atA', REMOTE_METHODS.surfaceAttach, { surfaceId: a.surfaceId, cols: 80, rows: 24 }); + await p.socket.take(); // attach A ack + await p.socket.take(); // banner A + remote(p, 'atB', REMOTE_METHODS.surfaceAttach, { surfaceId: b.surfaceId, cols: 80, rows: 24 }); + await p.socket.take(); // attach B ack + await p.socket.take(); // banner B + + // A stale detach naming the OLD surface must be an idempotent no-op. + remote(p, 'dtA', REMOTE_METHODS.surfaceDetach, { surfaceId: a.surfaceId }); + assert.equal((await p.socket.take()).data.ok, true); + + // B's stream is still live: a write still echoes. + remote(p, 'wr', REMOTE_METHODS.terminalWrite, { + surfaceId: b.surfaceId, + bytes: toBase64Url(utf8Encode('still here\r')), + }); + assert.equal((await p.socket.take()).data.ok, true); + const echo = await p.socket.take(); + assert.equal(echo.data.event, REMOTE_EVENTS.terminalData); + assert.match(eventText(echo), /still here/); + + // Detaching the CURRENT surface silences it. + remote(p, 'dtB', REMOTE_METHODS.surfaceDetach, { surfaceId: b.surfaceId }); + assert.equal((await p.socket.take()).data.ok, true); + remote(p, 'wr2', REMOTE_METHODS.terminalWrite, { + surfaceId: b.surfaceId, + bytes: toBase64Url(utf8Encode('gone\r')), + }); + assert.equal((await p.socket.take()).data.ok, true); + assert.ok(await p.socket.quiet(), 'no terminal.data after a matching detach'); + } finally { + await close(); + } +}); + // A short settle so no test leaves an in-flight frame racing teardown. test.after?.(async () => { await sleep(10); diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs index c312dc22..37147cab 100644 --- a/server/test/harness/fake-host.mjs +++ b/server/test/harness/fake-host.mjs @@ -224,8 +224,13 @@ export class FakeHost extends EventEmitter { } case REMOTE_METHODS.surfaceDetach: { + // Detach names its surface: a stale detach for a pane the client + // already switched away from must not kill the newer attachment. + const attachment = this.attachments.get(clientId); + if (attachment && attachment.surfaceId === params?.surfaceId) { + this.attachments.delete(clientId); // stops any further terminal.data + } ok(); - this.attachments.delete(clientId); // stops any further terminal.data return; } From 7cb2ca4541e44f295c9fe614dd5e98d5914abd19 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 08:36:31 -0700 Subject: [PATCH 27/52] Build server-lib-common before dormouse-lib --- lib/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/package.json b/lib/package.json index 533c034b..079990d7 100644 --- a/lib/package.json +++ b/lib/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "dev:pocket": "vite --config vite.pocket.config.ts", + "prebuild": "pnpm --filter server-lib-common build", "build": "tsc -b && vite build", "build:pocket": "vite build --config vite.pocket.config.ts", "preview": "vite preview", From 361ae51b1439efa5a5b201c122851bf6c090dc5c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 08:39:13 -0700 Subject: [PATCH 28/52] Subscribe before remote attach repaint --- lib/src/remote/host/remote-api.test.ts | 138 +++++++++++++++++++++++++ lib/src/remote/host/remote-api.ts | 43 +++++--- 2 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 lib/src/remote/host/remote-api.test.ts diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts new file mode 100644 index 00000000..950c1f90 --- /dev/null +++ b/lib/src/remote/host/remote-api.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + REMOTE_EVENTS, + REMOTE_METHODS, + fromBase64Url, + utf8Decode, + type RemoteEventMsg, + type RemoteResponse, +} from 'server-lib-common'; +import { FakePtyAdapter, setPlatform, type PlatformAdapter } from '../../lib/platform'; +import { registry, type TerminalEntry } from '../../lib/terminal-store'; +import { RemoteApiSession } from './remote-api'; + +type SentPayload = RemoteResponse | RemoteEventMsg; +type DataHandler = (detail: { id: string; data: string }) => void; +type ExitHandler = (detail: { id: string; exitCode: number }) => void; + +class RepaintOnResizePlatform { + readonly dataHandlers = new Set(); + readonly exitHandlers = new Set(); + readonly resizePty = vi.fn((id: string, cols: number, rows: number) => { + this.emitData(id, `pty-resize:${cols}x${rows}`); + }); + + writePty(): void {} + + onPtyData(handler: DataHandler): void { + this.dataHandlers.add(handler); + } + + offPtyData(handler: DataHandler): void { + this.dataHandlers.delete(handler); + } + + onPtyExit(handler: ExitHandler): void { + this.exitHandlers.add(handler); + } + + offPtyExit(handler: ExitHandler): void { + this.exitHandlers.delete(handler); + } + + emitData(id: string, data: string): void { + for (const handler of this.dataHandlers) { + handler({ id, data }); + } + } + + asAdapter(): PlatformAdapter { + return this as unknown as PlatformAdapter; + } +} + +function registerSurface(platform: RepaintOnResizePlatform, cols: number, rows: number): void { + const ptyId = 'pty-1'; + const terminal = { + cols, + rows, + resize: vi.fn((nextCols: number, nextRows: number) => { + terminal.cols = nextCols; + terminal.rows = nextRows; + platform.emitData(ptyId, `terminal-resize:${nextCols}x${nextRows}`); + }), + }; + + registry.set('surface-1', { + ptyId, + terminal, + } as unknown as TerminalEntry); +} + +function attach(session: RemoteApiSession, cols: number, rows: number): void { + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-1', cols, rows }, + }); +} + +function decodeTerminalData(payload: SentPayload): string { + const event = payload as RemoteEventMsg; + return utf8Decode(fromBase64Url((event.data as { bytes: string }).bytes)); +} + +describe('RemoteApiSession surface.attach', () => { + afterEach(() => { + vi.useRealTimers(); + registry.clear(); + setPlatform(new FakePtyAdapter()); + }); + + it('keeps synchronous repaint data from terminal resize', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 100, 30); + + expect(sent[0]).toMatchObject({ + requestId: 'attach-1', + ok: true, + result: { cols: 100, rows: 30 }, + }); + expect(sent[1]).toMatchObject({ + subId: 'attach-1', + event: REMOTE_EVENTS.terminalData, + }); + expect(decodeTerminalData(sent[1]!)).toBe('terminal-resize:100x30'); + }); + + it('keeps synchronous repaint data from the same-size PTY bounce', () => { + vi.useFakeTimers(); + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 80, 24); + + expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + expect(sent[0]).toMatchObject({ + requestId: 'attach-1', + ok: true, + result: { cols: 80, rows: 24 }, + }); + expect(sent[1]).toMatchObject({ + subId: 'attach-1', + event: REMOTE_EVENTS.terminalData, + }); + expect(decodeTerminalData(sent[1]!)).toBe('pty-resize:80x23'); + + vi.advanceTimersByTime(60); + expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-1', 80, 24); + }); +}); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index b5494865..1ebd6dfd 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -210,38 +210,51 @@ export class RemoteApiSession { const cols = clampTerminalDimension(params.cols, term.cols); const rows = clampTerminalDimension(params.rows, term.rows); const platform = getPlatform(); - - // Attach-is-the-resize: resizing the real xterm fires its onResize handler, - // which drives resizePty → SIGWINCH → the TUI/shell repaints, and that - // repaint is what fills the client's screen (no snapshot transfer). - if (term.cols !== cols || term.rows !== rows) { - term.resize(cols, rows); - } else { - // Same size: force one repaint with a quick rows bounce on the PTY only, - // leaving the already-correct local xterm buffer untouched. - platform.resizePty(ptyId, cols, Math.max(1, rows - 1)); - setTimeout(() => platform.resizePty(ptyId, cols, rows), FORCE_REPAINT_BOUNCE_MS); - } - const subId = request.requestId; + const pendingEvents: Array<{ event: string; data: unknown }> = []; + let streaming = false; + const emitOrBuffer = (event: string, data: unknown): void => { + if (streaming) { + this.#event(subId, event, data); + } else { + pendingEvents.push({ event, data }); + } + }; const onData = (detail: { id: string; data: string }): void => { if (detail.id !== ptyId) return; // The PTY delivers strings on this path; be defensive about the Uint8Array // path some adapters use. Either way it goes out as base64url PTY bytes. const raw: unknown = detail.data; const bytes = typeof raw === 'string' ? utf8Encode(raw) : (raw as Uint8Array); - this.#event(subId, REMOTE_EVENTS.terminalData, { bytes: toBase64Url(bytes) }); + emitOrBuffer(REMOTE_EVENTS.terminalData, { bytes: toBase64Url(bytes) }); }; const onExit = (detail: { id: string; exitCode: number }): void => { if (detail.id !== ptyId) return; - this.#event(subId, REMOTE_EVENTS.terminalClosed, { exitCode: detail.exitCode }); + emitOrBuffer(REMOTE_EVENTS.terminalClosed, { exitCode: detail.exitCode }); }; platform.onPtyData(onData); platform.onPtyExit(onExit); this.#attachment = { surfaceId: params.surfaceId, ptyId, subId, onData, onExit }; + // Attach-is-the-resize: resizing the real xterm fires its onResize handler, + // which drives resizePty → SIGWINCH → the TUI/shell repaints, and that + // repaint is what fills the client's screen (no snapshot transfer). The + // stream is subscribed first because some PTYs repaint synchronously. + if (term.cols !== cols || term.rows !== rows) { + term.resize(cols, rows); + } else { + // Same size: force one repaint with a quick rows bounce on the PTY only, + // leaving the already-correct local xterm buffer untouched. + platform.resizePty(ptyId, cols, Math.max(1, rows - 1)); + setTimeout(() => platform.resizePty(ptyId, cols, rows), FORCE_REPAINT_BOUNCE_MS); + } + const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; this.#ok(request, result); + streaming = true; + for (const event of pendingEvents) { + this.#event(subId, event.event, event.data); + } } #detach(request: RemoteRequest): void { From fd1862910826e7b4c5ef31db8d8d8d2afe5bd79e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:13:49 -0700 Subject: [PATCH 29/52] Drop stale relay host replies --- docs/specs/server.md | 5 +++++ server/src/relay.ts | 5 +++++ server/test/relay-displaced.test.mjs | 31 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/specs/server.md b/docs/specs/server.md index e26c6256..ffbc3c99 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -105,6 +105,11 @@ The server routes JSON envelopes between client sockets and host sockets (`@hono/node-ws`). Before a session is authorized it only forwards an allowlist of handshake types; after authorization it is a dumb pipe. +The relay keeps one current Host binding per Client socket. Host-originated +handshake replies and `msg` frames are routed only when the frame comes from +that current Host; late replies from a previous Host are ignored and cannot +re-establish an old session. + ## Pairing (phone ↔ laptop, first time) ``` diff --git a/server/src/relay.ts b/server/src/relay.ts index 2cec5e7d..e08407d7 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -114,6 +114,11 @@ export class RelayHub { // there is nothing to route (and no session to establish). const client = this.#clients.get(frame.clientId); if (!client) return; + // Host replies are only meaningful while the client is still bound to that + // host. A client socket may leave host A for host B before A answers; late + // handshake replies from A must not reach the active client or re-establish + // an old session. + if (client.hostId !== host.hostId) return; switch (frame.t) { case 'pair-result': this.#toClient(client, { diff --git a/server/test/relay-displaced.test.mjs b/server/test/relay-displaced.test.mjs index c637931c..060d312a 100644 --- a/server/test/relay-displaced.test.mjs +++ b/server/test/relay-displaced.test.mjs @@ -107,3 +107,34 @@ test('a displaced socket is also ignored after the replacement disconnects', asy ); assert.equal(client.established, false, 'stale socket cannot speak for an offline host'); }); + +test('late replies from a host the client left are ignored', async () => { + const hub = new RelayHub(openGate); + const hostA = hub.registerHost('h1', fakeSocket()); + const hostB = hub.registerHost('h2', fakeSocket()); + const clientSocket = fakeSocket(); + const client = hub.registerClient(clientSocket); + + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h1' })); + assert.equal(hostA.socket.sent.at(-1)?.t, 'connect'); + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h2' })); + assert.equal(client.hostId, 'h2'); + assert.equal(hostB.socket.sent.at(-1)?.t, 'connect'); + + hub.onHostFrame( + hostA, + JSON.stringify({ t: 'challenge', clientId: client.clientId, challenge: 'stale', expiresAt: 9e15 }), + ); + hub.onHostFrame( + hostA, + JSON.stringify({ t: 'pair-result', clientId: client.clientId, approved: true }), + ); + hub.onHostFrame( + hostA, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + + assert.deepEqual(clientSocket.sent, [], 'stale host replies must not reach the client'); + assert.equal(client.hostId, 'h2'); + assert.equal(client.established, false, 'stale host decision must not establish'); +}); From a6eec2208876cc8c0c53f88f252a4d8acce65dea Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:15:33 -0700 Subject: [PATCH 30/52] Notify previous relay host on rebind --- docs/specs/server.md | 3 +++ server/src/relay.ts | 6 ++++++ server/test/relay-displaced.test.mjs | 15 +++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/specs/server.md b/docs/specs/server.md index ffbc3c99..4a747a57 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -109,6 +109,9 @@ The relay keeps one current Host binding per Client socket. Host-originated handshake replies and `msg` frames are routed only when the frame comes from that current Host; late replies from a previous Host are ignored and cannot re-establish an old session. +When a Client socket binds to a different Host, the relay sends `client-gone` +to the previous live Host before replacing the binding, so Host-side pairing UI, +remote-api sessions, and watchers are disposed immediately. ## Pairing (phone ↔ laptop, first time) diff --git a/server/src/relay.ts b/server/src/relay.ts index e08407d7..619e2e07 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -227,6 +227,12 @@ export class RelayHub { } // Binding to a (new) host, or re-attempting `connect`, drops any prior // established session — a client holds at most one at a time. + if (client.hostId !== null && client.hostId !== frame.hostId) { + const previousHost = this.#hosts.get(client.hostId); + if (previousHost) { + this.#toHost(previousHost, { t: 'client-gone', clientId: client.clientId }); + } + } if (client.hostId !== frame.hostId || frame.t === 'connect') { client.established = false; } diff --git a/server/test/relay-displaced.test.mjs b/server/test/relay-displaced.test.mjs index 060d312a..64d85a79 100644 --- a/server/test/relay-displaced.test.mjs +++ b/server/test/relay-displaced.test.mjs @@ -138,3 +138,18 @@ test('late replies from a host the client left are ignored', async () => { assert.equal(client.hostId, 'h2'); assert.equal(client.established, false, 'stale host decision must not establish'); }); + +test('rebinding a client tells the previous host client-gone', async () => { + const hub = new RelayHub(openGate); + const hostA = hub.registerHost('h1', fakeSocket()); + const hostB = hub.registerHost('h2', fakeSocket()); + const { client } = await establishedClient(hub, hostA, 'h1'); + + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h2' })); + + assert.deepEqual(hostA.socket.sent.at(-1), { t: 'client-gone', clientId: client.clientId }); + assert.equal(hostB.socket.sent.at(-1)?.t, 'connect'); + assert.equal(hostB.socket.sent.at(-1)?.clientId, client.clientId); + assert.equal(client.hostId, 'h2'); + assert.equal(client.established, false); +}); From 73511928df12b2dcbbca7a24e8e889db55582390 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:23:27 -0700 Subject: [PATCH 31/52] Resolve standalone server-lib-common source --- standalone/package.json | 1 + standalone/vite.config.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/standalone/package.json b/standalone/package.json index 362fd9de..7243ace3 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "dev:agent-browser": "pnpm run stage && node scripts/dev-agent-browser.mjs", + "prebuild": "pnpm --filter server-lib-common build", "build": "pnpm run stage && tsc -b && vite build", "stage": "pnpm run stage:dor-cli && pnpm run stage:sidecar-proxy", "stage:dor-cli": "pnpm --filter dor build && node scripts/stage-dor-cli.mjs", diff --git a/standalone/vite.config.ts b/standalone/vite.config.ts index fd790d6e..e6b77655 100644 --- a/standalone/vite.config.ts +++ b/standalone/vite.config.ts @@ -5,6 +5,7 @@ import path from "path"; const libDir = path.resolve(__dirname, "../lib"); const dorDir = path.resolve(__dirname, "../dor"); +const serverLibCommonDir = path.resolve(__dirname, "../server-lib-common"); // https://v2.tauri.app/start/frontend/vite/ const host = process.env.TAURI_DEV_HOST; @@ -20,6 +21,11 @@ export default defineConfig({ // path; Vite governs lib files by lib's (paths-less) tsconfig, and `dor` // has no package exports, so resolve it explicitly the same way as lib. dor: path.resolve(dorDir, "src"), + // lib source imports the remote modules, which import `server-lib-common`; + // its package exports point at dist, which a clean standalone checkout + // build has not necessarily produced yet. Match the Pocket and website + // builds by resolving it directly to source. + "server-lib-common": path.resolve(serverLibCommonDir, "src"), }, }, // Tauri expects a fixed port; fail if that port is not available @@ -29,8 +35,8 @@ export default defineConfig({ strictPort: true, hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, fs: { - // Allow serving files from the lib and dor workspace packages - allow: [libDir, dorDir, "."], + // Allow serving files from the source-aliased workspace packages. + allow: [libDir, dorDir, serverLibCommonDir, "."], }, }, // Tauri CLI reads this env var to know where the dev server is From 32dedb7a1fa5c6e165998a87ecb5b9a902b3fe8b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:36:15 -0700 Subject: [PATCH 32/52] test(lib): Storybook stories for the remote pairing modal and Pocket app UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover every new UI surface this branch adds: - Modals/RemotePairingModal — Default, UnnamedDevice, LongValues - Pocket/SetupOrSignin — Welcome, SetupExpanded, SigningIn, CreatingAccount, Error - Pocket/HostsView — Empty, MixedList, Pairing, Connecting, Refreshing, Error - Pocket/PocketWall — SingleSession smoke over a fake RemoteAdapterClient Export SetupOrSignin, HostsView, and HostView from the Pocket App so the prop-driven views can be storied directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/remote/pocket-app/App.tsx | 6 +- lib/src/stories/HostsView.stories.tsx | 81 +++++++++++++ lib/src/stories/PocketWall.stories.tsx | 114 ++++++++++++++++++ .../stories/RemotePairingModal.stories.tsx | 58 +++++++++ lib/src/stories/SetupOrSignin.stories.tsx | 78 ++++++++++++ 5 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 lib/src/stories/HostsView.stories.tsx create mode 100644 lib/src/stories/PocketWall.stories.tsx create mode 100644 lib/src/stories/RemotePairingModal.stories.tsx create mode 100644 lib/src/stories/SetupOrSignin.stories.tsx diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 8ecd9ebb..9782aab5 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -25,7 +25,7 @@ import './pocket.css'; type Phase = 'auth' | 'hosts' | 'wall'; -interface HostView { +export interface HostView { hostId: string; label: string; online: boolean; @@ -185,7 +185,7 @@ export default function App(): React.ReactElement { // --- SetupOrSignin --------------------------------------------------------- -function SetupOrSignin({ +export function SetupOrSignin({ busy, error, onSignin, @@ -274,7 +274,7 @@ function SetupOrSignin({ // --- HostsView ------------------------------------------------------------- -function HostsView({ +export function HostsView({ hosts, busy, error, diff --git a/lib/src/stories/HostsView.stories.tsx b/lib/src/stories/HostsView.stories.tsx new file mode 100644 index 00000000..ac1acddd --- /dev/null +++ b/lib/src/stories/HostsView.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; +// Importing from App.tsx runs its `index.css` / `pocket.css` side-effect imports, +// so the `pk-*` styles and `:root` palette are loaded for these stories. +import { HostsView, type HostView } from '../remote/pocket-app/App'; + +// A paired online host, an unpaired online host (shows Pair + Connect), and an +// offline host (both actions disabled) — the full row matrix in one frame. +const MIXED_HOSTS: HostView[] = [ + { hostId: 'host-studio', label: 'Studio iMac', online: true }, + { hostId: 'host-laptop', label: 'MacBook Pro', online: true }, + { hostId: 'host-nas', label: 'Basement NAS', online: false }, +]; + +const PAIRED = new Set(['host-studio']); +const isPaired = (hostId: string) => PAIRED.has(hostId); + +// Phone-sized frame with the pocket dark background, matching the real app shell. +function PhoneFrame({ children }: { children: ReactNode }) { + return ( +

+ {children} +
+ ); +} + +const meta: Meta = { + title: 'Pocket/HostsView', + component: HostsView, + parameters: { layout: 'centered' }, + args: { + hosts: MIXED_HOSTS, + busy: null, + error: null, + isPaired, + onRefresh: () => {}, + onPair: () => {}, + onConnect: () => {}, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// No hosts enrolled yet → the `pk-empty` message. +export const Empty: Story = { + args: { hosts: [] }, +}; + +// Paired+online (Connect only), unpaired+online (Pair + Connect), offline (disabled). +export const MixedList: Story = {}; + +// Pairing in flight → the unpaired online host's Pair button shows "…". +export const Pairing: Story = { + args: { busy: 'pair' }, +}; + +// Connecting in flight → Connect buttons show "…" and disable. +export const Connecting: Story = { + args: { busy: 'connect' }, +}; + +// Refreshing the list → the header Refresh button shows "…". +export const Refreshing: Story = { + args: { busy: 'refresh' }, +}; + +// Host dropped → the `pk-error` banner above the list. +export const Error: Story = { + args: { error: 'The host disconnected.' }, +}; diff --git a/lib/src/stories/PocketWall.stories.tsx b/lib/src/stories/PocketWall.stories.tsx new file mode 100644 index 00000000..5cc1784a --- /dev/null +++ b/lib/src/stories/PocketWall.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useRef } from 'react'; +import { toBase64Url, utf8Encode, type DirectoryEntry } from 'server-lib-common'; +import { PocketWall } from '../remote/pocket-app/PocketWall'; +import { RemotePtyAdapter, type RemoteAdapterClient } from '../remote/client/remote-adapter'; +import type { TerminalHandlers } from '../remote/client/pocket-client'; +import { setPlatform } from '../lib/platform'; +import { disposeAllSessions, initAlertStateReceiver } from '../lib/terminal-registry'; + +// The adapter decodes `terminal.data` as base64url UTF-8, so encode splash text +// the same way the real wire does. +const b64 = (text: string) => toBase64Url(utf8Encode(text)); + +// One-terminal directory snapshot (id = surfaceId, as the registry binds panes). +const SINGLE_SESSION: DirectoryEntry[] = [ + { + paneRef: 'pane-1', + surfaceId: 'pane-1', + type: 'terminal', + title: 'zsh', + focused: true, + activity: 'prompt', + ringing: false, + hasTODO: false, + }, +]; + +// Streamed once the wall attaches the pane — stands in for the Host's repaint. +const SPLASH = b64( + [ + '\x1b[32mDormouse Pocket\x1b[0m connected to \x1b[36mStudio iMac\x1b[0m\r\n', + '\x1b[2mremote session · one attachment at a time\x1b[0m\r\n', + '\r\n', + 'ned@studio ~/projects/dormouse % \x1b[7m \x1b[0m\r\n', + ].join(''), +); + +/** + * Network-free {@link RemoteAdapterClient}: serves a fixed directory snapshot and + * echoes a splash through the attach handlers, so `PocketWall` renders its real + * `MobileTerminalUi` + `MobileWall` composition without a live Host. + */ +class FakeRemoteClient implements RemoteAdapterClient { + #snapshot: DirectoryEntry[]; + #n = 0; + + constructor(snapshot: DirectoryEntry[]) { + this.#snapshot = snapshot; + } + + async watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise { + // Deliver synchronously so the adapter has entries before PocketWall's first + // render reads `getDirectoryEntries()`. + onSnapshot(this.#snapshot); + return 'dir-sub'; + } + + async attach(_surfaceId: string, cols: number, rows: number, handlers: TerminalHandlers) { + const subId = `attach-${++this.#n}`; + // Push after the pane's xterm (and its onPtyData handler) is mounted. + queueMicrotask(() => handlers.onData(SPLASH)); + return { subId, result: { cols, rows } }; + } + + async write() {} + async resize() {} + async detach() {} + unsubscribe() {} +} + +function PocketWallStory() { + const adapterRef = useRef(null); + if (!adapterRef.current) { + // Mirror App.tsx's onConnect: stand up the adapter as the platform, prep a + // clean registry, then start the directory watch. + const adapter = new RemotePtyAdapter(new FakeRemoteClient(SINGLE_SESSION)); + setPlatform(adapter); + disposeAllSessions(); + initAlertStateReceiver(); + void adapter.init(); + adapterRef.current = adapter; + } + + useEffect(() => { + const adapter = adapterRef.current; + return () => { + void adapter?.dispose(); + disposeAllSessions(); + }; + }, []); + + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: 'Pocket/PocketWall', + component: PocketWallStory, + parameters: { layout: 'centered' }, +}; + +export default meta; +type Story = StoryObj; + +// Smoke story: one attached session streaming a splash. Proves the +// adapter → directory → MobileWall wiring (the composition itself is also +// exercised by `App/MobileTerminalUi`'s PocketWall story with a FakePtyAdapter). +export const SingleSession: Story = {}; diff --git a/lib/src/stories/RemotePairingModal.stories.tsx b/lib/src/stories/RemotePairingModal.stories.tsx new file mode 100644 index 00000000..25537275 --- /dev/null +++ b/lib/src/stories/RemotePairingModal.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { PairingRequest } from 'server-lib-common'; +import { RemotePairingModal } from '../remote/host/RemotePairingModal'; + +// A plausible pairing request; individual stories override the fields that +// drive the visible surface (requested label, account, key fingerprint). +function pairingRequest(over: Partial = {}): PairingRequest { + return { + accountId: 'ned@example.com', + passkeyCredentialId: 'cred-abc123', + passkeyPublicKeyHash: 'ph_9f2c1a77', + devicePublicKey: 'abcd1234ef567890deadbeefcafef00d', + requestedLabel: 'Ned’s iPhone', + ...over, + }; +} + +function RemotePairingModalStory({ request }: { request: PairingRequest }) { + return ( +
+ {/* Simulated terminal content behind the viewport-scoped modal. */} +
+
dev@dormouse:~/repo$ dormouse remote enroll
+
Waiting for a device to pair…
+
+ {}} onDeny={() => {}} /> +
+ ); +} + +const meta: Meta = { + title: 'Modals/RemotePairingModal', + component: RemotePairingModalStory, +}; + +export default meta; +type Story = StoryObj; + +// Named device, normal account, key long enough to show an `abcd1234…` fingerprint. +export const Default: Story = { + args: { request: pairingRequest() }, +}; + +// Empty requested label → the `(unnamed)` fallback. +export const UnnamedDevice: Story = { + args: { request: pairingRequest({ requestedLabel: '' }) }, +}; + +// Long account + long label to exercise the review block's `break-words` wrapping. +export const LongValues: Story = { + args: { + request: pairingRequest({ + requestedLabel: + 'Ned’s work iPhone 15 Pro Max in the downstairs office by the window (personal profile)', + accountId: 'ned.twigg+dormouse-remote-selfhost-poc-longaddress@subdomain.example-company.com', + }), + }, +}; diff --git a/lib/src/stories/SetupOrSignin.stories.tsx b/lib/src/stories/SetupOrSignin.stories.tsx new file mode 100644 index 00000000..65b66769 --- /dev/null +++ b/lib/src/stories/SetupOrSignin.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; +// Importing from App.tsx runs its `index.css` / `pocket.css` side-effect imports, +// so the `pk-*` styles and `:root` palette are loaded for these stories. +import { SetupOrSignin } from '../remote/pocket-app/App'; + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// The first-time-setup panel is internal state (`useState(showSetup)`), toggled +// by the `+ First-time setup` disclosure. Click it so the setup fields render. +async function openSetup({ canvasElement }: { canvasElement: HTMLElement }) { + await wait(50); + const disclosure = Array.from(canvasElement.querySelectorAll('button')).find( + (button) => button.textContent?.includes('First-time setup'), + ); + disclosure?.click(); + await wait(50); +} + +// Phone-sized frame with the pocket dark background, matching the real app shell. +function PhoneFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const meta: Meta = { + title: 'Pocket/SetupOrSignin', + component: SetupOrSignin, + parameters: { layout: 'centered' }, + args: { + busy: null, + error: null, + onSignin: () => {}, + onSetup: () => {}, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Idle: welcome copy, "Sign in with passkey", setup collapsed. +export const Welcome: Story = {}; + +// Disclosure opened → setup password + label fields + "Create passkey & sign in". +export const SetupExpanded: Story = { + play: openSetup, +}; + +// Sign-in in flight: primary button reads "Signing in…" and is disabled. +export const SigningIn: Story = { + args: { busy: 'signin' }, +}; + +// Account creation in flight: setup panel open with the button reading "Creating…". +export const CreatingAccount: Story = { + args: { busy: 'setup' }, + play: openSetup, +}; + +// Failed sign-in: the red `pk-error` banner above the button. +export const Error: Story = { + args: { error: 'Passkey sign-in was cancelled.' }, +}; From 4cdf12c5643415dbf46dd0268cdd146a4336d197 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:54:09 -0700 Subject: [PATCH 33/52] Gate remote terminal input to attached surface --- docs/specs/remote-api.md | 5 ++ lib/src/remote/host/remote-api.test.ts | 80 +++++++++++++++++++++++--- lib/src/remote/host/remote-api.ts | 8 +++ server/test/handshake.test.mjs | 13 +++-- server/test/harness/fake-host.mjs | 18 +++--- 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 1609a81b..3b22681b 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -251,6 +251,11 @@ type TerminalInput = | { method: 'terminal.resize'; params: { surfaceId: string; cols: number; rows: number } }; ``` +`terminal.write` and `terminal.resize` are valid only for the session's current +attachment. A stale request for a detached surface, or a request for a +background surface listed in the directory but not attached by this session, is +rejected and must not reach the PTY or change its size. + ### Size authority: last-attach-wins A terminal has one size, and the most recent size writer owns it: attaching diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 950c1f90..8346759c 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -3,7 +3,9 @@ import { REMOTE_EVENTS, REMOTE_METHODS, fromBase64Url, + toBase64Url, utf8Decode, + utf8Encode, type RemoteEventMsg, type RemoteResponse, } from 'server-lib-common'; @@ -21,8 +23,7 @@ class RepaintOnResizePlatform { readonly resizePty = vi.fn((id: string, cols: number, rows: number) => { this.emitData(id, `pty-resize:${cols}x${rows}`); }); - - writePty(): void {} + readonly writePty = vi.fn(); onPtyData(handler: DataHandler): void { this.dataHandlers.add(handler); @@ -51,8 +52,13 @@ class RepaintOnResizePlatform { } } -function registerSurface(platform: RepaintOnResizePlatform, cols: number, rows: number): void { - const ptyId = 'pty-1'; +function registerSurface( + platform: RepaintOnResizePlatform, + cols: number, + rows: number, + surfaceId = 'surface-1', + ptyId = 'pty-1', +): void { const terminal = { cols, rows, @@ -63,17 +69,17 @@ function registerSurface(platform: RepaintOnResizePlatform, cols: number, rows: }), }; - registry.set('surface-1', { + registry.set(surfaceId, { ptyId, terminal, } as unknown as TerminalEntry); } -function attach(session: RemoteApiSession, cols: number, rows: number): void { +function attach(session: RemoteApiSession, cols: number, rows: number, surfaceId = 'surface-1'): void { session.handle({ requestId: 'attach-1', method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-1', cols, rows }, + params: { surfaceId, cols, rows }, }); } @@ -135,4 +141,64 @@ describe('RemoteApiSession surface.attach', () => { vi.advanceTimersByTime(60); expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-1', 80, 24); }); + + it('rejects write and resize unless the surface is the current attachment', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + registerSurface(platform, 100, 30, 'surface-2', 'pty-2'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 80, 24, 'surface-1'); + sent.length = 0; + + session.handle({ + requestId: 'write-background', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-2', bytes: toBase64Url(utf8Encode('invisible\r')) }, + }); + session.handle({ + requestId: 'resize-background', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-2', cols: 120, rows: 40 }, + }); + + expect(platform.writePty).not.toHaveBeenCalled(); + expect((registry.get('surface-2')!.terminal as { cols: number; rows: number }).cols).toBe(100); + expect(sent).toEqual([ + { + requestId: 'write-background', + ok: false, + error: 'surface is not attached: surface-2', + }, + { + requestId: 'resize-background', + ok: false, + error: 'surface is not attached: surface-2', + }, + ]); + + session.handle({ + requestId: 'detach', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + sent.length = 0; + + session.handle({ + requestId: 'write-detached', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('stale\r')) }, + }); + + expect(platform.writePty).not.toHaveBeenCalled(); + expect(sent).toEqual([ + { + requestId: 'write-detached', + ok: false, + error: 'surface is not attached: surface-1', + }, + ]); + }); }); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 1ebd6dfd..7167a3ea 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -146,6 +146,12 @@ export class RemoteApiSession { return { params, entry }; } + #requireAttached(request: RemoteRequest, surfaceId: string): boolean { + if (this.#attachment?.surfaceId === surfaceId) return true; + this.#fail(request, `surface is not attached: ${surfaceId}`); + return false; + } + // --- Methods --- #hello(request: RemoteRequest): void { @@ -272,6 +278,7 @@ export class RemoteApiSession { const resolved = this.#resolveSurface(request); if (!resolved) return; const { params, entry } = resolved; + if (!this.#requireAttached(request, params.surfaceId)) return; // Feed the existing PTY input path; the local echo returns via onPtyData. getPlatform().writePty(entry.ptyId, utf8Decode(fromBase64Url(params.bytes))); this.#ok(request, {}); @@ -281,6 +288,7 @@ export class RemoteApiSession { const resolved = this.#resolveSurface(request); if (!resolved) return; const { params, entry } = resolved; + if (!this.#requireAttached(request, params.surfaceId)) return; const term = entry.terminal; const cols = clampTerminalDimension(params.cols, term.cols); const rows = clampTerminalDimension(params.rows, term.rows); diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index ec326397..425ad230 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -409,7 +409,7 @@ function eventText(frame) { return utf8Decode(fromBase64Url(frame.data.data.bytes)); } -test('remote terminal: directory snapshot, attach banner, echo write, resize, detach silences', async () => { +test('remote terminal: directory snapshot, attach banner, echo write, resize, detach blocks input', async () => { const { app, server, host, close } = await boot(); try { const p = await phone(app, server); @@ -463,7 +463,7 @@ test('remote terminal: directory snapshot, attach banner, echo write, resize, de assert.deepEqual(resizeAck.data.result, { cols: 120, rows: 50 }); assert.match(eventText(await p.socket.take()), /resized to 120x50/); - // surface.detach → ack, then a write is acked but produces no more data. + // surface.detach → ack, then stale input is rejected and produces no data. remote(p, 'dt', REMOTE_METHODS.surfaceDetach, { surfaceId }); assert.equal((await p.socket.take()).data.ok, true); @@ -473,8 +473,9 @@ test('remote terminal: directory snapshot, attach banner, echo write, resize, de }); const writeAck2 = await p.socket.take(); assert.equal(writeAck2.data.requestId, 'wr2'); - assert.equal(writeAck2.data.ok, true); - assert.ok(await p.socket.quiet(), 'detach silences the stream: no terminal.data after detach'); + assert.equal(writeAck2.data.ok, false); + assert.match(writeAck2.data.error, /not attached/); + assert.ok(await p.socket.quiet(), 'detach blocks input: no terminal.data after detach'); } finally { await close(); } @@ -520,7 +521,9 @@ test('remote terminal: a stale detach for the previous surface does not kill the surfaceId: b.surfaceId, bytes: toBase64Url(utf8Encode('gone\r')), }); - assert.equal((await p.socket.take()).data.ok, true); + const detachedWrite = await p.socket.take(); + assert.equal(detachedWrite.data.ok, false); + assert.match(detachedWrite.data.error, /not attached/); assert.ok(await p.socket.quiet(), 'no terminal.data after a matching detach'); } finally { await close(); diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs index 37147cab..9e1158a4 100644 --- a/server/test/harness/fake-host.mjs +++ b/server/test/harness/fake-host.mjs @@ -148,8 +148,9 @@ export class FakeHost extends EventEmitter { * Remote-api v1 with a synthetic directory + echo terminal. `hello` answers * capabilities; `directory.watch` snapshots the fake surfaces; `surface.attach` * streams a size banner; `terminal.write` echoes bytes back (treating `\r` as a - * newline and re-drawing a prompt); `terminal.resize` notes the new size; - * `surface.detach` silences the stream. Unknown methods echo ok:false. + * newline and re-drawing a prompt); `terminal.resize` notes the new size. Input + * and resize only apply to the currently attached surface. Unknown methods echo + * ok:false. */ #handleRemoteApi(clientId, data) { const request = data; @@ -198,9 +199,11 @@ export class FakeHost extends EventEmitter { case REMOTE_METHODS.terminalWrite: { const surface = this.#surface(params?.surfaceId); if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); - ok(); const attachment = this.attachments.get(clientId); - if (!attachment || attachment.surfaceId !== surface.surfaceId) return; // detached → silent + if (!attachment || attachment.surfaceId !== surface.surfaceId) { + return fail(`surface is not attached: ${surface.surfaceId}`); + } + ok(); const input = utf8Decode(fromBase64Url(params.bytes)); const echoed = input.includes('\r') ? `${input.replace(/\r/g, '\r\n')}$ ` : input; this.#emitData(clientId, attachment.subId, echoed); @@ -210,11 +213,13 @@ export class FakeHost extends EventEmitter { case REMOTE_METHODS.terminalResize: { const surface = this.#surface(params?.surfaceId); if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + const attachment = this.attachments.get(clientId); + if (!attachment || attachment.surfaceId !== surface.surfaceId) { + return fail(`surface is not attached: ${surface.surfaceId}`); + } surface.cols = clampTerminalDimension(params.cols, surface.cols); surface.rows = clampTerminalDimension(params.rows, surface.rows); ok({ cols: surface.cols, rows: surface.rows }); - const attachment = this.attachments.get(clientId); - if (!attachment || attachment.surfaceId !== surface.surfaceId) return; this.#emitData( clientId, attachment.subId, @@ -299,4 +304,3 @@ export class FakeHost extends EventEmitter { } } } - From f4ba5ad08ba1e65f246ff28f949b0c467e750611 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:55:12 -0700 Subject: [PATCH 34/52] Reject malformed pairing requests before relay --- docs/specs/server.md | 6 ++++-- server/src/handshake.ts | 18 +++++++++++++++--- server/test/handshake.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 4a747a57..4fdee30a 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -127,8 +127,10 @@ phone server host (laptop) The `pair-request` carries the `PairingRequest` shape from `server-lib-common` (`accountId`, `passkeyCredentialId`, `passkeyPublicKeyHash`, `devicePublicKey`, `requestedLabel`). The server checks the session's -credential matches, then relays; the Host runs `PairingCeremony` and only -local approval writes the ACL. +credential matches and rejects malformed requests before relaying; the Host runs +`PairingCeremony` and only local approval writes the ACL. A malformed +`PairingRequest` is answered locally with `pair-result approved:false` and is +never shown in the Host approval UI. ## Connect (every session) diff --git a/server/src/handshake.ts b/server/src/handshake.ts index cb8e0ce6..256af780 100644 --- a/server/src/handshake.ts +++ b/server/src/handshake.ts @@ -45,7 +45,7 @@ export type Connect2Check = */ export interface HandshakeGate { /** Verify a `pair` request before relaying it to the Host. */ - checkPair(request: PairingRequest): Promise; + checkPair(request: unknown): Promise; /** Remember the Host challenge the server just relayed to a client (freshness half). */ observeChallenge(clientId: string, hostId: string, challenge: string, expiresAt: number): void; /** Verify a `connect2` request before relaying it to the Host. */ @@ -85,8 +85,8 @@ export class Handshake implements HandshakeGate { this.#now = config.now ?? (() => Date.now()); } - async checkPair(request: PairingRequest): Promise { - if (!request || typeof request !== 'object') { + async checkPair(request: unknown): Promise { + if (!isPairingRequest(request)) { return { ok: false, error: 'malformed pairing request' }; } if (request.accountId !== SELFHOST_ACCOUNT_ID) { @@ -161,3 +161,15 @@ export class Handshake implements HandshakeGate { return failures.length === 0 ? { ok: true } : { ok: false, failures }; } } + +function isPairingRequest(request: unknown): request is PairingRequest { + return ( + !!request && + typeof request === 'object' && + typeof (request as PairingRequest).accountId === 'string' && + typeof (request as PairingRequest).passkeyCredentialId === 'string' && + typeof (request as PairingRequest).passkeyPublicKeyHash === 'string' && + typeof (request as PairingRequest).devicePublicKey === 'string' && + typeof (request as PairingRequest).requestedLabel === 'string' + ); +} diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index 425ad230..83f8fd79 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -269,6 +269,27 @@ test('server rejects a pair for a credential not on the account, without forward } }); +test('server rejects a malformed pair shape before forwarding', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + const pairs = collect(fakeHost, 'pair'); + const request = await buildPairingRequest(p); + delete request.devicePublicKey; + request.requestedLabel = 42; + + p.socket.send({ t: 'pair', hostId: host.hostId, request }); + const result = await p.socket.take(); + + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, false); + assert.match(result.error, /malformed pairing request/); + assert.equal(pairs.length, 0, 'the Host never saw the malformed pair request'); + } finally { + await close(); + } +}); + test('server rejects connect2 when the assertion is bound to a different challenge', async () => { const { app, server, host, fakeHost, close } = await boot(); try { From 3c745b118b0426fb751e1dde6319f45c079e914c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 09:56:47 -0700 Subject: [PATCH 35/52] Reply when pairing approval expires --- docs/specs/server.md | 4 ++- lib/src/remote/host/remote-host.test.ts | 35 ++++++++++++++++++++++++- lib/src/remote/host/remote-host.ts | 18 ++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 4fdee30a..b1611194 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -175,7 +175,9 @@ A `remote-host` module in `lib`, active in standalone: `authorizeConnection` — all straight from `server-lib-common`, running in the webview. * **Pairing approval modal**: shows the requested label + account; Approve / - Deny. (Same modal pattern as KillConfirm.) + Deny. (Same modal pattern as KillConfirm.) If the Host user approves after + the pairing ticket expires, the Host sends `pair-result approved:false` with + an error and dismisses the modal; the ACL is untouched. * **Terminal bridge**: `directory.watch` snapshots come from the existing terminal registry/state store (title, activity, cwd, exitCode, ringing, hasTODO — all already tracked); `surface.attach` resizes the PTY through diff --git a/lib/src/remote/host/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts index 80ff0656..ca672174 100644 --- a/lib/src/remote/host/remote-host.test.ts +++ b/lib/src/remote/host/remote-host.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { + DEFAULT_PAIRING_TTL_MS, concatBytes, ecdsaRawToDer, generateDeviceKeyPair, @@ -139,7 +140,10 @@ describe('RemoteHost frame handling', () => { let savedRecords: HostAclRecord[] = []; let approvals: PendingPairing[] = []; - function makeHost(loadAcl: () => HostAclRecord[] = () => []) { + function makeHost( + loadAcl: () => HostAclRecord[] = () => [], + now: () => number = () => Date.now(), + ) { savedRecords = []; approvals = []; const host = new RemoteHost({ @@ -152,6 +156,7 @@ describe('RemoteHost frame handling', () => { }, requestApproval: (pending) => approvals.push(pending), dismissApproval: () => {}, + now, }); host.start(); socket.open(); @@ -209,6 +214,34 @@ describe('RemoteHost frame handling', () => { expect(savedRecords).toEqual([]); }); + it('expired approval → pair-result approved:false, ACL untouched', () => { + let now = 1_000; + makeHost(() => [], () => now); + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest, + }); + now += DEFAULT_PAIRING_TTL_MS; + + approvals[0]!.approve(); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ + clientId: 'c1', + approved: false, + error: 'pairing approval expired', + }); + expect(result.record).toBeUndefined(); + expect(savedRecords).toEqual([]); + }); + it('connect issues a challenge frame', () => { makeHost(); socket.receive({ t: 'connect', clientId: 'c1' }); diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts index cdb95d9a..abfddcc2 100644 --- a/lib/src/remote/host/remote-host.ts +++ b/lib/src/remote/host/remote-host.ts @@ -21,6 +21,7 @@ import { HostAcl, HostChallengeIssuer, + PairingError, PairingCeremony, WS_ROUTES, WS_TOKEN_PARAM, @@ -262,7 +263,13 @@ export class RemoteHost { let record: HostAclRecord; try { record = this.#ceremony.approve(pairingId, { approvedBy: 'host-user', label }); - } catch { + } catch (error) { + this.#send({ + t: 'pair-result', + clientId, + approved: false, + error: pairingApprovalError(error), + }); this.#dismissApproval(clientId); return; } @@ -329,3 +336,12 @@ export class RemoteHost { this.#dismissApproval(clientId); } } + +function pairingApprovalError(error: unknown): string { + if (error instanceof PairingError) { + return error.code === 'expired' + ? 'pairing approval expired' + : 'pairing approval is no longer pending'; + } + return 'pairing approval failed'; +} From b5a0e98abb839720f337b6255fcb588dcc83f4ff Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 18:32:52 -0700 Subject: [PATCH 36/52] fix(remote): resolve confirmed review findings from PR #203 Five confirmed issues from the code review: - Pocket app could not reconnect after a host-gone/socket drop: the client nulls its socket on close, but onConnect/onPair sent frames without reopening it, throwing "relay socket is not open" until the user hit Refresh. All frame-sending actions now funnel through an ensureSocket() self-heal. - The remote-host stack (relay/WebSocket/enrollment + the window.dormouseRemoteHost console hook) mounted unconditionally in the shared Wall, shipping it into the website playground and vscode webview. Gated behind an enableRemoteHost prop (standalone only) and lazy-loaded so it stays out of those bundles. - Host bearer-token lookup used a plain === compare while the setup password used timingSafeEqual; aligned it on a constant-time digest compare. - Same-size attach bounce used Math.max(1, rows-1), so a 1-row surface bounced to an identical size -> no SIGWINCH -> no repaint -> blank screen. Bounce up when rows === 1. - Dropped a setHosts(prev => [...prev]) no-op re-render hack in favor of explicit pairedIds state. Co-Authored-By: Claude Opus 4.8 --- lib/src/App.tsx | 4 +++- lib/src/components/Wall.tsx | 26 +++++++++++++++++++++++--- lib/src/remote/host/remote-api.ts | 8 ++++++-- lib/src/remote/pocket-app/App.tsx | 27 ++++++++++++++++++--------- server/src/state.ts | 22 +++++++++++++++++++--- standalone/src/main.tsx | 1 + 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 8437216d..be12b41c 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -27,15 +27,17 @@ export default function App({ restoredLayout, initialDoors, baseboardNotice, + enableRemoteHost, }: { initialPaneIds?: string[]; restoredLayout?: unknown; initialDoors?: PersistedDoor[]; baseboardNotice?: ReactNode; + enableRemoteHost?: boolean; }) { return ( - + ); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 6b289365..5563b511 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'; +import { useRef, useState, useEffect, useCallback, useMemo, lazy, Suspense, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { DockviewReact, @@ -10,7 +10,15 @@ import 'dockview-react/dist/styles/dockview.css'; import { Baseboard } from './Baseboard'; import { ExternalLinkModalHost } from './ExternalLinkModalHost'; import { AgentBrowserScreenModalHost } from './AgentBrowserScreenModalHost'; -import { RemotePairingModalHost } from '../remote/host/RemotePairingModalHost'; +// Remote-host code (relay/WebSocket/enrollment + the window.dormouseRemoteHost +// console hook) is loaded and mounted only when the embedding runtime opts in +// via `enableRemoteHost` — see the mount below. Lazy so it stays out of the +// website playground and vscode webview bundles, which never enable it. +const RemotePairingModalHost = lazy(() => + import('../remote/host/RemotePairingModalHost').then((m) => ({ + default: m.RemotePairingModalHost, + })), +); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; @@ -447,6 +455,7 @@ export function Wall({ onEvent, baseboardNotice, showBaseboard = true, + enableRemoteHost = false, }: { initialPaneIds?: string[]; initialMode?: WallMode; @@ -456,6 +465,13 @@ export function Wall({ onEvent?: (event: WallEvent) => void; baseboardNotice?: ReactNode; showBaseboard?: boolean; + /** + * Opt in to the remote-control Host (the "Pocket" pairing seam). Only the + * standalone desktop/sidecar runtime sets this; the website playground and + * vscode webview leave it off so the remote-host stack and its + * `window.dormouseRemoteHost` console hook never load there. + */ + enableRemoteHost?: boolean; } = {}) { const apiRef = useRef(null); const [dockviewApi, setDockviewApi] = useState(null); @@ -1846,7 +1862,11 @@ export function Wall({ onKeyboardActiveChange={setDialogKeyboardActive} resolveLabel={surfaceRefForId} /> - + {enableRemoteHost ? ( + + + + ) : null}
diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 7167a3ea..b591a7ce 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -250,8 +250,12 @@ export class RemoteApiSession { term.resize(cols, rows); } else { // Same size: force one repaint with a quick rows bounce on the PTY only, - // leaving the already-correct local xterm buffer untouched. - platform.resizePty(ptyId, cols, Math.max(1, rows - 1)); + // leaving the already-correct local xterm buffer untouched. Bounce away + // from `rows` in whichever direction stays >= 1 (a 1-row surface must + // bounce up, since rows-1 would be an identical no-op that fires no + // SIGWINCH and so never repaints). + const bounced = rows > 1 ? rows - 1 : rows + 1; + platform.resizePty(ptyId, cols, bounced); setTimeout(() => platform.resizePty(ptyId, cols, rows), FORCE_REPAINT_BOUNCE_MS); } diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 9782aab5..8c6cd63c 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -51,9 +51,18 @@ export default function App(): React.ReactElement { const [error, setError] = useState(null); const [busy, setBusy] = useState(null); const [hosts, setHosts] = useState([]); + const [pairedIds, setPairedIds] = useState>(() => new Set()); const [activeHost, setActiveHost] = useState(null); const adapterRef = useRef(null); + // The client nulls its socket on any close, so an action taken after a + // server restart / network drop must reopen it rather than reuse a dead + // socket (which would throw 'relay socket is not open'). Every user action + // that sends a frame funnels through here so it self-heals. + const ensureSocket = useCallback(async () => { + if (!client.socketOpen) await client.openSocket(); + }, [client]); + const run = useCallback(async (label: string, fn: () => Promise) => { setError(null); setBusy(label); @@ -67,14 +76,12 @@ export default function App(): React.ReactElement { }, []); const loadHosts = useCallback(async () => { - // The client nulls its socket on any close, so this self-heals after a - // server restart or network drop instead of reusing a dead socket. - if (!client.socketOpen) { - await client.openSocket(); - } - setHosts(await client.listHosts()); + await ensureSocket(); + const list = await client.listHosts(); + setHosts(list); + setPairedIds(new Set(list.filter((h) => client.isPaired(h.hostId)).map((h) => h.hostId))); setPhase('hosts'); - }, [client]); + }, [client, ensureSocket]); /** Tear down the live session and return to the hosts list. */ const teardownAdapter = useCallback(() => { @@ -96,6 +103,7 @@ export default function App(): React.ReactElement { const onConnect = (host: HostView) => run('connect', async () => { + await ensureSocket(); const decision: ConnectDecision = await client.connect(host.hostId); if (!decision.allowed) { throw new Error(`Connection denied${decision.failures ? `: ${decision.failures.join(', ')}` : ''}`); @@ -117,9 +125,10 @@ export default function App(): React.ReactElement { const onPair = (host: HostView) => run('pair', async () => { + await ensureSocket(); const result = await client.pair(host.hostId, DEVICE_LABEL); if (!result.approved) throw new Error(result.error ?? 'Pairing was denied.'); - setHosts((prev) => [...prev]); // reflect the new paired state + setPairedIds((prev) => new Set(prev).add(host.hostId)); }); const leaveWall = () => { @@ -156,7 +165,7 @@ export default function App(): React.ReactElement { hosts={hosts} busy={busy} error={error} - isPaired={(id) => client.isPaired(id)} + isPaired={(id) => pairedIds.has(id)} onRefresh={() => run('refresh', loadHosts)} onPair={onPair} onConnect={onConnect} diff --git a/server/src/state.ts b/server/src/state.ts index 46ddf90a..dfd6c3b7 100644 --- a/server/src/state.ts +++ b/server/src/state.ts @@ -14,7 +14,7 @@ */ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { randomBytes, randomUUID } from 'node:crypto'; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { join } from 'node:path'; import { SELFHOST_ACCOUNT_ID, toBase64Url } from 'server-lib-common'; @@ -95,6 +95,11 @@ abstract class JsonFileStore { } } +/** Fixed-length SHA-256 digest, so timing-safe compares never branch on length. */ +function sha256(text: string): Buffer { + return createHash('sha256').update(text, 'utf8').digest(); +} + export class AccountStore extends JsonFileStore { constructor(stateDir: string, now: () => number = () => Date.now()) { super(stateDir, 'account.json', now); @@ -155,10 +160,21 @@ export class HostStore extends JsonFileStore { return this.read([]); } - /** Look up an enrolled host by its bearer token (the `/ws/host` credential). */ + /** + * Look up an enrolled host by its bearer token (the `/ws/host` credential). + * The token is a secret, so it is compared with a constant-time digest + * compare (mirroring the setup-password path in app.ts) rather than `===`, + * whose early-exit leaks byte positions. Every host is checked without an + * early break so the work does not depend on which entry matches. + */ async findByToken(hostToken: string): Promise { const hosts = await this.list(); - return hosts.find((h) => h.hostToken === hostToken); + const providedHash = sha256(hostToken); + let match: StoredHost | undefined; + for (const h of hosts) { + if (timingSafeEqual(sha256(h.hostToken), providedHash)) match = h; + } + return match; } /** diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index a1fd5573..b7fc89a5 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -104,6 +104,7 @@ async function bootstrap() { restoredLayout={result.layout} initialDoors={result.doors} baseboardNotice={} + enableRemoteHost /> , ); From facfab7fc4c5eb93de39117cda2aaf02a3955fe8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 18:41:41 -0700 Subject: [PATCH 37/52] fix(remote): address remaining review findings from PR #203 Follow-up to the confirmed-fix commit; resolves the plausible/maintainability items surfaced in review. Correctness / robustness: - Pocket connect no longer dead-ends with multiple synced passkeys: the assertion is now scoped to credentials this device has a stored public key for (non-empty allowCredentials) instead of letting the OS pick an unverifiable one. Empty list preserves first-time discovery. - remote-adapter maps an absent terminal exitCode to -1 (unknown/failure), matching the local terminal path, instead of coercing to a clean 0. - The same-size attach repaint bounce timer is now cancellable and identity- guarded, so a stale timer can't clobber a newer attachment or a disposed session within its ~60ms window. Security-model consistency: - Thread requireUserVerification through the server handshake (connect2) and sign-in so the server enforces the same UV policy as the host and the two verifiers can't drift. Default off; no behavior change unless configured. Simplification / reuse / efficiency: - Collapse pocket-client's per-type waiter FIFO arrays to a single waiter per frame type (the handshake is strictly sequential); route close()/#onClose teardown through one #teardown(reason, { notifyGone }) helper. - Consolidate remote-host's three lockstep per-client collections into one Map. - Extract a shared local-json-store helper behind the host ACL and enrollment localStorage persistence. - directory-collect: pass each pane only itself to deriveHeader (its .primary is independent of the pane list; the cross-pane scan only fed the discarded secondary), cutting per-snapshot work from O(n^2) to O(n) with identical output. Verified: server-lib-common 111/111, server 63/63, lib 808/808 vitest; lib/server/standalone all typecheck clean. Co-Authored-By: Claude Opus 4.8 --- lib/src/lib/local-json-store.test.ts | 87 ++++++++++++++++++++ lib/src/lib/local-json-store.ts | 49 +++++++++++ lib/src/remote/client/pocket-client.test.ts | 56 +++++++++++++ lib/src/remote/client/pocket-client.ts | 77 ++++++++++++----- lib/src/remote/client/remote-adapter.test.ts | 27 ++++++ lib/src/remote/client/remote-adapter.ts | 7 +- lib/src/remote/client/webauthn.ts | 31 +++++-- lib/src/remote/host/acl.ts | 27 ++---- lib/src/remote/host/directory-collect.ts | 15 +++- lib/src/remote/host/enrollment.ts | 17 +--- lib/src/remote/host/remote-api.test.ts | 54 ++++++++++++ lib/src/remote/host/remote-api.ts | 27 +++++- lib/src/remote/host/remote-host.ts | 68 ++++++++++----- server/src/app.ts | 18 +++- server/src/handshake.ts | 13 +++ server/test/handshake.test.mjs | 56 ++++++++++++- server/test/helpers.mjs | 15 +++- 17 files changed, 552 insertions(+), 92 deletions(-) create mode 100644 lib/src/lib/local-json-store.test.ts create mode 100644 lib/src/lib/local-json-store.ts diff --git a/lib/src/lib/local-json-store.test.ts b/lib/src/lib/local-json-store.test.ts new file mode 100644 index 00000000..de4f2964 --- /dev/null +++ b/lib/src/lib/local-json-store.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { loadJson, saveJson } from './local-json-store'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +interface Widget { + id: string; +} + +function isWidget(value: unknown): value is Widget { + return !!value && typeof value === 'object' && typeof (value as Widget).id === 'string'; +} + +describe('local-json-store', () => { + afterEach(() => vi.unstubAllGlobals()); + + describe('loadJson', () => { + it('round-trips a stored value', () => { + const store = stubLocalStorage(); + store.set('k', JSON.stringify({ id: 'w1' })); + expect(loadJson('k', null, isWidget)).toEqual({ id: 'w1' }); + }); + + it('returns the fallback for a missing key', () => { + stubLocalStorage(); + expect(loadJson('missing', null, isWidget)).toBeNull(); + expect(loadJson('missing', [])).toEqual([]); + }); + + it('returns the fallback for malformed JSON', () => { + const store = stubLocalStorage(); + store.set('k', 'not json'); + expect(loadJson('k', null, isWidget)).toBeNull(); + expect(loadJson('k', [], Array.isArray)).toEqual([]); + }); + + it('returns the fallback when the guard rejects the parsed value', () => { + const store = stubLocalStorage(); + store.set('k', JSON.stringify({ notId: 42 })); + expect(loadJson('k', null, isWidget)).toBeNull(); + }); + + it('returns the parsed value unvalidated when no guard is given', () => { + const store = stubLocalStorage(); + store.set('k', JSON.stringify({ id: 'w1' })); + expect(loadJson('k', null)).toEqual({ id: 'w1' }); + }); + + it('returns the fallback when localStorage is absent', () => { + vi.stubGlobal('localStorage', undefined); + expect(loadJson('k', null, isWidget)).toBeNull(); + expect(loadJson('k', [], Array.isArray)).toEqual([]); + }); + }); + + describe('saveJson', () => { + it('JSON-stringifies and writes the value', () => { + const store = stubLocalStorage(); + saveJson('k', { id: 'w1' }); + expect(store.get('k')).toBe(JSON.stringify({ id: 'w1' })); + }); + + it('does not throw when localStorage is absent', () => { + vi.stubGlobal('localStorage', undefined); + expect(() => saveJson('k', { id: 'w1' })).not.toThrow(); + }); + + it('swallows a write failure (e.g. quota exceeded)', () => { + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => { + throw new DOMException('quota', 'QuotaExceededError'); + }, + removeItem: () => {}, + }); + expect(() => saveJson('k', { id: 'w1' })).not.toThrow(); + }); + }); +}); diff --git a/lib/src/lib/local-json-store.ts b/lib/src/lib/local-json-store.ts new file mode 100644 index 00000000..5311ff8e --- /dev/null +++ b/lib/src/lib/local-json-store.ts @@ -0,0 +1,49 @@ +/** + * The load/save dance for a single JSON blob kept in `localStorage`. Several + * host-side stores (the ACL, the enrollment credentials) persist one value + * under one key with identical failure semantics: + * + * - absent `localStorage` (SSR / no-storage host / test context) must not + * throw — reads yield the fallback, writes are silently dropped; + * - a missing key, malformed JSON, or a value that fails validation all + * collapse to the caller's fallback rather than propagating; + * - a failed write (no storage, quota exceeded) is swallowed so the + * in-memory value keeps working for the session. + * + * Each caller supplies its own key, fallback, and (optionally) a type guard, so + * the fallback and validation stay caller-specific while the boilerplate lives + * here once. + */ + +/** + * Read and JSON-parse the value at `key`, returning `fallback` if storage is + * unavailable, the key is missing, the JSON is malformed, or `validate` (when + * given) rejects the parsed value. + */ +export function loadJson( + key: string, + fallback: F, + validate?: (value: unknown) => value is V, +): V | F { + try { + const raw = globalThis.localStorage?.getItem(key); + if (!raw) return fallback; + const parsed: unknown = JSON.parse(raw); + if (validate && !validate(parsed)) return fallback; + return parsed as V; + } catch { + return fallback; + } +} + +/** + * JSON-stringify `value` and write it to `key`, swallowing any failure (absent + * storage, quota exceeded) so callers keep their in-memory value. + */ +export function saveJson(key: string, value: unknown): void { + try { + globalThis.localStorage?.setItem(key, JSON.stringify(value)); + } catch { + // No localStorage / quota exceeded: the in-memory value still works. + } +} diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 639d68ee..f62ab2b7 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -92,11 +92,33 @@ function memoryStorage(): PocketStorage { return { getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), + knownCredentialIds: () => [...passkeys.keys()], isPaired: (hostId) => paired.has(hostId), markPaired: (hostId) => void paired.add(hostId), }; } +/** + * A {@link WebAuthnClient} that records the `allowCredentials` each + * `getAssertion` is scoped to, so tests can assert connect narrows selection. + */ +function recordingWebAuthn(): { + webauthn: WebAuthnClient; + assertionAllowLists: Array; +} { + const assertionAllowLists: Array = []; + return { + assertionAllowLists, + webauthn: { + registerPasskey: fakeWebAuthn.registerPasskey, + async getAssertion(_challenge, _rpId, allowCredentials): Promise { + assertionAllowLists.push(allowCredentials); + return assertion; + }, + }, + }; +} + class FakeSocket implements PocketSocket { readyState = 0; readonly sent: Array> = []; @@ -289,6 +311,40 @@ describe('connect', () => { expect(client.connectedHostId).toBe('h1'); }); + it('scopes the connect assertion to the stored credential and resolves its public key', async () => { + const { webauthn, assertionAllowLists } = recordingWebAuthn(); + const { client, socket } = await signedIn({ webauthn }); + + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + const connect2 = await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: true }); + + const decision = await connecting; + expect(decision.allowed).toBe(true); + // sign-in discovers (empty list); connect scopes to the credential setup stored. + expect(assertionAllowLists.at(-1)).toEqual([CREDENTIAL_ID]); + // ...so the stored public key is the one placed into the connect2 request. + const request = connect2.request as { passkey: { publicKey: string } }; + expect(request.passkey.publicKey).toBe(PASSKEY_PUBLIC_KEY); + }); + + it('rejects a second waiter for an already-pending frame type', async () => { + const { client, socket } = await signedIn(); + const first = client.connect('h1'); + // Once the first connect is awaiting its challenge, a second overlapping + // connect must not silently queue behind it. + await nextSent(socket, (f) => f.t === 'connect'); + await expect(client.connect('h1')).rejects.toThrow(/already awaiting/); + + // The first handshake still completes normally. + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: true }); + expect((await first).allowed).toBe(true); + }); + it('resolves not-allowed with failures on a denied decision', async () => { const { client, socket } = await signedIn(); const connecting = client.connect('h1'); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 42423383..5a7b5ca0 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -61,6 +61,8 @@ export type PocketSocket = RemoteWebSocket; export interface PocketStorage { getPasskeyPublicKey(credentialId: string): string | null; setPasskeyPublicKey(credentialId: string, publicKey: string): void; + /** Credential ids this device has stored a public key for (may be empty). */ + knownCredentialIds(): string[]; isPaired(hostId: string): boolean; markPaired(hostId: string): void; } @@ -125,8 +127,14 @@ export class PocketClient { #deviceKey: DeviceKeyPair | null = null; #onHostGone: (() => void) | null = null; - /** Handshake frames (`pair-result`/`challenge`/`decision`) awaited FIFO by type. */ - readonly #waiters = new Map(); + /** + * The single in-flight handshake waiter per frame type + * (`pair-result`/`challenge`/`decision`). The handshake awaits exactly one of + * each in strict sequence and the App's single-flight guard forbids overlap, + * so at most one waiter per type is ever pending — {@link #expect} throws if a + * second is registered rather than silently queueing it. + */ + readonly #waiters = new Map(); /** In-flight remote-api requests, keyed by `requestId`. */ readonly #pending = new Map(); /** Live event subscriptions, keyed by `subId`. */ @@ -261,7 +269,15 @@ export class PocketClient { >; const challenge = challengeFrame.challenge; - const assertion = await this.#webauthn.getAssertion(challenge, this.#requireRpId()); + // Scope the assertion to credentials this device has a stored public key for. + // With several synced passkeys for one rpId, an empty allowCredentials lets + // the OS pick a credential whose public key we never stored — an unverifiable + // dead end below. An empty list here (first-time flows) preserves discovery. + const assertion = await this.#webauthn.getAssertion( + challenge, + this.#requireRpId(), + this.#storage.knownCredentialIds(), + ); const deviceSignature = await signDeviceChallenge(device.privateKey, { hostId, challenge, @@ -378,11 +394,10 @@ export class PocketClient { close(): void { const ws = this.#ws; - // Null BEFORE closing: #onClose reads `#ws === null` as "intentional - // close", and while real sockets emit their close event asynchronously, - // test fakes may emit synchronously from within close(). - this.#ws = null; - this.#connectedHostId = null; + // Tear down BEFORE closing the socket: #onClose reads `#ws === null` as an + // intentional close (no host-gone), and while real sockets emit their close + // event asynchronously, test fakes may emit it synchronously from close(). + this.#teardown('relay socket closed', { notifyGone: false }); try { ws?.close(); } catch { @@ -411,10 +426,9 @@ export class PocketClient { } #expect(type: 'pair-result' | 'challenge' | 'decision'): Promise { + if (this.#waiters.has(type)) throw new Error(`already awaiting a '${type}' frame`); return new Promise((resolve, reject) => { - const list = this.#waiters.get(type) ?? []; - list.push({ resolve, reject }); - this.#waiters.set(type, list); + this.#waiters.set(type, { resolve, reject }); }); } @@ -430,8 +444,11 @@ export class PocketClient { case 'pair-result': case 'challenge': case 'decision': { - const waiter = this.#waiters.get(frame.t)?.shift(); - waiter?.resolve(frame); + const waiter = this.#waiters.get(frame.t); + if (waiter) { + this.#waiters.delete(frame.t); + waiter.resolve(frame); + } return; } case 'msg': @@ -467,22 +484,31 @@ export class PocketClient { } #onClose(): void { - // `close()` nulls #ws before the event fires, so a non-null #ws here means - // the socket died on us (server restart, network drop) rather than being - // closed intentionally. + // `close()` tears down and nulls #ws before the event fires, so a non-null + // #ws here means the socket died on us (server restart, network drop) rather + // than an intentional close. An unexpected drop of an established session is + // still host loss — the app must leave the wall instead of idling on a dead + // stream — even without a `host-gone` frame. const unexpected = this.#ws !== null; const hadSession = this.#connectedHostId !== null; + this.#teardown('relay socket closed', { notifyGone: unexpected && hadSession }); + } + + /** + * Reset all socket-bound state and fail pending work. The one real difference + * between an intentional {@link close} and an unexpected drop is whether to + * fire `onHostGone`, made explicit here via `notifyGone`. + */ + #teardown(reason: string, { notifyGone }: { notifyGone: boolean }): void { this.#ws = null; // never reuse a closed socket; openSocket() makes a fresh one this.#connectedHostId = null; - this.#rejectAll(new Error('relay socket closed')); - // A close without a `host-gone` frame is still host loss for an established - // session — the app must leave the wall instead of idling on a dead stream. - if (unexpected && hadSession) this.#onHostGone?.(); + this.#rejectAll(new Error(reason)); + if (notifyGone) this.#onHostGone?.(); } /** Fail every awaited handshake frame and in-flight request (avoids hangs). */ #rejectAll(error: Error): void { - for (const list of this.#waiters.values()) for (const waiter of list) waiter.reject(error); + for (const waiter of this.#waiters.values()) waiter.reject(error); this.#waiters.clear(); for (const pending of this.#pending.values()) pending.reject(error); this.#pending.clear(); @@ -528,6 +554,15 @@ export function localStoragePocketStorage(): PocketStorage { globalThis.localStorage.getItem(PASSKEY_PREFIX + credentialId), setPasskeyPublicKey: (credentialId, publicKey) => globalThis.localStorage.setItem(PASSKEY_PREFIX + credentialId, publicKey), + knownCredentialIds: () => { + const store = globalThis.localStorage; + const ids: string[] = []; + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key?.startsWith(PASSKEY_PREFIX)) ids.push(key.slice(PASSKEY_PREFIX.length)); + } + return ids; + }, isPaired: (hostId) => globalThis.localStorage.getItem(PAIRED_PREFIX + hostId) === '1', markPaired: (hostId) => globalThis.localStorage.setItem(PAIRED_PREFIX + hostId, '1'), }; diff --git a/lib/src/remote/client/remote-adapter.test.ts b/lib/src/remote/client/remote-adapter.test.ts index 69ffec1a..111602cf 100644 --- a/lib/src/remote/client/remote-adapter.test.ts +++ b/lib/src/remote/client/remote-adapter.test.ts @@ -220,6 +220,33 @@ describe('RemotePtyAdapter attach / active pane', () => { adapter.writePty('s1', 'x'); expect(client.writes).toHaveLength(0); }); + + it('terminal.closed with an omitted exitCode surfaces the unknown-exit sentinel (-1), not 0', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((d) => exits.push(d)); + + await adapter.setActivePane('s1', 80, 24); + // TerminalClosedEvent.exitCode is optional on the wire; a signal-only / + // killed / non-selfhost close forwards no code. It must not read as 0. + client.lastAttach().handlers.onClosed?.(undefined); + + expect(exits).toEqual([{ id: 's1', exitCode: -1 }]); + expect(adapter.activeSurfaceId).toBeNull(); + }); + + it('terminal.closed with a present exitCode passes it through unchanged (incl. 0)', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((d) => exits.push(d)); + + await adapter.setActivePane('s1', 80, 24); + client.lastAttach().handlers.onClosed?.(0); + + expect(exits).toEqual([{ id: 's1', exitCode: 0 }]); + }); }); describe('RemotePtyAdapter dispose', () => { diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index 64be464b..d9eb3337 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -297,7 +297,12 @@ export class RemotePtyAdapter implements PlatformAdapter { #emitExit(id: string, exitCode?: number): void { if (this.#attached?.surfaceId === id) this.#attached = null; - for (const handler of this.#exitHandlers) handler({ id, exitCode: exitCode ?? 0 }); + // An absent exitCode is an UNKNOWN termination (signal-only, killed, or a + // non-selfhost Host that never reports one) — not a clean exit. Coercing it + // to 0 would paint an abnormal close as success. Map it to the same -1 + // sentinel the local path uses (terminal-lifecycle.ts `exitCode ?? -1`), + // which renders as a nonzero "failure" exit, so remote and local agree. + for (const handler of this.#exitHandlers) handler({ id, exitCode: exitCode ?? -1 }); } // --- Degraded capabilities (absent PTY features) ------------------------- diff --git a/lib/src/remote/client/webauthn.ts b/lib/src/remote/client/webauthn.ts index 48d55755..7dc8c3c0 100644 --- a/lib/src/remote/client/webauthn.ts +++ b/lib/src/remote/client/webauthn.ts @@ -24,7 +24,16 @@ export interface PasskeyRegistration { /** The two authenticator operations the Pocket client needs; faked in tests. */ export interface WebAuthnClient { registerPasskey(challenge: string, rpId: string, accountId: string): Promise; - getAssertion(challenge: string, rpId: string): Promise; + /** + * Get an assertion bound to `challenge`. Pass `allowCredentials` (base64url + * credential ids) to scope selection to specific passkeys; leave it empty to + * discover any of the account's resident credentials. + */ + getAssertion( + challenge: string, + rpId: string, + allowCredentials?: readonly string[], + ): Promise; } /** @@ -80,16 +89,26 @@ async function registerPasskey( } /** - * Get an assertion from any of the account's discoverable passkeys (empty - * `allowCredentials`), bound to `challenge`. One call feeds both the sign-in - * and the connect handshakes, so the user sees a single biometric prompt. + * Get an assertion bound to `challenge`. Sign-in leaves `allowCredentials` empty + * to discover any of the account's resident passkeys; connect passes the stored + * credential id(s) so the authenticator selects a passkey this device can verify + * (with several synced passkeys for one rpId, an empty list lets the OS pick one + * whose public key we never stored). One call feeds both handshakes, so the user + * sees a single biometric prompt. */ -async function getAssertion(challenge: string, rpId: string): Promise { +async function getAssertion( + challenge: string, + rpId: string, + allowCredentials: readonly string[] = [], +): Promise { const credential = (await navigator.credentials.get({ publicKey: { challenge: toBufferSource(fromBase64Url(challenge)), rpId, - allowCredentials: [], + allowCredentials: allowCredentials.map((id) => ({ + type: 'public-key', + id: toBufferSource(fromBase64Url(id)), + })), userVerification: 'preferred', }, })) as PublicKeyCredential | null; diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts index fa2bde5c..06e8d7f9 100644 --- a/lib/src/remote/host/acl.ts +++ b/lib/src/remote/host/acl.ts @@ -9,6 +9,7 @@ */ import { HostAcl, type HostAclRecord } from 'server-lib-common'; +import { loadJson, saveJson } from '../../lib/local-json-store'; export const ACL_KEY_PREFIX = 'dormouse.remote-host.acl.'; @@ -18,27 +19,17 @@ function aclKey(hostId: string): string { /** Load the persisted records for a host, dropping anything malformed. */ export function loadAclRecords(hostId: string): HostAclRecord[] { - try { - const raw = globalThis.localStorage?.getItem(aclKey(hostId)); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - // Only keep records for this host; fromRecords rejects a mismatched hostId. - return parsed.filter( - (record): record is HostAclRecord => - !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, - ); - } catch { - return []; - } + // Missing key / malformed JSON / non-array all collapse to `[]`. + const parsed = loadJson(aclKey(hostId), [], Array.isArray); + // Only keep records for this host; fromRecords rejects a mismatched hostId. + return parsed.filter( + (record): record is HostAclRecord => + !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, + ); } export function saveAclRecords(hostId: string, records: readonly HostAclRecord[]): void { - try { - globalThis.localStorage?.setItem(aclKey(hostId), JSON.stringify(records)); - } catch { - // No localStorage: the in-memory ACL still works for this session. - } + saveJson(aclKey(hostId), records); } /** diff --git a/lib/src/remote/host/directory-collect.ts b/lib/src/remote/host/directory-collect.ts index 56f3e3b7..9b4f4199 100644 --- a/lib/src/remote/host/directory-collect.ts +++ b/lib/src/remote/host/directory-collect.ts @@ -24,9 +24,8 @@ export function collectDirectorySnapshot(): DirectoryEntry[] { const appTitleForPane = buildAppTitleResolver(paneStates, activityStates); const ids = [...registry.keys()]; - // The wall derives titles across all visible panes so duplicates disambiguate; - // feed it the same set here. Reuse these per-pane states below rather than - // re-fetching (each miss would allocate a fresh default twice). + // Reuse these per-pane states in the map below rather than re-fetching (each + // miss would allocate a fresh default twice). const allPanes = ids.map((id) => getTerminalPaneState(id)); const active = typeof document !== 'undefined' ? document.activeElement : null; @@ -37,8 +36,16 @@ export function collectDirectorySnapshot(): DirectoryEntry[] { const activity = activityStates.get(id); const element = registry.get(id)?.element ?? null; const focused = !!element && !!active && element.contains(active); + // The directory entry shows only the derived `primary`; it has no + // secondary/cwd-disambiguation field. `deriveHeader`'s `primary` is a pure + // per-pane value (`headerPrimary`) computed independently of the pane list + // it's given — that list drives only `secondary`, which this path discards. + // Feeding the full set here would rerun deriveHeader's O(n) same-primary + // scan (and `shortestUniqueCwdLabels`) once per pane, i.e. O(n²) per + // 150ms-debounced snapshot, to build a value nothing reads. Compare the + // pane against only itself so that scan is O(1); `primary` is identical. const title = resolveDisplayPrimary( - deriveHeader(pane, allPanes, { appTitleForPane }).primary, + deriveHeader(pane, [pane], { appTitleForPane }).primary, null, ); return { diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index 0e91bf8f..59891f2a 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -11,6 +11,7 @@ */ import { API_ROUTES, type HostEnrollResponse } from 'server-lib-common'; +import { loadJson, saveJson } from '../../lib/local-json-store'; export interface HostEnrollment { /** Origin the Server is reachable at, e.g. `https://dormouse.tailnet.ts.net`. */ @@ -40,14 +41,8 @@ function isEnrollment(value: unknown): value is HostEnrollment { } export function getEnrollment(): HostEnrollment | null { - try { - const raw = globalThis.localStorage?.getItem(ENROLLMENT_KEY); - if (!raw) return null; - const parsed: unknown = JSON.parse(raw); - return isEnrollment(parsed) ? parsed : null; - } catch { - return null; - } + // Missing key / malformed JSON / failed guard all collapse to `null`. + return loadJson(ENROLLMENT_KEY, null, isEnrollment); } export function clearEnrollment(): void { @@ -59,11 +54,7 @@ export function clearEnrollment(): void { } function saveEnrollment(enrollment: HostEnrollment): void { - try { - globalThis.localStorage?.setItem(ENROLLMENT_KEY, JSON.stringify(enrollment)); - } catch { - // No localStorage: the caller still gets the in-memory enrollment back. - } + saveJson(ENROLLMENT_KEY, enrollment); } /** diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 8346759c..9ece709a 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -142,6 +142,60 @@ describe('RemoteApiSession surface.attach', () => { expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-1', 80, 24); }); + it('does not fire the same-size bounce restore after detaching', () => { + vi.useFakeTimers(); + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 80, 24); + + // The synchronous bounce away from `rows` has fired; the restore is pending. + expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + expect(platform.resizePty).toHaveBeenCalledTimes(1); + + // Detach inside the ~60ms window, before the restore fires. + session.handle({ + requestId: 'detach', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + + vi.advanceTimersByTime(60); + + // The stale restore must never touch the now-detached PTY. + expect(platform.resizePty).toHaveBeenCalledTimes(1); + expect(platform.resizePty).not.toHaveBeenCalledWith('pty-1', 80, 24); + }); + + it('does not let a stale bounce restore clobber a newer attachment', () => { + vi.useFakeTimers(); + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + registerSurface(platform, 80, 24, 'surface-2', 'pty-2'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + // First attach schedules a restore bounce for pty-1. + attach(session, 80, 24, 'surface-1'); + expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + + // Re-attaching to a different surface replaces the attachment (last-attach-wins) + // and must cancel the prior pty-1 restore. + attach(session, 80, 24, 'surface-2'); + expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-2', 80, 23); + + vi.advanceTimersByTime(60); + + // Only the current attachment's restore fires; pty-1's stale restore does not. + expect(platform.resizePty).toHaveBeenNthCalledWith(3, 'pty-2', 80, 24); + expect(platform.resizePty).toHaveBeenCalledTimes(3); + expect(platform.resizePty).not.toHaveBeenCalledWith('pty-1', 80, 24); + }); + it('rejects write and resize unless the surface is the current attachment', () => { const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index b591a7ce..020f6bb1 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -55,6 +55,8 @@ interface Attachment { subId: string; onData: (detail: { id: string; data: string }) => void; onExit: (detail: { id: string; exitCode: number }) => void; + /** Pending same-size repaint bounce (see FORCE_REPAINT_BOUNCE_MS), if any. */ + bounceTimer: ReturnType | null; } export interface RemoteApiSessionOptions { @@ -240,7 +242,15 @@ export class RemoteApiSession { }; platform.onPtyData(onData); platform.onPtyExit(onExit); - this.#attachment = { surfaceId: params.surfaceId, ptyId, subId, onData, onExit }; + const attachment: Attachment = { + surfaceId: params.surfaceId, + ptyId, + subId, + onData, + onExit, + bounceTimer: null, + }; + this.#attachment = attachment; // Attach-is-the-resize: resizing the real xterm fires its onResize handler, // which drives resizePty → SIGWINCH → the TUI/shell repaints, and that @@ -256,7 +266,16 @@ export class RemoteApiSession { // SIGWINCH and so never repaints). const bounced = rows > 1 ? rows - 1 : rows + 1; platform.resizePty(ptyId, cols, bounced); - setTimeout(() => platform.resizePty(ptyId, cols, rows), FORCE_REPAINT_BOUNCE_MS); + // The restore runs ~60ms later, so the client may detach, re-attach at a + // different size, or dispose the session first. Cancel on teardown and, + // as a backstop, re-check this is still the current attachment before + // touching the PTY — a stale restore would clobber the newer size owner + // (last-attach-wins) or resize a detached/exited PTY. + attachment.bounceTimer = setTimeout(() => { + attachment.bounceTimer = null; + if (this.#attachment !== attachment) return; + platform.resizePty(ptyId, cols, rows); + }, FORCE_REPAINT_BOUNCE_MS); } const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; @@ -303,6 +322,10 @@ export class RemoteApiSession { #teardownAttachment(): void { if (!this.#attachment) return; + if (this.#attachment.bounceTimer) { + clearTimeout(this.#attachment.bounceTimer); + this.#attachment.bounceTimer = null; + } const platform = getPlatform(); platform.offPtyData(this.#attachment.onData); platform.offPtyExit(this.#attachment.onExit); diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts index abfddcc2..aeee9589 100644 --- a/lib/src/remote/host/remote-host.ts +++ b/lib/src/remote/host/remote-host.ts @@ -51,6 +51,16 @@ export interface RemoteApiSessionLike { /** Minimal WebSocket surface, so tests can inject a fake. */ export type WebSocketLike = RemoteWebSocket; +/** Per-client lifecycle state tracked by the Host, keyed by clientId. */ +interface ClientState { + /** True once the Host allowed this client's connection — the `msg` gate. */ + established: boolean; + /** The in-flight pairing awaiting local approval, if any. */ + pending?: PendingPairing; + /** The remote-api handler, created on the first authorized `msg`. */ + session?: RemoteApiSessionLike; +} + export type RemoteHostStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'stopped'; export interface RemoteHostOptions { @@ -90,12 +100,13 @@ export class RemoteHost { readonly #now: () => number; readonly #reconnect: boolean; - /** clientIds whose connection the Host allowed — the `msg` gate on this side. */ - readonly #established = new Set(); - /** clientId → the in-flight pairing awaiting local approval. */ - readonly #pending = new Map(); - /** clientId → its remote-api handler, created on first authorized `msg`. */ - readonly #sessions = new Map(); + /** + * Per-client lifecycle state keyed by clientId. Folding the three concerns + * (allowed connection, in-flight pairing, live session) into one record makes + * teardown a single `delete` — no handler can leave the collections out of + * sync. + */ + readonly #clients = new Map(); #ws: WebSocketLike | null = null; #status: RemoteHostStatus = 'idle'; @@ -195,11 +206,21 @@ export class RemoteHost { /** Connection-scoped state resets on a dropped socket (the ACL persists). */ #dropTransientState(): void { - for (const session of this.#sessions.values()) session.dispose(); - this.#sessions.clear(); - for (const clientId of this.#pending.keys()) this.#dismissApproval(clientId); - this.#pending.clear(); - this.#established.clear(); + for (const state of this.#clients.values()) state.session?.dispose(); + for (const [clientId, state] of this.#clients) { + if (state.pending) this.#dismissApproval(clientId); + } + this.#clients.clear(); + } + + /** Get or create the per-client state record for `clientId`. */ + #clientState(clientId: string): ClientState { + let state = this.#clients.get(clientId); + if (!state) { + state = { established: false }; + this.#clients.set(clientId, state); + } + return state; } #send(frame: HostFrame): void { @@ -253,13 +274,15 @@ export class RemoteHost { approve: (label) => this.#approvePairing(clientId, ticket.pairingId, label), deny: (error) => this.#denyPairing(clientId, ticket.pairingId, error), }; - this.#pending.set(clientId, pending); + this.#clientState(clientId).pending = pending; this.#requestApproval(pending); } /** The local approval — the ONLY path that writes the ACL. */ #approvePairing(clientId: string, pairingId: string, label?: string): void { - if (!this.#pending.delete(clientId)) return; // already resolved + const state = this.#clients.get(clientId); + if (!state?.pending) return; // already resolved + state.pending = undefined; let record: HostAclRecord; try { record = this.#ceremony.approve(pairingId, { approvedBy: 'host-user', label }); @@ -279,7 +302,9 @@ export class RemoteHost { } #denyPairing(clientId: string, pairingId: string, error = 'pairing denied by host'): void { - if (!this.#pending.delete(clientId)) return; + const state = this.#clients.get(clientId); + if (!state?.pending) return; + state.pending = undefined; try { this.#ceremony.deny(pairingId); } catch { @@ -304,7 +329,7 @@ export class RemoteHost { }, request, ); - if (decision.allowed) this.#established.add(clientId); + if (decision.allowed) this.#clientState(clientId).established = true; // `failures` is optional on the wire; omit it on an allowed decision. this.#send({ t: 'decision', @@ -315,24 +340,23 @@ export class RemoteHost { } #onMsg(clientId: string, data: unknown): void { - if (!this.#established.has(clientId)) return; // never before an allowed decision - let session = this.#sessions.get(clientId); + const state = this.#clients.get(clientId); + if (!state?.established) return; // never before an allowed decision + let session = state.session; if (!session) { if (!this.#createSession) return; session = this.#createSession({ hostId: this.#enrollment.hostId, send: (payload) => this.#send({ t: 'msg', clientId, data: payload }), }); - this.#sessions.set(clientId, session); + state.session = session; } session.handle(data); } #onClientGone(clientId: string): void { - this.#established.delete(clientId); - this.#pending.delete(clientId); - this.#sessions.get(clientId)?.dispose(); - this.#sessions.delete(clientId); + this.#clients.get(clientId)?.session?.dispose(); + this.#clients.delete(clientId); this.#dismissApproval(clientId); } } diff --git a/server/src/app.ts b/server/src/app.ts index 6eeb4e68..cc4a15ac 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -67,6 +67,14 @@ export interface AppConfig { readonly setupPassword: string; /** External origin, e.g. `https://dormouse.tailnet.ts.net`; source of `rpId`. */ readonly origin: string; + /** + * Demand the authenticator's user-verification flag (biometric/PIN) on the + * relay's connection-handshake assertions, mirroring the Host's + * `ConnectionPolicy.requireUserVerification` so Server and Host cannot disagree + * on what a valid assertion is. Omitted/false keeps the current presence-only + * behavior; a deployment opts in explicitly (env → config in `index.ts`). + */ + readonly requireUserVerification?: boolean; /** Directory holding `account.json`. */ readonly stateDir: string; /** @@ -148,7 +156,12 @@ export function createApp(config: AppConfig): CreatedApp { const hostStore = new HostStore(config.stateDir, now); const sessions = new SessionStore(now); // Server-side handshake policy layered on the transport-dumb hub (slice 3). - const handshake = new Handshake(accounts, { origin: config.origin, rpId, now }); + const handshake = new Handshake(accounts, { + origin: config.origin, + rpId, + requireUserVerification: config.requireUserVerification, + now, + }); const hub = new RelayHub(handshake); // Separate issuers per flow: a setup challenge cannot be redeemed at sign-in. const setupChallenges = new HostChallengeIssuer({ now }); @@ -276,6 +289,9 @@ export function createApp(config: AppConfig): CreatedApp { challenge, origin: config.origin, rpId, + // Same server-wide UV policy the connect handshake enforces, so sign-in + // is not a softer path than a remote connect when UV is required. + requireUserVerification: config.requireUserVerification, }); if (!result.ok) { return c.json({ error: `assertion rejected: ${result.reason}` }, 401); diff --git a/server/src/handshake.ts b/server/src/handshake.ts index 256af780..8ca76c1c 100644 --- a/server/src/handshake.ts +++ b/server/src/handshake.ts @@ -59,6 +59,14 @@ export interface HandshakeConfig { readonly origin: string; /** Relying-party id the assertion must be scoped to. */ readonly rpId: string; + /** + * Demand the authenticator's user-verification flag (biometric/PIN), not just + * user presence. This must mirror the Host's `ConnectionPolicy.requireUserVerification` + * (connection.ts `authorizeConnection`): both verifiers evaluate the same + * assertion, so if only one demands UV they silently disagree on what a valid + * assertion is. Undefined/false keeps the current presence-only behavior. + */ + readonly requireUserVerification?: boolean; /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ readonly now?: () => number; } @@ -74,6 +82,7 @@ export class Handshake implements HandshakeGate { readonly #accounts: AccountStore; readonly #origin: string; readonly #rpId: string; + readonly #requireUserVerification: boolean; readonly #now: () => number; /** clientId → the last Host challenge relayed to it; consumed single-use. */ readonly #relayed = new Map(); @@ -82,6 +91,7 @@ export class Handshake implements HandshakeGate { this.#accounts = accounts; this.#origin = config.origin; this.#rpId = config.rpId; + this.#requireUserVerification = config.requireUserVerification ?? false; this.#now = config.now ?? (() => Date.now()); } @@ -152,6 +162,9 @@ export class Handshake implements HandshakeGate { challenge: request.challenge, origin: this.#origin, rpId: this.#rpId, + // Mirror the Host's UV demand so Server and Host cannot drift on what a + // valid assertion is (connection.ts `authorizeConnection`). + requireUserVerification: this.#requireUserVerification, }); if (!result.ok) failures.push('passkey-assertion-invalid'); } else { diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index 83f8fd79..abab914f 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -47,8 +47,8 @@ import { FakeHost } from './harness/fake-host.mjs'; // --- Fixtures -------------------------------------------------------------- /** Boot a server + one enrolled `FakeHost`, ready to accept clients. */ -async function boot({ autoApprove = true } = {}) { - const created = await freshApp(); +async function boot({ autoApprove = true, requireUserVerification } = {}) { + const created = await freshApp({ requireUserVerification }); const server = await startServer(created); const { body: host } = await enrollHost(created.app, { label: 'Laptop' }); const fakeHost = await startFakeHost(server, host, { autoApprove }); @@ -380,6 +380,58 @@ test('server rejects a replayed connect2 (same challenge twice) before forwardin } }); +// --- requireUserVerification: Server mirrors the Host's UV demand ----------- + +test('requireUserVerification: server rejects a UV-absent connect2 before forwarding (mirrors the Host)', async () => { + // The Host enforces UV via ConnectionPolicy.requireUserVerification; the server + // must demand the same, or the two verifiers silently disagree the moment UV + // is turned on. With UV required, a presence-only assertion is rejected here — + // before the Host is ever consulted. + const { app, server, host, fakeHost, close } = await boot({ requireUserVerification: true }); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const challengeFrame = await connect(p, host.hostId); + // A well-formed assertion whose ONLY defect is the missing user-verification + // flag (user present, but no biometric/PIN). + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertion: { userVerified: false }, + }); + + assert.equal(decision.allowed, false); + assert.ok( + decision.failures.includes('passkey-assertion-invalid'), + JSON.stringify(decision.failures), + ); + assert.equal(decisions.length, 0, 'rejected before forwarding — Host challenge stays unburned'); + } finally { + await close(); + } +}); + +test('default (UV not configured): a UV-absent connect2 is still allowed (unchanged behavior)', async () => { + // The knob defaults to off: exactly the same UV-absent assertion the strict + // server rejects above is accepted end to end, proving the fix adds no + // behavior change for existing deployments. + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertion: { userVerified: false }, + }); + + assert.deepEqual(decision, { t: 'decision', allowed: true }); + assert.equal(fakeHost.established.size, 1, 'the Host established the session'); + } finally { + await close(); + } +}); + // --- Stale-session invalidation on Host restart/replacement ----------------- test('a Host restart invalidates an established session (host-gone, then msg blocked)', async () => { diff --git a/server/test/helpers.mjs b/server/test/helpers.mjs index cfb066e5..d831d3ed 100644 --- a/server/test/helpers.mjs +++ b/server/test/helpers.mjs @@ -30,9 +30,20 @@ export function makeClock(startMs = 1_700_000_000_000) { }; } -export async function freshApp({ password = PASSWORD, origin = ORIGIN, now } = {}) { +export async function freshApp({ + password = PASSWORD, + origin = ORIGIN, + now, + requireUserVerification, +} = {}) { const stateDir = await mkdtemp(join(tmpdir(), 'dormouse-server-')); - const created = createApp({ setupPassword: password, origin, stateDir, now }); + const created = createApp({ + setupPassword: password, + origin, + stateDir, + now, + requireUserVerification, + }); return { ...created, stateDir, origin, rpId: new URL(origin).hostname }; } From d7101db9a5519138bda3294409be788243b6f979 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:13:00 -0700 Subject: [PATCH 38/52] Keep remote directory PTYs alive --- docs/specs/remote-api.md | 4 ++++ lib/src/remote/client/remote-adapter.test.ts | 4 ++-- lib/src/remote/client/remote-adapter.ts | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 3b22681b..0bb945e8 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -195,6 +195,10 @@ future optimization there is no current reason to pay for. Thumbnails are future work; in v1 the picker renders from titles, activity, and the `ringing`/`hasTODO` badges. +Terminal directory `exitCode` is the last finished command's semantic status, +not PTY lifetime. A listed terminal is still a live Host registry surface until +its attachment emits `terminal.closed`. + --- # Attaching to a surface diff --git a/lib/src/remote/client/remote-adapter.test.ts b/lib/src/remote/client/remote-adapter.test.ts index 111602cf..3c27c127 100644 --- a/lib/src/remote/client/remote-adapter.test.ts +++ b/lib/src/remote/client/remote-adapter.test.ts @@ -89,7 +89,7 @@ function entry(surfaceId: string, over: Partial = {}): Directory } describe('RemotePtyAdapter directory', () => { - it('turns a snapshot into onPtyList (alive from exitCode) and getDirectoryEntries', async () => { + it('turns a snapshot into onPtyList without treating command exitCode as PTY death', async () => { const client = new FakeClient(); const adapter = new RemotePtyAdapter(client); const lists: PtyInfo[][] = []; @@ -101,7 +101,7 @@ describe('RemotePtyAdapter directory', () => { expect(lists).toEqual([ [ { id: 's1', alive: true }, - { id: 's2', alive: false, exitCode: 0 }, + { id: 's2', alive: true }, ], ]); expect(adapter.getDirectoryEntries().map((e) => e.surfaceId)).toEqual(['s1', 's2']); diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index d9eb3337..144f85dd 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -203,8 +203,9 @@ export class RemotePtyAdapter implements PlatformAdapter { #emitPtyList(): void { const ptys: PtyInfo[] = this.#entries.map((entry) => ({ id: entry.surfaceId, - alive: entry.exitCode === undefined, - ...(entry.exitCode === undefined ? {} : { exitCode: entry.exitCode }), + // Directory entries are live Host registry surfaces. `entry.exitCode` is + // the last command's semantic status, not the PTY process lifetime. + alive: true, })); for (const handler of this.#listHandlers) handler({ ptys }); } From 7b4f19d7c6cf191de318d6ae5a920564a00ea4ab Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:14:46 -0700 Subject: [PATCH 39/52] Drop stale relay handshakes --- docs/specs/server.md | 3 + server/src/relay.ts | 24 ++++--- server/test/relay-displaced.test.mjs | 100 +++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index b1611194..53cd2937 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -112,6 +112,9 @@ re-establish an old session. When a Client socket binds to a different Host, the relay sends `client-gone` to the previous live Host before replacing the binding, so Host-side pairing UI, remote-api sessions, and watchers are disposed immediately. +Client-originated `pair` and `connect2` frames are also rechecked after their +async validation work: if the Client disconnected, rebound, or the Host socket +was replaced while validation was pending, the stale result is dropped. ## Pairing (phone ↔ laptop, first time) diff --git a/server/src/relay.ts b/server/src/relay.ts index 619e2e07..2360c480 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -221,7 +221,8 @@ export class RelayHub { this.#toClient(client, { t: 'error', error: 'missing hostId' }); return; } - if (!this.#hosts.has(frame.hostId)) { + const host = this.#hosts.get(frame.hostId); + if (!host) { this.#toClient(client, { t: 'error', error: `host ${frame.hostId} is offline` }); return; } @@ -239,8 +240,7 @@ export class RelayHub { client.hostId = frame.hostId; if (frame.t === 'connect') { - const host = this.#hosts.get(frame.hostId); - if (host) this.#toHost(host, { t: 'connect', clientId: client.clientId }); + this.#toHost(host, { t: 'connect', clientId: client.clientId }); return; } if (frame.t === 'pair') { @@ -248,26 +248,24 @@ export class RelayHub { // made: the owner account, a registered credential, a matching key // hash. A forged request is answered locally and never reaches the Host. const check = await this.#gate.checkPair(frame.request); + if (!this.#isCurrentClientHost(client, frame.hostId, host)) return; if (!check.ok) { this.#toClient(client, { t: 'pair-result', approved: false, error: check.error }); return; } - const host = this.#hosts.get(frame.hostId); - if (host) this.#toHost(host, { t: 'pair', clientId: client.clientId, request: frame.request }); + this.#toHost(host, { t: 'pair', clientId: client.clientId, request: frame.request }); return; } // connect2: the server verifies the assertion (against the STORED key) // and challenge freshness before forwarding. On failure the client gets // a denial and the Host's challenge stays unburned. const check = await this.#gate.checkConnect2(client.clientId, frame.request); + if (!this.#isCurrentClientHost(client, frame.hostId, host)) return; if (!check.ok) { this.#toClient(client, { t: 'decision', allowed: false, failures: check.failures }); return; } - const host = this.#hosts.get(frame.hostId); - if (host) { - this.#toHost(host, { t: 'connect2', clientId: client.clientId, request: frame.request }); - } + this.#toHost(host, { t: 'connect2', clientId: client.clientId, request: frame.request }); return; } case 'msg': @@ -302,6 +300,14 @@ export class RelayHub { #toHost(host: HostConn, frame: ServerToHostFrame): void { safeSend(host.socket, frame); } + + #isCurrentClientHost(client: ClientConn, hostId: string, host: HostConn): boolean { + return ( + this.#clients.get(client.clientId) === client && + client.hostId === hostId && + this.#hosts.get(hostId) === host + ); + } } // --------------------------------------------------------------------------- diff --git a/server/test/relay-displaced.test.mjs b/server/test/relay-displaced.test.mjs index 64d85a79..ca4c1934 100644 --- a/server/test/relay-displaced.test.mjs +++ b/server/test/relay-displaced.test.mjs @@ -31,6 +31,14 @@ function fakeSocket() { }; } +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + /** Register a client, bind it to `hostId`, and establish via a host decision. */ async function establishedClient(hub, hostConn, hostId) { const socket = fakeSocket(); @@ -153,3 +161,95 @@ test('rebinding a client tells the previous host client-gone', async () => { assert.equal(client.hostId, 'h2'); assert.equal(client.established, false); }); + +test('pair is not forwarded after async validation if the host was replaced', async () => { + const pairCheck = deferred(); + let started; + const pairStarted = new Promise((resolve) => { + started = resolve; + }); + const gate = { + ...openGate, + checkPair: async () => { + started(); + return pairCheck.promise; + }, + }; + const hub = new RelayHub(gate); + const oldHost = hub.registerHost('h1', fakeSocket()); + const clientSocket = fakeSocket(); + const client = hub.registerClient(clientSocket); + + const routing = hub.onClientFrame(client, JSON.stringify({ t: 'pair', hostId: 'h1', request: {} })); + await pairStarted; + + const newHost = hub.registerHost('h1', fakeSocket()); + pairCheck.resolve({ ok: true }); + await routing; + + assert.equal(oldHost.socket.closed, true); + assert.ok(clientSocket.sent.some((f) => f.t === 'host-gone')); + assert.equal(oldHost.socket.sent.some((f) => f.t === 'pair'), false); + assert.equal(newHost.socket.sent.some((f) => f.t === 'pair'), false); +}); + +test('pair is not forwarded after async validation if the client disconnected', async () => { + const pairCheck = deferred(); + let started; + const pairStarted = new Promise((resolve) => { + started = resolve; + }); + const gate = { + ...openGate, + checkPair: async () => { + started(); + return pairCheck.promise; + }, + }; + const hub = new RelayHub(gate); + const host = hub.registerHost('h1', fakeSocket()); + const client = hub.registerClient(fakeSocket()); + + const routing = hub.onClientFrame(client, JSON.stringify({ t: 'pair', hostId: 'h1', request: {} })); + await pairStarted; + + hub.unregisterClient(client); + pairCheck.resolve({ ok: true }); + await routing; + + assert.deepEqual(host.socket.sent, [{ t: 'client-gone', clientId: client.clientId }]); +}); + +test('connect2 is not forwarded after async validation if the host was replaced', async () => { + const connectCheck = deferred(); + let started; + const connectStarted = new Promise((resolve) => { + started = resolve; + }); + const gate = { + ...openGate, + checkConnect2: async () => { + started(); + return connectCheck.promise; + }, + }; + const hub = new RelayHub(gate); + const oldHost = hub.registerHost('h1', fakeSocket()); + const clientSocket = fakeSocket(); + const client = hub.registerClient(clientSocket); + + const routing = hub.onClientFrame( + client, + JSON.stringify({ t: 'connect2', hostId: 'h1', request: {} }), + ); + await connectStarted; + + const newHost = hub.registerHost('h1', fakeSocket()); + connectCheck.resolve({ ok: true }); + await routing; + + assert.equal(oldHost.socket.closed, true); + assert.ok(clientSocket.sent.some((f) => f.t === 'host-gone')); + assert.equal(oldHost.socket.sent.some((f) => f.t === 'connect2'), false); + assert.equal(newHost.socket.sent.some((f) => f.t === 'connect2'), false); +}); From b0059e45a722232706913aaac10fe7092aae98e8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:17:33 -0700 Subject: [PATCH 40/52] Recover stale Pocket pairings --- docs/specs/pocket-app.md | 6 ++++ lib/src/remote/client/pocket-client.test.ts | 38 +++++++++++++++++++++ lib/src/remote/client/pocket-client.ts | 30 ++++++++++++++-- lib/src/remote/pocket-app/App.tsx | 8 +++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index f75797b5..19bb5b3a 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -38,6 +38,12 @@ v1 protocol allows one attachment per session, so pane switching is detach → attach, and the attach repaint (resize) redraws the screen. Badges for non-attached panes come from `directory.watch` without attaching. +Pocket's local "paired" host marker is optimistic cache, not authority. When a +connect denial reports an ACL miss (`passkey-not-paired`, +`device-not-paired`, or `pairing-mismatch`), Pocket clears that marker and +shows Pair again so expected Host ACL resets, revocations, or browser +device-key loss recover through the normal pairing ceremony. + # Module layout ``` diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index f62ab2b7..6b929556 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -19,6 +19,7 @@ import { } from 'server-lib-common'; import { + hasRecoverablePairingFailure, PocketClient, type PocketSocket, type PocketStorage, @@ -95,6 +96,7 @@ function memoryStorage(): PocketStorage { knownCredentialIds: () => [...passkeys.keys()], isPaired: (hostId) => paired.has(hostId), markPaired: (hostId) => void paired.add(hostId), + unmarkPaired: (hostId) => void paired.delete(hostId), }; } @@ -225,6 +227,13 @@ async function signedIn(overrides: Partial = {}): Promise { + const pairing = client.pair('h1', 'iPhone'); + await nextSent(socket, (f) => f.t === 'pair'); + socket.server({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); + await pairing; +} + // --- Tests ----------------------------------------------------------------- describe('setup + signin', () => { @@ -287,6 +296,14 @@ describe('pair', () => { }); describe('connect', () => { + it('classifies ACL miss failures as recoverable stale pairing', () => { + expect(hasRecoverablePairingFailure(['device-not-paired'])).toBe(true); + expect(hasRecoverablePairingFailure(['pairing-mismatch'])).toBe(true); + expect(hasRecoverablePairingFailure(['passkey-not-paired'])).toBe(true); + expect(hasRecoverablePairingFailure(['challenge-invalid'])).toBe(false); + expect(hasRecoverablePairingFailure(undefined)).toBe(false); + }); + it('challenge → one assertion + device signature → connect2 → allowed', async () => { const { client, socket, device } = await signedIn(); const connecting = client.connect('h1'); @@ -347,6 +364,9 @@ describe('connect', () => { it('resolves not-allowed with failures on a denied decision', async () => { const { client, socket } = await signedIn(); + await pairApproved(client, socket); + expect(client.isPaired('h1')).toBe(true); + const connecting = client.connect('h1'); await nextSent(socket, (f) => f.t === 'connect'); socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(3), expiresAt: 9e15 }); @@ -355,8 +375,26 @@ describe('connect', () => { const decision = await connecting; expect(decision.allowed).toBe(false); expect(decision.failures).toEqual(['device-not-paired']); + expect(decision.pairingStale).toBe(true); + expect(client.isPaired('h1')).toBe(false); expect(client.connectedHostId).toBeNull(); }); + + it('keeps the paired marker for non-pairing denials', async () => { + const { client, socket } = await signedIn(); + await pairApproved(client, socket); + + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(4), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: false, failures: ['challenge-invalid'] }); + + const decision = await connecting; + expect(decision.allowed).toBe(false); + expect(decision.pairingStale).toBeUndefined(); + expect(client.isPaired('h1')).toBe(true); + }); }); /** Drive the full connect dance until the session is established. */ diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 5a7b5ca0..bbfc6b48 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -65,6 +65,7 @@ export interface PocketStorage { knownCredentialIds(): string[]; isPaired(hostId: string): boolean; markPaired(hostId: string): void; + unmarkPaired(hostId: string): void; } export interface PocketClientDeps { @@ -91,6 +92,8 @@ export interface TerminalHandlers { export interface ConnectDecision { readonly allowed: boolean; readonly failures?: readonly ConnectionFailure[]; + /** True when a denial means the local paired marker is stale and the user can re-pair. */ + readonly pairingStale?: boolean; } export interface PairResult { @@ -299,8 +302,18 @@ export class PocketClient { ServerToClientFrame, { t: 'decision' } >; - if (decisionFrame.allowed) this.#connectedHostId = hostId; - return { allowed: decisionFrame.allowed, failures: decisionFrame.failures }; + const pairingStale = + !decisionFrame.allowed && hasRecoverablePairingFailure(decisionFrame.failures); + if (decisionFrame.allowed) { + this.#connectedHostId = hostId; + } else if (pairingStale) { + this.#storage.unmarkPaired(hostId); + } + return { + allowed: decisionFrame.allowed, + failures: decisionFrame.failures, + ...(pairingStale ? { pairingStale: true } : {}), + }; } // --- Remote-api v1 ------------------------------------------------------- @@ -541,6 +554,18 @@ export const PASSKEY_UNAVAILABLE_MESSAGE = "This device does not hold the passkey's public key, so it cannot pair or connect. " + 'Pair from the device that first created the passkey (POC limitation).'; +const RECOVERABLE_PAIRING_FAILURES = new Set([ + 'passkey-not-paired', + 'device-not-paired', + 'pairing-mismatch', +]); + +export function hasRecoverablePairingFailure( + failures: readonly ConnectionFailure[] | undefined, +): boolean { + return failures?.some((failure) => RECOVERABLE_PAIRING_FAILURES.has(failure)) ?? false; +} + function uuid(): string { return globalThis.crypto.randomUUID(); } @@ -565,5 +590,6 @@ export function localStoragePocketStorage(): PocketStorage { }, isPaired: (hostId) => globalThis.localStorage.getItem(PAIRED_PREFIX + hostId) === '1', markPaired: (hostId) => globalThis.localStorage.setItem(PAIRED_PREFIX + hostId, '1'), + unmarkPaired: (hostId) => globalThis.localStorage.removeItem(PAIRED_PREFIX + hostId), }; } diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 8c6cd63c..f71db37b 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -106,6 +106,14 @@ export default function App(): React.ReactElement { await ensureSocket(); const decision: ConnectDecision = await client.connect(host.hostId); if (!decision.allowed) { + if (decision.pairingStale) { + setPairedIds((prev) => { + if (!prev.has(host.hostId)) return prev; + const next = new Set(prev); + next.delete(host.hostId); + return next; + }); + } throw new Error(`Connection denied${decision.failures ? `: ${decision.failures.join(', ')}` : ''}`); } await client.hello(); From b2cef0276954d3f49d69191406b7ff4bf3440422 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:46:16 -0700 Subject: [PATCH 41/52] Allow remote host relay connections in CSP --- standalone/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 4ad438b5..c1773c7d 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost ws://127.0.0.1:* ws://localhost:*; frame-src http://127.0.0.1:* http://localhost:*" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* https: ws://127.0.0.1:* ws://localhost:* wss:; frame-src http://127.0.0.1:* http://localhost:*" } }, "bundle": { From 46da43a635423ec8026697bd61b3979940f113d7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:47:30 -0700 Subject: [PATCH 42/52] Normalize server WebAuthn origin --- docs/specs/server.md | 3 +++ server/src/app.ts | 12 +++++++----- server/test/setup.test.mjs | 23 +++++++++++++++++++++++ server/test/signin.test.mjs | 15 +++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 53cd2937..713b2c95 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -38,6 +38,9 @@ WebAuthn requires a secure context: `localhost` works for development; for a real phone, put the server behind TLS (`tailscale serve` is the intended selfhost path, any reverse proxy works). The server itself always speaks plain HTTP. +`DORMOUSE_ORIGIN` is parsed once and normalized with `URL.origin`; WebAuthn +clientData checks, passkey assertion verification, and the Host enrollment +policy all use that normalized origin. # State files diff --git a/server/src/app.ts b/server/src/app.ts index cc4a15ac..e714d57b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -151,13 +151,15 @@ export interface CreatedApp { export function createApp(config: AppConfig): CreatedApp { const now = config.now ?? (() => Date.now()); - const rpId = new URL(config.origin).hostname; + const originUrl = new URL(config.origin); + const origin = originUrl.origin; + const rpId = originUrl.hostname; const accounts = new AccountStore(config.stateDir, now); const hostStore = new HostStore(config.stateDir, now); const sessions = new SessionStore(now); // Server-side handshake policy layered on the transport-dumb hub (slice 3). const handshake = new Handshake(accounts, { - origin: config.origin, + origin, rpId, requireUserVerification: config.requireUserVerification, now, @@ -226,7 +228,7 @@ export function createApp(config: AppConfig): CreatedApp { if (typeof clientData.challenge !== 'string' || !setupChallenges.consume(clientData.challenge)) { return c.json({ error: 'unrecognized or expired challenge' }, 400); } - if (clientData.origin !== config.origin) { + if (clientData.origin !== origin) { return c.json({ error: 'origin mismatch' }, 400); } @@ -287,7 +289,7 @@ export function createApp(config: AppConfig): CreatedApp { const result = await verifyPasskeyAssertion(assertion as PasskeyAssertion, stored.publicKey, { challenge, - origin: config.origin, + origin, rpId, // Same server-wide UV policy the connect handshake enforces, so sign-in // is not a softer path than a remote connect when UV is required. @@ -317,7 +319,7 @@ export function createApp(config: AppConfig): CreatedApp { const res: HostEnrollResponse = { hostId: host.hostId, hostToken: host.hostToken, - origin: config.origin, + origin, rpId, }; return c.json(res); diff --git a/server/test/setup.test.mjs b/server/test/setup.test.mjs index 7dab0484..60dbefb6 100644 --- a/server/test/setup.test.mjs +++ b/server/test/setup.test.mjs @@ -14,6 +14,7 @@ import { PASSWORD, RP_ID, freshApp, + enrollHost, newAuthenticator, post, readAccount, @@ -170,3 +171,25 @@ test('origin/rpId derive from config', async () => { assert.equal(typeof body.challenge, 'string'); assert.equal(new URL(ORIGIN).hostname, RP_ID); }); + +test('configured origin is normalized for setup and Host policy', async () => { + const { app } = await freshApp({ origin: 'https://Example.COM/' }); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge, rpId } = await begin.json(); + assert.equal(rpId, 'example.com'); + + const finish = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge, origin: 'https://example.com' }), + label: 'x', + }); + assert.equal(finish.status, 200); + + const { res, body } = await enrollHost(app); + assert.equal(res.status, 200); + assert.equal(body.origin, 'https://example.com'); + assert.equal(body.rpId, 'example.com'); +}); diff --git a/server/test/signin.test.mjs b/server/test/signin.test.mjs index 41535f0a..c9546d09 100644 --- a/server/test/signin.test.mjs +++ b/server/test/signin.test.mjs @@ -77,6 +77,21 @@ test('sign-in rejects an assertion for a foreign origin', async () => { assert.match((await res.json()).error, /origin/); }); +test('sign-in accepts the browser-normalized origin for a normalized config', async () => { + const { app } = await freshApp({ origin: 'https://Example.COM/' }); + const authenticator = await SimAuthenticator.create({ rpId: 'example.com' }); + assert.equal( + (await register(app, authenticator, { origin: 'https://example.com' })).status, + 200, + ); + + const { res } = await signin(app, authenticator, { + origin: 'https://example.com', + rpId: 'example.com', + }); + assert.equal(res.status, 200); +}); + test('an expired session token no longer validates', async () => { const clock = makeClock(); const { app, sessions } = await freshApp({ now: clock.now }); From 49d4f91dd4a7f4aaadb15a828e4b29b364fd5d2f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:48:56 -0700 Subject: [PATCH 43/52] Normalize WebAuthn clientData challenges --- docs/specs/server.md | 4 ++++ server/src/app.ts | 18 ++++++++++++++++-- server/test/helpers.mjs | 6 ++++++ server/test/setup.test.mjs | 17 +++++++++++++++++ server/test/signin.test.mjs | 29 ++++++++++++++++++++++++++++- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 713b2c95..4e664dfd 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -75,6 +75,10 @@ Two facts keep the server dependency-free: Server-issued challenges (registration, sign-in) reuse `HostChallengeIssuer` — it is a generic single-use/TTL challenge store despite the name. +Before a challenge is consumed, the server canonicalizes the browser's +`clientDataJSON.challenge` by decoded base64url bytes, so padded browser +serializations redeem the issued challenge without weakening single-use replay +protection. This also makes the server fully testable without a browser: the `SimAuthenticator` harness in `server-lib-common` produces real assertions, diff --git a/server/src/app.ts b/server/src/app.ts index e714d57b..89b418c9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -225,7 +225,8 @@ export function createApp(config: AppConfig): CreatedApp { if (clientData.type !== 'webauthn.create') { return c.json({ error: 'clientData type must be webauthn.create' }, 400); } - if (typeof clientData.challenge !== 'string' || !setupChallenges.consume(clientData.challenge)) { + const challenge = normalizeChallenge(clientData.challenge); + if (!challenge || !setupChallenges.consume(challenge)) { return c.json({ error: 'unrecognized or expired challenge' }, 400); } if (clientData.origin !== origin) { @@ -282,7 +283,10 @@ export function createApp(config: AppConfig): CreatedApp { if (!clientData || typeof clientData.challenge !== 'string') { return c.json({ error: 'malformed clientDataJSON' }, 400); } - const challenge = clientData.challenge; + const challenge = normalizeChallenge(clientData.challenge); + if (!challenge) { + return c.json({ error: 'malformed clientDataJSON' }, 400); + } if (!signinChallenges.consume(challenge)) { return c.json({ error: 'unrecognized or expired challenge' }, 400); } @@ -483,6 +487,16 @@ function decodeClientData( } } +/** Canonicalize browser-serialized base64url challenges before single-use lookup. */ +function normalizeChallenge(challenge: unknown): string | null { + if (typeof challenge !== 'string') return null; + try { + return toBase64Url(fromBase64Url(challenge)); + } catch { + return null; + } +} + /** True if `publicKey` (base64url SPKI) imports as an ECDSA P-256 verify key. */ async function importableSpkiP256(publicKey: unknown): Promise { if (typeof publicKey !== 'string') return false; diff --git a/server/test/helpers.mjs b/server/test/helpers.mjs index d831d3ed..f6064fb7 100644 --- a/server/test/helpers.mjs +++ b/server/test/helpers.mjs @@ -68,6 +68,12 @@ export function registrationClientData({ challenge, origin = ORIGIN, type = 'web return toBase64Url(utf8Encode(JSON.stringify({ type, challenge, origin, crossOrigin: false }))); } +/** Serialize an unpadded base64url challenge the way some browsers do in clientDataJSON. */ +export function padBase64Url(text) { + const rem = text.length % 4; + return rem === 0 ? text : `${text}${'='.repeat(4 - rem)}`; +} + /** begin → finish registration for `authenticator`; returns the finish Response. */ export async function register( app, diff --git a/server/test/setup.test.mjs b/server/test/setup.test.mjs index 60dbefb6..0a08f452 100644 --- a/server/test/setup.test.mjs +++ b/server/test/setup.test.mjs @@ -16,6 +16,7 @@ import { freshApp, enrollHost, newAuthenticator, + padBase64Url, post, readAccount, register, @@ -103,6 +104,22 @@ test('setup/finish rejects a replayed challenge', async () => { assert.match((await replay.json()).error, /challenge/); }); +test('setup/finish accepts a padded base64url clientData challenge', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge: padBase64Url(challenge) }), + label: 'x', + }); + assert.equal(res.status, 200); +}); + test('setup/finish rejects a mismatched origin in clientDataJSON', async () => { const { app } = await freshApp(); const authenticator = await newAuthenticator(); diff --git a/server/test/signin.test.mjs b/server/test/signin.test.mjs index c9546d09..2348bb75 100644 --- a/server/test/signin.test.mjs +++ b/server/test/signin.test.mjs @@ -11,7 +11,17 @@ import { Hono } from 'hono'; import { API_ROUTES } from 'server-lib-common'; import { SimAuthenticator } from '../../server-lib-common/test/harness/actors.mjs'; -import { freshApp, makeClock, newAuthenticator, post, register, signin } from './helpers.mjs'; +import { + ORIGIN, + RP_ID, + freshApp, + makeClock, + newAuthenticator, + padBase64Url, + post, + register, + signin, +} from './helpers.mjs'; test('sign-in happy path mints a session the store accepts', async () => { const { app, sessions } = await freshApp(); @@ -55,6 +65,23 @@ test('sign-in rejects a replayed challenge/assertion', async () => { assert.match((await replay.json()).error, /challenge/); }); +test('sign-in accepts a padded base64url clientData challenge', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const begin = await post(app, API_ROUTES.signinBegin, {}); + const { challenge } = await begin.json(); + const assertion = await authenticator.assert({ + challenge, + origin: ORIGIN, + rpId: RP_ID, + tamper: { challenge: padBase64Url(challenge) }, + }); + const res = await post(app, API_ROUTES.signinFinish, { assertion }); + assert.equal(res.status, 200); +}); + test('sign-in rejects a tampered signature', async () => { const { app } = await freshApp(); const authenticator = await newAuthenticator(); From 73cc58b5dad57f92fb8a4d0183732dc6740b66f0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 3 Jul 2026 22:51:13 -0700 Subject: [PATCH 44/52] Hide Pocket remote kill control --- docs/specs/pocket-app.md | 4 ++ lib/src/components/MobileWall.test.tsx | 82 ++++++++++++++++++++++++ lib/src/components/MobileWall.tsx | 23 ++++--- lib/src/remote/pocket-app/PocketWall.tsx | 1 + 4 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 lib/src/components/MobileWall.test.tsx diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 19bb5b3a..2b2483fe 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -31,6 +31,10 @@ One mobile terminal experience in `lib`, three consumers: the website playground (fake adapter), the real Pocket (remote adapter), and whatever comes later. Everything not in the PTY core no-ops or is absent — the interface is designed for capability degradation. +Pocket hides `MobileWall`'s local Kill affordance: remote panes are +Host-owned, and v1 grants no phone-side kill/layout authority. Closing a local +xterm view without a Host-side close would leave the Host attachment live and +the phone view inconsistent. Adapter-specific extras (the same pattern as `FakePtyAdapter`'s scenario controls): the concrete `RemotePtyAdapter` exposes `setActivePane(id)` — the diff --git a/lib/src/components/MobileWall.test.tsx b/lib/src/components/MobileWall.test.tsx new file mode 100644 index 00000000..a5a92ce8 --- /dev/null +++ b/lib/src/components/MobileWall.test.tsx @@ -0,0 +1,82 @@ +/** + * @vitest-environment jsdom + */ +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MobileWall } from './MobileWall'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const registry = vi.hoisted(() => ({ + activitySnapshot: new Map(), + clearSessionTodo: vi.fn(), + dismissOrToggleAlert: vi.fn(), + disposeSession: vi.fn(), + getActivitySnapshot: vi.fn(), + getOrCreateTerminal: vi.fn(), + terminalPaneStateSnapshot: new Map(), + getTerminalPaneStateSnapshot: vi.fn(), + setTerminalUserTitle: vi.fn(), + subscribeToActivity: vi.fn(() => () => {}), + subscribeToTerminalPaneState: vi.fn(() => () => {}), +})); + +vi.mock('../lib/terminal-registry', () => ({ + ...registry, + DEFAULT_ACTIVITY_STATE: { status: 'WATCHING_DISABLED', todo: false }, +})); + +vi.mock('./TerminalPane', () => ({ + TerminalPane: ({ id }: { id: string }) =>
, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + registry.getActivitySnapshot.mockReturnValue(registry.activitySnapshot); + registry.getTerminalPaneStateSnapshot.mockReturnValue(registry.terminalPaneStateSnapshot); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => null), + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function renderWall(showKillButton?: boolean) { + act(() => { + root.render( + + + , + ); + }); +} + +describe('MobileWall', () => { + it('shows the Kill control by default', () => { + renderWall(); + + expect(container.querySelector('button[aria-label="Kill"]')).not.toBeNull(); + }); + + it('can hide the local Kill control for Host-owned remote panes', () => { + renderWall(false); + + expect(container.querySelector('button[aria-label="Kill"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Minimize"]')).not.toBeNull(); + }); +}); diff --git a/lib/src/components/MobileWall.tsx b/lib/src/components/MobileWall.tsx index 459aceed..e47ade11 100644 --- a/lib/src/components/MobileWall.tsx +++ b/lib/src/components/MobileWall.tsx @@ -43,6 +43,7 @@ export interface MobileWallProps { onActiveSessionChange?: (id: string) => void; onSessionMinimize?: (id: string) => void; onSessionKill?: (id: string) => void; + showKillButton?: boolean; className?: string; } @@ -98,6 +99,7 @@ export function MobileWall({ onActiveSessionChange, onSessionMinimize, onSessionKill, + showKillButton = true, className, }: MobileWallProps) { const [internalSessions, setInternalSessions] = useState(() => controlledSessions ?? [DEFAULT_MOBILE_SESSION]); @@ -161,6 +163,7 @@ export function MobileWall({ session={activeItem} onMinimize={() => onSessionMinimize?.(activeItem.id)} onKill={() => killSession(activeItem.id)} + showKillButton={showKillButton} />
@@ -173,10 +176,12 @@ function MobileWallHeader({ session, onMinimize, onKill, + showKillButton, }: { session: MobileTerminalSessionItem; onMinimize: () => void; onKill: () => void; + showKillButton: boolean; }) { const status = session.status ?? 'WATCHING_DISABLED'; const todoPill = useTodoPillContent(session.todo === true); @@ -235,14 +240,16 @@ function MobileWallHeader({ > - - - + {showKillButton ? ( + + + + ) : null}
); diff --git a/lib/src/remote/pocket-app/PocketWall.tsx b/lib/src/remote/pocket-app/PocketWall.tsx index 847ec89a..bac609a2 100644 --- a/lib/src/remote/pocket-app/PocketWall.tsx +++ b/lib/src/remote/pocket-app/PocketWall.tsx @@ -131,6 +131,7 @@ export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.Re activeSessionId={activePaneId ?? undefined} onActiveSessionChange={setActivePaneId} onSessionMinimize={() => setKeyboardMode('sessions')} + showKillButton={false} /> } interactive From b3034c25d2f31f061ed9be1d13435e26156f6a6c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 08:59:43 -0700 Subject: [PATCH 45/52] Derive relayed challenge expiry on server --- docs/specs/server.md | 5 ++++ server/src/handshake.ts | 14 +++++++++-- server/test/handshake.test.mjs | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 4e664dfd..aea19159 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -122,6 +122,11 @@ remote-api sessions, and watchers are disposed immediately. Client-originated `pair` and `connect2` frames are also rechecked after their async validation work: if the Client disconnected, rebound, or the Host socket was replaced while validation was pending, the stale result is dropped. +For `connect2`, the server remembers the last Host challenge it relayed to a +Client with a relay-local expiry derived from the server's observation time +(`DEFAULT_CHALLENGE_TTL_MS`). The Host's `expiresAt` is still forwarded to the +Client, but the server never compares its own clock to that Host wall-clock +timestamp. ## Pairing (phone ↔ laptop, first time) diff --git a/server/src/handshake.ts b/server/src/handshake.ts index 8ca76c1c..04db591f 100644 --- a/server/src/handshake.ts +++ b/server/src/handshake.ts @@ -22,6 +22,7 @@ */ import { + DEFAULT_CHALLENGE_TTL_MS, SELFHOST_ACCOUNT_ID, hashPasskeyPublicKey, verifyPasskeyAssertion, @@ -69,12 +70,15 @@ export interface HandshakeConfig { readonly requireUserVerification?: boolean; /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ readonly now?: () => number; + /** Relay-side TTL for Host challenges observed by the server. */ + readonly relayedChallengeTtlMs?: number; } /** The last Host challenge the server relayed to a client. */ interface RelayedChallenge { readonly hostId: string; readonly challenge: string; + /** Server-local expiry, derived when the relay observed the challenge. */ readonly expiresAt: number; } @@ -84,6 +88,7 @@ export class Handshake implements HandshakeGate { readonly #rpId: string; readonly #requireUserVerification: boolean; readonly #now: () => number; + readonly #relayedChallengeTtlMs: number; /** clientId → the last Host challenge relayed to it; consumed single-use. */ readonly #relayed = new Map(); @@ -93,6 +98,7 @@ export class Handshake implements HandshakeGate { this.#rpId = config.rpId; this.#requireUserVerification = config.requireUserVerification ?? false; this.#now = config.now ?? (() => Date.now()); + this.#relayedChallengeTtlMs = config.relayedChallengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS; } async checkPair(request: unknown): Promise { @@ -116,8 +122,12 @@ export class Handshake implements HandshakeGate { return { ok: true }; } - observeChallenge(clientId: string, hostId: string, challenge: string, expiresAt: number): void { - this.#relayed.set(clientId, { hostId, challenge, expiresAt }); + observeChallenge(clientId: string, hostId: string, challenge: string, _hostExpiresAt: number): void { + this.#relayed.set(clientId, { + hostId, + challenge, + expiresAt: this.#now() + this.#relayedChallengeTtlMs, + }); } forgetClient(clientId: string): void { diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index abab914f..dc2dbba9 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -17,6 +17,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + DEFAULT_CHALLENGE_TTL_MS, REMOTE_EVENTS, REMOTE_METHODS, SELFHOST_ACCOUNT_ID, @@ -36,6 +37,7 @@ import { RP_ID, enrollHost, freshApp, + makeClock, newAuthenticator, ownerSession, sleep, @@ -43,6 +45,7 @@ import { wsConnect, } from './helpers.mjs'; import { FakeHost } from './harness/fake-host.mjs'; +import { Handshake } from '../dist/handshake.js'; // --- Fixtures -------------------------------------------------------------- @@ -380,6 +383,48 @@ test('server rejects a replayed connect2 (same challenge twice) before forwardin } }); +test('server derives relayed challenge expiry from its own observation clock', async () => { + const clock = makeClock(); + const authenticator = await newAuthenticator(); + const gate = new Handshake( + { + findPasskey: async (credentialId) => + credentialId === authenticator.credentialId + ? { + credentialId, + publicKey: authenticator.publicKey, + label: 'Test Passkey', + createdAt: clock.now(), + } + : undefined, + }, + { origin: ORIGIN, rpId: RP_ID, now: clock.now }, + ); + const challenge = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + + gate.observeChallenge( + 'client-1', + 'host-1', + challenge, + clock.now() - DEFAULT_CHALLENGE_TTL_MS, + ); + clock.advance(DEFAULT_CHALLENGE_TTL_MS - 1); + + const assertion = await authenticator.assert({ challenge, origin: ORIGIN, rpId: RP_ID }); + const result = await gate.checkConnect2('client-1', { + accountId: SELFHOST_ACCOUNT_ID, + devicePublicKey: 'device-public-key', + challenge, + deviceSignature: 'device-signature', + passkey: { + publicKey: authenticator.publicKey, + assertion, + }, + }); + + assert.deepEqual(result, { ok: true }); +}); + // --- requireUserVerification: Server mirrors the Host's UV demand ----------- test('requireUserVerification: server rejects a UV-absent connect2 before forwarding (mirrors the Host)', async () => { From 04f5dcbf0b22229c2a097c2038ab06860f75f766 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 09:02:31 -0700 Subject: [PATCH 46/52] Pin remote terminal input to attachment --- docs/specs/remote-api.md | 4 ++ lib/src/remote/host/remote-api.test.ts | 53 ++++++++++++++++++++++++++ lib/src/remote/host/remote-api.ts | 33 +++++++++++----- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 0bb945e8..f4e6f6e5 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -259,6 +259,10 @@ type TerminalInput = attachment. A stale request for a detached surface, or a request for a background surface listed in the directory but not attached by this session, is rejected and must not reach the PTY or change its size. +The attachment is bound to the terminal selected at `surface.attach` time: +after a Host-side pane swap moves that terminal to another pane, the remote +stream, `terminal.write`, and `terminal.resize` keep targeting the same PTY +rather than re-resolving the old `surfaceId` through the current registry slot. ### Size authority: last-attach-wins diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 9ece709a..5bd4e6a4 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -255,4 +255,57 @@ describe('RemoteApiSession surface.attach', () => { }, ]); }); + + it('keeps write and resize pinned to the attached terminal after pane swaps', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + registerSurface(platform, 100, 30, 'surface-2', 'pty-2'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 90, 25, 'surface-1'); + const attachedEntry = registry.get('surface-1')!; + const swappedInEntry = registry.get('surface-2')!; + registry.set('surface-1', swappedInEntry); + registry.set('surface-2', attachedEntry); + sent.length = 0; + + session.handle({ + requestId: 'write-after-swap', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('still-attached\r')) }, + }); + + expect(platform.writePty).toHaveBeenCalledWith('pty-1', 'still-attached\r'); + expect(platform.writePty).not.toHaveBeenCalledWith('pty-2', expect.any(String)); + + session.handle({ + requestId: 'resize-after-swap', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, + }); + + expect((attachedEntry.terminal as { cols: number; rows: number }).cols).toBe(120); + expect((attachedEntry.terminal as { cols: number; rows: number }).rows).toBe(40); + expect((swappedInEntry.terminal as { cols: number; rows: number }).cols).toBe(100); + expect((swappedInEntry.terminal as { cols: number; rows: number }).rows).toBe(30); + expect(sent).toEqual([ + { + requestId: 'write-after-swap', + ok: true, + result: {}, + }, + { + subId: 'attach-1', + event: REMOTE_EVENTS.terminalData, + data: { bytes: toBase64Url(utf8Encode('terminal-resize:120x40')) }, + }, + { + requestId: 'resize-after-swap', + ok: true, + result: { cols: 120, rows: 40 }, + }, + ]); + }); }); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 020f6bb1..a3030daa 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -52,6 +52,7 @@ const FORCE_REPAINT_BOUNCE_MS = 60; interface Attachment { surfaceId: string; ptyId: string; + entry: TerminalEntry; subId: string; onData: (detail: { id: string; data: string }) => void; onExit: (detail: { id: string; exitCode: number }) => void; @@ -148,10 +149,22 @@ export class RemoteApiSession { return { params, entry }; } - #requireAttached(request: RemoteRequest, surfaceId: string): boolean { - if (this.#attachment?.surfaceId === surfaceId) return true; + #requireAttached(request: RemoteRequest, surfaceId: string): Attachment | null { + if (this.#attachment?.surfaceId === surfaceId) return this.#attachment; this.#fail(request, `surface is not attached: ${surfaceId}`); - return false; + return null; + } + + #attachedParams

( + request: RemoteRequest, + ): { params: P; attachment: Attachment } | null { + const params = request.params as P | undefined; + if (!params || typeof params.surfaceId !== 'string') { + this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + return null; + } + const attachment = this.#requireAttached(request, params.surfaceId); + return attachment ? { params, attachment } : null; } // --- Methods --- @@ -245,6 +258,7 @@ export class RemoteApiSession { const attachment: Attachment = { surfaceId: params.surfaceId, ptyId, + entry, subId, onData, onExit, @@ -298,20 +312,19 @@ export class RemoteApiSession { } #write(request: RemoteRequest): void { - const resolved = this.#resolveSurface(request); + const resolved = this.#attachedParams(request); if (!resolved) return; - const { params, entry } = resolved; - if (!this.#requireAttached(request, params.surfaceId)) return; + const { params, attachment } = resolved; // Feed the existing PTY input path; the local echo returns via onPtyData. - getPlatform().writePty(entry.ptyId, utf8Decode(fromBase64Url(params.bytes))); + getPlatform().writePty(attachment.ptyId, utf8Decode(fromBase64Url(params.bytes))); this.#ok(request, {}); } #resize(request: RemoteRequest): void { - const resolved = this.#resolveSurface(request); + const resolved = this.#attachedParams(request); if (!resolved) return; - const { params, entry } = resolved; - if (!this.#requireAttached(request, params.surfaceId)) return; + const { params, attachment } = resolved; + const entry = attachment.entry; const term = entry.terminal; const cols = clampTerminalDimension(params.cols, term.cols); const rows = clampTerminalDimension(params.rows, term.rows); From a936710860bbbe758f611f95df721517453dcd86 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 10:18:50 -0700 Subject: [PATCH 47/52] Bind relayed challenges to hosts --- server/src/handshake.ts | 15 +++++++++++--- server/src/relay.ts | 2 +- server/test/handshake.test.mjs | 37 +++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/server/src/handshake.ts b/server/src/handshake.ts index 04db591f..e31624c7 100644 --- a/server/src/handshake.ts +++ b/server/src/handshake.ts @@ -49,8 +49,12 @@ export interface HandshakeGate { checkPair(request: unknown): Promise; /** Remember the Host challenge the server just relayed to a client (freshness half). */ observeChallenge(clientId: string, hostId: string, challenge: string, expiresAt: number): void; - /** Verify a `connect2` request before relaying it to the Host. */ - checkConnect2(clientId: string, request: ConnectionRequest): Promise; + /** Verify a `connect2` request before relaying it to the target Host. */ + checkConnect2( + clientId: string, + targetHostId: string, + request: ConnectionRequest, + ): Promise; /** Drop any remembered challenge for a client that disconnected. */ forgetClient(clientId: string): void; } @@ -134,7 +138,11 @@ export class Handshake implements HandshakeGate { this.#relayed.delete(clientId); } - async checkConnect2(clientId: string, request: ConnectionRequest): Promise { + async checkConnect2( + clientId: string, + targetHostId: string, + request: ConnectionRequest, + ): Promise { const failures: ConnectionFailure[] = []; // (d) Freshness half: the request must answer the exact Host challenge the @@ -144,6 +152,7 @@ export class Handshake implements HandshakeGate { this.#relayed.delete(clientId); const challengeFresh = relayed !== undefined && + relayed.hostId === targetHostId && typeof request?.challenge === 'string' && relayed.challenge === request.challenge && this.#now() < relayed.expiresAt; diff --git a/server/src/relay.ts b/server/src/relay.ts index 2360c480..08639a16 100644 --- a/server/src/relay.ts +++ b/server/src/relay.ts @@ -259,7 +259,7 @@ export class RelayHub { // connect2: the server verifies the assertion (against the STORED key) // and challenge freshness before forwarding. On failure the client gets // a denial and the Host's challenge stays unburned. - const check = await this.#gate.checkConnect2(client.clientId, frame.request); + const check = await this.#gate.checkConnect2(client.clientId, frame.hostId, frame.request); if (!this.#isCurrentClientHost(client, frame.hostId, host)) return; if (!check.ok) { this.#toClient(client, { t: 'decision', allowed: false, failures: check.failures }); diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs index dc2dbba9..0e0b84a2 100644 --- a/server/test/handshake.test.mjs +++ b/server/test/handshake.test.mjs @@ -411,7 +411,7 @@ test('server derives relayed challenge expiry from its own observation clock', a clock.advance(DEFAULT_CHALLENGE_TTL_MS - 1); const assertion = await authenticator.assert({ challenge, origin: ORIGIN, rpId: RP_ID }); - const result = await gate.checkConnect2('client-1', { + const result = await gate.checkConnect2('client-1', 'host-1', { accountId: SELFHOST_ACCOUNT_ID, devicePublicKey: 'device-public-key', challenge, @@ -425,6 +425,41 @@ test('server derives relayed challenge expiry from its own observation clock', a assert.deepEqual(result, { ok: true }); }); +test('server rejects a connect2 that answers a different Host challenge', async () => { + const clock = makeClock(); + const authenticator = await newAuthenticator(); + const gate = new Handshake( + { + findPasskey: async (credentialId) => + credentialId === authenticator.credentialId + ? { + credentialId, + publicKey: authenticator.publicKey, + label: 'Test Passkey', + createdAt: clock.now(), + } + : undefined, + }, + { origin: ORIGIN, rpId: RP_ID, now: clock.now }, + ); + const challenge = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + gate.observeChallenge('client-1', 'host-a', challenge, clock.now() + DEFAULT_CHALLENGE_TTL_MS); + + const assertion = await authenticator.assert({ challenge, origin: ORIGIN, rpId: RP_ID }); + const result = await gate.checkConnect2('client-1', 'host-b', { + accountId: SELFHOST_ACCOUNT_ID, + devicePublicKey: 'device-public-key', + challenge, + deviceSignature: 'device-signature', + passkey: { + publicKey: authenticator.publicKey, + assertion, + }, + }); + + assert.deepEqual(result, { ok: false, failures: ['challenge-invalid'] }); +}); + // --- requireUserVerification: Server mirrors the Host's UV demand ----------- test('requireUserVerification: server rejects a UV-absent connect2 before forwarding (mirrors the Host)', async () => { From 42ca85acbe0d6e0a321bd6445c8f8d6ea88452a5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 10:19:57 -0700 Subject: [PATCH 48/52] Close Pocket relay when leaving wall --- lib/src/remote/pocket-app/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index f71db37b..7d1fca63 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -141,6 +141,7 @@ export default function App(): React.ReactElement { const leaveWall = () => { teardownAdapter(); + client.close(); setActiveHost(null); setPhase('hosts'); }; From d7b01c23a606f9aaff51c74d85fac0055cbbc2fe Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 10:40:51 -0700 Subject: [PATCH 49/52] Carry PTY liveness in the directory snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pocket picker showed a pane as attachable from an `alive` bit that the client derived from `exitCode` — but a directory entry's `exitCode` is the last shell-integration command's status, not process death, so `#emitPtyList` hardcoded `alive: true`. A pane whose PTY process has actually exited but that lingers in the Host registry (Dormouse keeps the "[Process exited]" pane open until the user closes it) was therefore offered as attachable, and attaching transferred nothing. Carry a real liveness bit end to end: the registry `TerminalEntry` gains an `exited` flag set once in the PTY-exit handler (the true process-death signal), directory-collect reads it into a new required `DirectoryEntry.alive` on the wire, and the client adapter emits `alive: entry.alive`. `exitCode` is never consulted for liveness. An exited-but-lingering surface now reports `alive: false` and is not offered for attach. Co-Authored-By: Claude Opus 4.8 --- docs/specs/remote-api.md | 15 ++++++++++--- lib/src/lib/terminal-lifecycle.ts | 4 ++++ lib/src/lib/terminal-store.ts | 6 ++++++ lib/src/remote/client/remote-adapter.test.ts | 22 ++++++++++++++++++++ lib/src/remote/client/remote-adapter.ts | 7 ++++--- lib/src/remote/host/directory-collect.ts | 7 +++++++ lib/src/remote/host/directory.test.ts | 13 ++++++++++++ lib/src/remote/host/directory.ts | 3 +++ lib/src/remote/pocket-app/wall-model.test.ts | 1 + lib/src/stories/PocketWall.stories.tsx | 1 + server-lib-common/src/remote/wire.ts | 7 +++++++ server/test/harness/fake-host.mjs | 1 + 12 files changed, 81 insertions(+), 6 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index f4e6f6e5..25a0480e 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -175,6 +175,7 @@ interface DirectoryEntry { // Terminal-only, from the existing semantic-event model (terminal-state.ts): activity?: 'unknown' | 'prompt' | 'editing' | 'running' | 'finished'; exitCode?: number; + alive: boolean; // the PTY process is still alive (see below) cwd?: string; // Browser-only: url?: string; @@ -195,9 +196,17 @@ future optimization there is no current reason to pay for. Thumbnails are future work; in v1 the picker renders from titles, activity, and the `ringing`/`hasTODO` badges. -Terminal directory `exitCode` is the last finished command's semantic status, -not PTY lifetime. A listed terminal is still a live Host registry surface until -its attachment emits `terminal.closed`. +`alive` reflects real PTY-process liveness: it is `true` while the pane's +process is running and `false` once that process has exited. Dormouse keeps an +exited pane open in the Host registry (rendering "[Process exited with code N]") +until the user closes it, so such a surface is still *listed* but reports +`alive: false` — the phone's picker uses this to stop offering a dead pane as +attachable (attaching would transfer nothing). + +This is distinct from `exitCode`, which is the last finished command's +shell-integration semantic status, not PTY lifetime. A pane can report +`alive: true` with an `exitCode` set (a command finished but the shell lives on), +and a pane reporting `alive: false` may carry no `exitCode` at all. --- diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 01f13c18..e4f2dd5e 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -167,6 +167,10 @@ function wirePtyEvents(id: string, terminal: Terminal): () => void { const handleExit = (detail: { id: string; exitCode: number }) => { if (detail.id !== id) return; terminal.write(`\r\n[Process exited with code ${detail.exitCode}]\r\n`); + // The PTY process is dead but the pane lingers in the registry; mark it so + // the directory reports this surface as `alive: false` to the phone. + const entry = registry.get(id); + if (entry) entry.exited = true; // The process is gone, so any command we seeded for this pane is no longer // live; clear it so `dor ensure` stops matching a dead surface. finishLaunchedCommandByPtyId(id, detail.exitCode); diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 20f1093c..5e771e3c 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -23,6 +23,12 @@ export interface TerminalEntry { attentionDismissedRing: boolean; isReplaying: boolean; untouched: boolean; + /** + * The PTY process has exited (onPtyExit fired) but the pane lingers in the + * registry showing "[Process exited…]". The directory reports this surface as + * `alive: false` so the phone's picker stops offering it as attachable. + */ + exited?: boolean; } export interface TerminalOverlayDims { diff --git a/lib/src/remote/client/remote-adapter.test.ts b/lib/src/remote/client/remote-adapter.test.ts index 3c27c127..f74b45bc 100644 --- a/lib/src/remote/client/remote-adapter.test.ts +++ b/lib/src/remote/client/remote-adapter.test.ts @@ -82,6 +82,7 @@ function entry(surfaceId: string, over: Partial = {}): Directory type: 'terminal', title: surfaceId, focused: false, + alive: true, ringing: false, hasTODO: false, ...over, @@ -109,6 +110,27 @@ describe('RemotePtyAdapter directory', () => { expect(adapter.getPaneEntry('s2')?.exitCode).toBe(0); }); + it('carries the entry alive bit into onPtyList (a dead pane is not attachable)', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const lists: PtyInfo[][] = []; + adapter.onPtyList(({ ptys }) => lists.push(ptys)); + + await adapter.init(); + // s2's PTY has exited (lingering surface) → alive:false, even with exitCode 0. + client.pushSnapshot([ + entry('s1', { alive: true }), + entry('s2', { alive: false, exitCode: 0 }), + ]); + + expect(lists).toEqual([ + [ + { id: 's1', alive: true }, + { id: 's2', alive: false }, + ], + ]); + }); + it('notifies subscribeDirectory listeners until they unsubscribe', async () => { const client = new FakeClient(); const adapter = new RemotePtyAdapter(client); diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index 144f85dd..430b59c9 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -203,9 +203,10 @@ export class RemotePtyAdapter implements PlatformAdapter { #emitPtyList(): void { const ptys: PtyInfo[] = this.#entries.map((entry) => ({ id: entry.surfaceId, - // Directory entries are live Host registry surfaces. `entry.exitCode` is - // the last command's semantic status, not the PTY process lifetime. - alive: true, + // `entry.alive` is real PTY-process liveness (a lingering exited surface + // reports false), NOT `entry.exitCode` — that is the last command's + // shell-integration status, which deriving alive from was the bug. + alive: entry.alive, })); for (const handler of this.#listHandlers) handler({ ptys }); } diff --git a/lib/src/remote/host/directory-collect.ts b/lib/src/remote/host/directory-collect.ts index 9b4f4199..7822a91a 100644 --- a/lib/src/remote/host/directory-collect.ts +++ b/lib/src/remote/host/directory-collect.ts @@ -48,11 +48,18 @@ export function collectDirectorySnapshot(): DirectoryEntry[] { deriveHeader(pane, [pane], { appTitleForPane }).primary, null, ); + // A pane whose PTY exited lingers in the registry (showing "[Process + // exited…]") with `exited` set; report it as not-alive. A missing entry + // (shouldn't happen — ids come from `registry.keys()`) is also not-alive. + // A live pane has an entry with `exited` unset → alive. + const entry = registry.get(id); + const alive = entry !== undefined && entry.exited !== true; return { paneRef: id, surfaceId: id, title, focused, + alive, pane, ringing: activity?.status === 'ALERT_RINGING', hasTODO: activity?.todo === true, diff --git a/lib/src/remote/host/directory.test.ts b/lib/src/remote/host/directory.test.ts index b7344ad7..16e18d27 100644 --- a/lib/src/remote/host/directory.test.ts +++ b/lib/src/remote/host/directory.test.ts @@ -25,6 +25,7 @@ function input(partial: Partial = {}): DirectoryPaneInput { surfaceId: 'p1', title: 'shell', focused: false, + alive: true, pane: pane(), ringing: false, hasTODO: false, @@ -52,6 +53,7 @@ describe('buildDirectoryEntry', () => { title: 'pnpm dev', focused: true, activity: 'running', + alive: true, cwd: '/home/me/project', ringing: true, hasTODO: true, @@ -60,6 +62,17 @@ describe('buildDirectoryEntry', () => { expect('exitCode' in entry).toBe(false); }); + it('passes alive straight through (true for a live pane, false for an exited one)', () => { + expect(buildDirectoryEntry(input({ alive: true })).alive).toBe(true); + // An exited pane lingering in the registry: exitCode may still be present, + // but alive is independently false. + const dead = buildDirectoryEntry( + input({ alive: false, pane: pane({ activity: { kind: 'finished', exitCode: 0 } as ShellActivity }) }), + ); + expect(dead.alive).toBe(false); + expect(dead.exitCode).toBe(0); + }); + it('includes exitCode only when a finished command has a real code', () => { const withCode = buildDirectoryEntry( input({ pane: pane({ activity: { kind: 'finished', exitCode: 1 } as ShellActivity }) }), diff --git a/lib/src/remote/host/directory.ts b/lib/src/remote/host/directory.ts index a1b9d07e..9cb3f2af 100644 --- a/lib/src/remote/host/directory.ts +++ b/lib/src/remote/host/directory.ts @@ -16,6 +16,8 @@ export interface DirectoryPaneInput { title: string; /** Focused on the Host. */ focused: boolean; + /** The pane's PTY process is still alive (not a lingering exited surface). */ + alive: boolean; pane: TerminalPaneState; /** The pane's alert is ringing on the Host (alert-manager). */ ringing: boolean; @@ -37,6 +39,7 @@ export function buildDirectoryEntry(input: DirectoryPaneInput): DirectoryEntry { focused: input.focused, activity: pane.activity.kind, ...(exitCode !== undefined ? { exitCode } : {}), + alive: input.alive, ...(cwd ? { cwd } : {}), ringing: input.ringing, hasTODO: input.hasTODO, diff --git a/lib/src/remote/pocket-app/wall-model.test.ts b/lib/src/remote/pocket-app/wall-model.test.ts index 20e7393c..5256dffe 100644 --- a/lib/src/remote/pocket-app/wall-model.test.ts +++ b/lib/src/remote/pocket-app/wall-model.test.ts @@ -21,6 +21,7 @@ function entry(surfaceId: string, over: Partial = {}): Directory type: 'terminal', title: surfaceId, focused: false, + alive: true, ringing: false, hasTODO: false, ...over, diff --git a/lib/src/stories/PocketWall.stories.tsx b/lib/src/stories/PocketWall.stories.tsx index 5cc1784a..8dd43c78 100644 --- a/lib/src/stories/PocketWall.stories.tsx +++ b/lib/src/stories/PocketWall.stories.tsx @@ -20,6 +20,7 @@ const SINGLE_SESSION: DirectoryEntry[] = [ title: 'zsh', focused: true, activity: 'prompt', + alive: true, ringing: false, hasTODO: false, }, diff --git a/server-lib-common/src/remote/wire.ts b/server-lib-common/src/remote/wire.ts index bcada9f1..cca77804 100644 --- a/server-lib-common/src/remote/wire.ts +++ b/server-lib-common/src/remote/wire.ts @@ -184,6 +184,13 @@ export interface DirectoryEntry { focused: boolean; activity?: 'unknown' | 'prompt' | 'editing' | 'running' | 'finished'; exitCode?: number; + /** + * The pane's PTY process is still alive. A registry surface whose process has + * exited (Dormouse keeps it open showing "[Process exited…]" until closed) + * reports `alive: false` — distinct from `exitCode`, which is the last + * shell-integration command's status, not process lifetime. + */ + alive: boolean; cwd?: string; ringing: boolean; hasTODO: boolean; diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs index 9e1158a4..310ee979 100644 --- a/server/test/harness/fake-host.mjs +++ b/server/test/harness/fake-host.mjs @@ -254,6 +254,7 @@ export class FakeHost extends EventEmitter { title: surface.title, focused: index === 0, activity: 'prompt', + alive: true, ringing: false, hasTODO: false, })); From 6d1b39ff2b445473101eb72855b03d94ff784489 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 10:41:16 -0700 Subject: [PATCH 50/52] Drop the attachment when its PTY exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning `terminal.write`/`terminal.resize` to the attached terminal (rather than re-resolving the surfaceId each call) lost the old fail-safe: when the attached PTY exited, the `onExit` handler emitted `terminal.closed` but left `#attachment` set, so a subsequent write/resize operated on the now-disposed entry — a `term.resize()` on a disposed xterm — instead of failing cleanly. `onExit` now tears down the attachment right after emitting `terminal.closed` (clearing the repaint bounce timer and PTY listeners), so post-exit write/resize fail with "surface is not attached" and detach stays an idempotent no-op. Co-Authored-By: Claude Opus 4.8 --- docs/specs/remote-api.md | 3 ++ lib/src/remote/host/remote-api.test.ts | 58 ++++++++++++++++++++++++++ lib/src/remote/host/remote-api.ts | 8 ++++ 3 files changed, 69 insertions(+) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 25a0480e..7fc62434 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -272,6 +272,9 @@ The attachment is bound to the terminal selected at `surface.attach` time: after a Host-side pane swap moves that terminal to another pane, the remote stream, `terminal.write`, and `terminal.resize` keep targeting the same PTY rather than re-resolving the old `surfaceId` through the current registry slot. +When that PTY exits, the Host emits `terminal.closed` and then drops the +attachment, so a later `terminal.write`/`terminal.resize` for the surface is +rejected ("surface is not attached") instead of acting on the disposed terminal. ### Size authority: last-attach-wins diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 5bd4e6a4..3e15bfe1 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -47,6 +47,12 @@ class RepaintOnResizePlatform { } } + emitExit(id: string, exitCode: number): void { + for (const handler of this.exitHandlers) { + handler({ id, exitCode }); + } + } + asAdapter(): PlatformAdapter { return this as unknown as PlatformAdapter; } @@ -308,4 +314,56 @@ describe('RemoteApiSession surface.attach', () => { }, ]); }); + + it('tears down the attachment when the attached PTY exits', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 100, 30, 'surface-1'); + sent.length = 0; + + // The attached PTY exits (process death, or the pane disposed on the Host). + platform.emitExit('pty-1', 0); + + // The client is told the terminal closed... + expect(sent).toEqual([ + { + subId: 'attach-1', + event: REMOTE_EVENTS.terminalClosed, + data: { exitCode: 0 }, + }, + ]); + sent.length = 0; + + // ...and the attachment is gone, so a later write/resize for that surface + // fails safe instead of touching the dead PTY / disposed xterm. + session.handle({ + requestId: 'write-after-exit', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('ghost\r')) }, + }); + session.handle({ + requestId: 'resize-after-exit', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, + }); + + expect(platform.writePty).not.toHaveBeenCalled(); + expect(platform.resizePty).not.toHaveBeenCalled(); + expect(sent).toEqual([ + { + requestId: 'write-after-exit', + ok: false, + error: 'surface is not attached: surface-1', + }, + { + requestId: 'resize-after-exit', + ok: false, + error: 'surface is not attached: surface-1', + }, + ]); + }); }); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index a3030daa..327327fb 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -251,7 +251,15 @@ export class RemoteApiSession { }; const onExit = (detail: { id: string; exitCode: number }): void => { if (detail.id !== ptyId) return; + // Deliver the close to the client first, then drop the attachment so a + // later write/resize for this surface fails safe with "not attached" + // instead of touching the now-dead PTY / disposed xterm (the pre-pin code + // re-resolved via the registry and got that fail-safe for free). Teardown + // offPtyExit(onExit)s mid-callback, which is safe — this handler, having + // filtered to its own ptyId, won't fire again — and nulls #attachment so + // #requireAttached fails and the bounce timer + PTY listeners are cleaned. emitOrBuffer(REMOTE_EVENTS.terminalClosed, { exitCode: detail.exitCode }); + this.#teardownAttachment(); }; platform.onPtyData(onData); platform.onPtyExit(onExit); From d618f3b5d101028d4782684820638d2b74e045d9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 12:30:18 -0700 Subject: [PATCH 51/52] Scope the Host webview CSP to the SaaS origin by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote-host feature needed the standalone webview to reach a relay server, and the CSP had been opened to scheme-wide `https:`/`wss:` — i.e. the webview could connect to any host on the internet, weakening exfiltration protection for every user (most of whom never use the remote feature). The shipped binary is one download for everyone, and self-hosters install a custom build, so scope the default to the SaaS origin (`*.dormouse.sh`) and let custom builds widen it: - Default `connect-src` (tauri.conf.json) allows `https://*.dormouse.sh wss://*.dormouse.sh` plus localhost for dev — no arbitrary internet host. This covers the no-remote and SaaS users with the tightest policy. - A custom/self-host build sets `DORMOUSE_REMOTE_CONNECT_SRC` (e.g. `https://dormouse.example.com wss://dormouse.example.com`, or a tailnet wildcard) to replace the default remote sources. scripts/tauri.mjs reads the env var and injects a `--config` CSP override, so the checked-in default stays clean and secure; withRemoteConnectSrc throws if the base CSP drifts so a build can't silently ship the wrong policy. Documented in server.md. No Rust/runtime change — the override is build-time, matching the custom-binary workflow self-hosters already use. Co-Authored-By: Claude Opus 4.8 --- docs/specs/server.md | 15 ++++++++++ standalone/package.json | 2 +- standalone/scripts/csp.mjs | 31 ++++++++++++++++++++ standalone/scripts/csp.test.mjs | 42 ++++++++++++++++++++++++++++ standalone/scripts/tauri.mjs | 34 ++++++++++++++++++++++ standalone/src-tauri/tauri.conf.json | 2 +- 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 standalone/scripts/csp.mjs create mode 100644 standalone/scripts/csp.test.mjs create mode 100644 standalone/scripts/tauri.mjs diff --git a/docs/specs/server.md b/docs/specs/server.md index aea19159..4e441f56 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -42,6 +42,21 @@ plain HTTP. clientData checks, passkey assertion verification, and the Host enrollment policy all use that normalized origin. +## Host webview CSP (self-host builds) + +The standalone Host is a Tauri app, and its webview `connect-src` bounds where +the Host can reach a relay server. The shipped binary is scoped to the SaaS +origin only (`https://*.dormouse.sh wss://*.dormouse.sh`, plus localhost for +dev), so a compromised webview cannot exfiltrate to an arbitrary host. A +self-host server on a different origin is therefore reached only by a custom +build: set `DORMOUSE_REMOTE_CONNECT_SRC` when building +(`pnpm --filter dormouse-standalone tauri build`) to the CSP sources for your +server, e.g. `https://dormouse.example.com wss://dormouse.example.com` (or a +tailnet wildcard `https://*.ts.net wss://*.ts.net`). It replaces the default +SaaS sources; localhost and the rest of the policy are untouched. The default +is deliberately not internet-wide — widening it is an explicit, per-build +opt-in. + # State files ``` diff --git a/standalone/package.json b/standalone/package.json index 7243ace3..092b6c9b 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -12,7 +12,7 @@ "stage": "pnpm run stage:dor-cli && pnpm run stage:sidecar-proxy", "stage:dor-cli": "pnpm --filter dor build && node scripts/stage-dor-cli.mjs", "stage:sidecar-proxy": "node scripts/build-sidecar-proxy.mjs", - "tauri": "pnpm run stage && tauri", + "tauri": "pnpm run stage && node scripts/tauri.mjs", "test": "vitest run" }, "dependencies": { diff --git a/standalone/scripts/csp.mjs b/standalone/scripts/csp.mjs new file mode 100644 index 00000000..8815ffff --- /dev/null +++ b/standalone/scripts/csp.mjs @@ -0,0 +1,31 @@ +// Build-time CSP `connect-src` policy for the standalone binary. +// +// The shipped binary everyone downloads is scoped to the SaaS origin only: +// remote-control Hosts talk to `*.dormouse.sh` over https/wss, and nothing +// else. That covers the ~99% of users (no remote at all, or SaaS) with the +// tightest connect-src, so a compromised webview can't exfiltrate to an +// arbitrary host. Self-hosters (who reach a server on their own domain or a +// tailnet) widen it for their own custom build via DORMOUSE_REMOTE_CONNECT_SRC +// — see docs/specs/server.md. The default lives in src-tauri/tauri.conf.json; +// this module is the single place that knows how to retarget it. + +/** The remote-server `connect-src` sources baked into the shipped binary. */ +export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +/** + * Return `baseCsp` with its default remote-server sources replaced by + * `remoteSrc` (a space-separated CSP source list, e.g. + * `https://dormouse.example.com wss://dormouse.example.com`). Throws if the + * default sources aren't present, so a drifted base CSP fails the build loudly + * instead of silently shipping an unintended policy. + */ +export function withRemoteConnectSrc(baseCsp, remoteSrc) { + if (!baseCsp.includes(DEFAULT_REMOTE_CONNECT_SRC)) { + throw new Error( + `CSP override: expected default remote sources ${JSON.stringify(DEFAULT_REMOTE_CONNECT_SRC)} ` + + 'in the base CSP, but they were not found. tauri.conf.json changed — ' + + 'update DEFAULT_REMOTE_CONNECT_SRC in standalone/scripts/csp.mjs to match.', + ); + } + return baseCsp.replaceAll(DEFAULT_REMOTE_CONNECT_SRC, remoteSrc.trim()); +} diff --git a/standalone/scripts/csp.test.mjs b/standalone/scripts/csp.test.mjs new file mode 100644 index 00000000..f242f027 --- /dev/null +++ b/standalone/scripts/csp.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { DEFAULT_REMOTE_CONNECT_SRC, withRemoteConnectSrc } from './csp.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const conf = JSON.parse(readFileSync(join(here, '..', 'src-tauri', 'tauri.conf.json'), 'utf8')); +const csp = conf.app.security.csp; + +test('the shipped default CSP is scoped to the SaaS origin', () => { + assert.ok(csp.includes(DEFAULT_REMOTE_CONNECT_SRC), 'default remote sources present'); + // Secure by default: no scheme-wide `https:`/`wss:` in connect-src that + // would let the webview reach an arbitrary internet host. + assert.ok(!csp.includes(' https: '), 'no bare https: source'); + assert.ok(!csp.includes(' wss:;') && !csp.includes(' wss: '), 'no bare wss: source'); + // Localhost stays allowed (dev + local self-host server). + assert.ok(csp.includes('http://localhost:*') && csp.includes('ws://localhost:*')); +}); + +test('withRemoteConnectSrc retargets the remote sources', () => { + const out = withRemoteConnectSrc(csp, 'https://dormouse.example.com wss://dormouse.example.com'); + assert.ok(out.includes('https://dormouse.example.com wss://dormouse.example.com')); + assert.ok(!out.includes('dormouse.sh'), 'default SaaS sources replaced'); + // Everything else (localhost, ipc, directives) is untouched. + assert.ok(out.includes('http://localhost:*') && out.startsWith("default-src 'self'")); +}); + +test('withRemoteConnectSrc trims whitespace from the env value', () => { + const out = withRemoteConnectSrc(csp, ' https://a wss://a\n'); + assert.ok(out.includes('https://a wss://a')); + assert.ok(!out.includes('https://a wss://a\n')); +}); + +test('withRemoteConnectSrc throws when the base CSP has drifted', () => { + assert.throws( + () => withRemoteConnectSrc("connect-src 'self' https:;", 'https://x'), + /tauri\.conf\.json changed/, + ); +}); diff --git a/standalone/scripts/tauri.mjs b/standalone/scripts/tauri.mjs new file mode 100644 index 00000000..6eeb3e95 --- /dev/null +++ b/standalone/scripts/tauri.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Wraps the Tauri CLI so a custom (self-host) build can retarget the CSP's +// remote-server `connect-src` without editing the checked-in default. +// +// Unset (the shipped binary): the tight default in tauri.conf.json applies +// (SaaS origin `*.dormouse.sh` only). +// Set DORMOUSE_REMOTE_CONNECT_SRC="https://my.host wss://my.host": the default +// remote sources are replaced with that value via a `--config` override, so +// the checked-in config stays clean and the shipped default stays secure. +// +// cross-spawn (matches the other scripts here): resolves the local `tauri` +// bin and behaves on Windows where a bare spawn('pnpm', …) can't. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import spawn from 'cross-spawn'; +import { withRemoteConnectSrc } from './csp.mjs'; + +const args = process.argv.slice(2); +const remoteSrc = process.env.DORMOUSE_REMOTE_CONNECT_SRC?.trim(); + +if (remoteSrc) { + const here = dirname(fileURLToPath(import.meta.url)); + const conf = JSON.parse(readFileSync(join(here, '..', 'src-tauri', 'tauri.conf.json'), 'utf8')); + const csp = withRemoteConnectSrc(conf.app.security.csp, remoteSrc); + args.push('--config', JSON.stringify({ app: { security: { csp } } })); + console.error(`[tauri] connect-src remote sources overridden via DORMOUSE_REMOTE_CONNECT_SRC=${remoteSrc}`); +} + +const child = spawn('pnpm', ['exec', 'tauri', ...args], { stdio: 'inherit' }); +child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); +}); diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index c1773c7d..a26541bb 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* https: ws://127.0.0.1:* ws://localhost:* wss:; frame-src http://127.0.0.1:* http://localhost:*" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https://*.dormouse.sh wss://*.dormouse.sh; frame-src http://127.0.0.1:* http://localhost:*" } }, "bundle": { From d46152c08d0d1654758256dbdbba70d7d78732d0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 4 Jul 2026 12:43:50 -0700 Subject: [PATCH 52/52] Wire csp.test.mjs into standalone's test script vitest's include glob doesn't match scripts/*.test.mjs, so the CSP-scoping drift guard added for withRemoteConnectSrc had no CI coverage. Run it via node --test, matching how server/dor run their .mjs tests. --- standalone/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standalone/package.json b/standalone/package.json index 092b6c9b..db67d1a6 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -13,7 +13,7 @@ "stage:dor-cli": "pnpm --filter dor build && node scripts/stage-dor-cli.mjs", "stage:sidecar-proxy": "node scripts/build-sidecar-proxy.mjs", "tauri": "pnpm run stage && node scripts/tauri.mjs", - "test": "vitest run" + "test": "vitest run && node --test scripts/*.test.mjs" }, "dependencies": { "@phosphor-icons/react": "^2.1.10",