Remote control ("Pocket"): self-hosted phone terminal#203
Conversation
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 <noreply@anthropic.com>
…t, no iframes - 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 <noreply@anthropic.com>
…tic scrollback as v2 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 <device>', and the wall lease becomes purely presentational (wholesale tethering). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…nal in five slices 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…l bridge (slice 4) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…rm adapter 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 <noreply@anthropic.com>
…RemotePtyAdapter (phase 1a) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
32dedb7
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://20f52e8c.mouseterm.pages.dev |
| Branch Preview URL: | https://security-model.mouseterm.pages.dev |
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) <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
Reviewed the new server relay/handshake and the Host bridge closely — the trust model reads carefully: the server verifies connect2 assertions against the stored passkey key (never the request-carried one), consumes the relayed challenge single-use before forwarding, invalidates sessions on Host replacement, and leaves authorizeConnection as the Host's final authority. Nice. The one thing blocking a green run is a build-graph gap, not the code itself.
Visual Regression + Cloudflare Pages fail: server-lib-common isn't built for the browser bundles
Both red checks share one root cause. This PR makes Wall.tsx statically import RemotePairingModalHost → enrollment.ts → import … from 'server-lib-common'. Wall has Storybook stories and is bundled into the website, so both browser bundles now pull in server-lib-common, which resolves via its exports to ./dist/index.js. That dist/ is gitignored and only gets built in pipelines that build it explicitly:
Build & Testis green becausepnpm -r run testrunsserver-lib-common's owntestscript (pnpm run build && node --test …), which producesdist/as a side effect beforelib's vitest runs.- Chromatic (
build-storybook) and the website build never build it, so rolldown fails:Rolldown failed to resolve import "server-lib-common" from "./src/remote/host/enrollment.ts". Chromatic was green onmain(f13ea08), so this is new to the PR.
Two fixes, both defensible:
- Alias to source in the two bundler configs, matching the pattern already there.
lib/.storybook/main.ts'sviteFinalalready aliasesdoranddormouse-libto source precisely for this class of failure (its comment: "Storybook's Vite doesn't read tsconfig paths, so … any Wall-importing story fails"). Adding'server-lib-common': path.resolve(here, '..', '..', 'server-lib-common', 'src')there, and the same alias inwebsite/vite.config.ts'sresolve.alias, fixes both without a build step. - Build it first in each pipeline (mirrors what
Build & Testgets for free) — e.g. buildserver-lib-commonbeforebuild-storybookinchromatic.ymland before the website build.
I'd lean toward option 1 since it's the established convention in this repo and needs no CI-ordering. Happy to push a commit with the aliases (Storybook + website) and confirm both builds go green locally — say the word.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…sskeys, socket loss 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 <noreply@anthropic.com>
…app UI 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) <noreply@anthropic.com>
Remote control ("Pocket"): self-hosted phone terminal
Adds the first end-to-end path for driving a Dormouse terminal from a phone: a self-hosted relay server, a shared security model, and a Pocket web app that renders a real remote Host over a terminal-only remote-api v1. This is a POC (one owner account, terminal surfaces only), built as five vertical slices behind the design specs in
docs/specs/.What's here
server-lib-common/— runtime-agnostic contracts shared by server, host, and client so the three sides can't drift:docs/specs/remote-security-model.md): device keys, host challenges, passkey-assertion verification, the Host ACL, the pairing ceremony, and connection authorization — with end-to-end tests covering every spec guarantee.remote/wire.ts): HTTP routes, relay frames, and the terminal-only remote-api v1 messages.server/— a Hono webserver acting as the coordinating relay:lib/src/remote/— the host and client halves plus the Pocket app:RemoteApiSession) that translates the wire protocol into the existing xterm/PTY plumbing.RemotePtyAdapterthat implements the existingPlatformAdapterseam, so the sameMobileTerminalUithe site proves out with a fake adapter renders a live remote Host (phase 1a/1b).Notes
docs/specs/pocket-app.md).consolidate duplicated helpers behind shared contracts) is a cleanup pass: a sharedclampTerminalDimensionandRemoteWebSockettype, aJsonFileStorebase behind the two server stores, areadPasswordGatedhelper and#resolveSurfaceguard for the repeated route boilerplate, and use of the sharedloadHostAcl.Testing
server-lib-common,server:tsc+ node test suites green.lib: 776 vitest tests pass,tsc -bclean.🤖 Generated with Claude Code