diff --git a/.gitignore b/.gitignore index fb00e6c5..a9204805 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ standalone/dist/ standalone/sidecar/dor-cli/ standalone/sidecar/iframe-proxy.cjs standalone/sidecar/agent-browser-host.cjs +standalone/sidecar/remote-host.cjs standalone/sidecar/node_modules/ standalone/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index cd7c89cf..6cb9504c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ pnpm build # build lib, vscode extension, and website - **`lib/`** — Shared React + TailwindCSS frontend library: components, tests, Storybook. - `lib/src/lib/platform/` — platform abstraction (`PlatformAdapter` interface, fake + VSCode adapters) - - `lib/src/remote/` — remote control: `host/` (laptop side), `client/` (phone-side protocol + `RemotePtyAdapter`), `pocket-app/` (Pocket shell), `ws.ts` (shared socket surface) + - `lib/src/host/` — Node-side host modules bundled into both hosts: the iframe proxy, the agent-browser host, and `remote/` (the `RemoteHostService` that runs in the Tauri sidecar and the VS Code extension host) + - `lib/src/remote/` — remote control: `host/` (laptop side: protocol-v1 session, security, the webview's responder + pairing UI), `client/` (phone-side protocol + `RemotePtyAdapter`), `pocket-app/` (Pocket shell), `ws.ts` (shared socket surface) - **`standalone/`** — Tauri desktop app (Rust + Vite frontend). - `standalone/sidecar/` — Node.js PTY manager (native PTY via node-pty), bundled as the Tauri sidecar - `standalone/src-tauri/` — Rust backend bridging webview ↔ sidecar @@ -50,8 +51,8 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi - **`docs/specs/tutorial.md`** — Website playground tutorial: device-specific routes, the `tut` runner + detector + progress state, desktop and Pocket profiles, localStorage keys, the lib hooks that exist for tutorial observability, and the mouse/clipboard feature-coverage matrix. Touch points: the playground pages in `website/src/pages/`, `website/src/lib/tut-*.ts`, `PocketTerminalExperience.tsx`, fake-adapter scenario extensions, the `WallEvent` union. - **`docs/specs/webgl-text.md`** — The SDF text-rendering stack for the 3D/WebXR terminal effort: the diffplug/xterm.js fork pipeline (branch strategy, sdf-version lockstep with `@xterm/xterm` pins, GitHub-release tarball distribution), the SDF glyph architecture in the forked webgl addon (color-free atlas with one texture entry per shape, shader tint/smoothstep contract, raster fallbacks for emoji/custom glyphs/decorated cells, the MSDF-compatible texel reservation), and the canopy Storybook lab with its upstream-vs-fork regression harness. Touch points: `canopy/`, the fork's `addons/addon-webgl` (separate repo), any bump of the fork tarball URL or `@xterm/*` pins in `canopy/package.json`. - **`docs/specs/remote-security-model.md`** — The trust model for remote control: passkeys prove fresh user presence (user credentials — they sync), non-extractable per-browser device keys prove long-lived Client identity, the Host's local ACL authorizes the *pair* via a local-approval pairing ceremony, and the Host — never the Server — makes the final access decision. Read this first for anything remote; the other three remote specs build on it. Touch points: `server-lib-common/src/security/`, `server/src/handshake.ts`, the security modules in `lib/src/remote/host/` and `lib/src/remote/client/`. -- **`docs/specs/remote-api.md`** — The protocol a Client speaks after `authorizeConnection`: the shipped terminal-only **protocol-v1** (snapshot directory, attach-is-the-resize, last-attach-wins size authority) and the staged remainder (browser surfaces, in-flight replay, semantic scrollback, tethering display, grants, VR Window, WebRTC). Touch points: `server-lib-common/src/remote/wire.ts` (the fixed wire contract), `lib/src/remote/host/remote-api.ts`, `lib/src/remote/client/`. -- **`docs/specs/server.md`** — The selfhost coordinating server: env config, two-JSON-file state, "WebAuthn without a WebAuthn library", the HTTP API, the relay frame flow (one host challenge feeds both signatures → one biometric prompt per connect), the Host webview CSP for self-host relays (`DORMOUSE_REMOTE_CONNECT_SRC`), Host/Pocket side responsibilities, the testing harness, and instructions for running it end to end. Touch points: `server/src/`, `lib/src/remote/host/enrollment.ts`, the `dev:pocket-server` flow. +- **`docs/specs/remote-api.md`** — The protocol a Client speaks after `authorizeConnection`: the shipped terminal-only **protocol-v1** (snapshot directory, attach-is-the-resize, last-attach-wins size authority) and the staged remainder (browser surfaces, in-flight replay, semantic scrollback, tethering display, grants, VR Window, WebRTC). Touch points: `server-lib-common/src/remote/wire.ts` (the fixed wire contract), `lib/src/remote/host/remote-api.ts` + `host-surface-provider.ts`, `lib/src/host/remote/` (the Node-side service both hosts install), `lib/src/remote/client/`. +- **`docs/specs/server.md`** — The selfhost coordinating server: env config, local JSON-file state, "WebAuthn without a WebAuthn library", the HTTP API, the relay frame flow (one host challenge feeds both signatures → one biometric prompt per connect), the baked relay-origin allowlist for self-host builds (`DORMOUSE_REMOTE_CONNECT_SRC`), Host/Pocket side responsibilities, the testing harness, and instructions for running it end to end. Touch points: `server/src/`, `lib/src/remote/host/enrollment.ts`, `scripts/csp-defaults.mjs`, the `dev:pocket-server` flow. - **`docs/specs/pocket-app.md`** — Pocket app architecture: the remote session is a `PlatformAdapter` (`RemotePtyAdapter`), so Pocket is auth screens + the mobile-terminal-ui composition; the `lib/src/remote/` module layout and the same-origin deployment rule (WebAuthn origin binding + Chrome PNA). Touch points: `lib/src/remote/client/` + `pocket-app/`, `lib/vite.pocket.config.ts`, the Pocket static serving in `server/src/app.ts`. - **`docs/specs/deploy.md`** — Release process: the artifact matrix, release checklist, two-stage pipeline (CI builds unsigned + attests; a local script verifies, signs macOS/Windows, and creates the GitHub Release), Tauri updater manifest, changelog flow, and secrets. Touch points: `.github/workflows/release.yml`, `scripts/sign-and-deploy.sh`, `scripts/bump-version.sh`, the updater config in `tauri.conf.json`. diff --git a/SELF_HOST.md b/SELF_HOST.md new file mode 100644 index 00000000..5f17e79f --- /dev/null +++ b/SELF_HOST.md @@ -0,0 +1,1106 @@ +# Run the Dormouse server behind Tailscale + +> This is an assistant-run setup playbook. Start a fresh Claude instance in +> this repository and say: `read @SELF_HOST.md and walk me through it`. + +This installs the Dormouse coordinating server on the user's own Mac, reachable +only from their tailnet at `https://..ts.net`. That is the +whole self-host story today. An always-on cloud relay is designed but not +built; it lives under `## Future`. + +## Instructions to the assistant + +Your job is to guide the user through this runbook one checkpoint at a time, +performing the repository and command-line work you safely can and pausing only +for browser-console actions, secrets, or explicit approval of external or +destructive changes. Do not dump the entire runbook back at the user. + +Before acting: + +1. Read `AGENTS.md`, `SECURITY.md`, `docs/specs/server.md`, + `docs/specs/remote-security-model.md`, and the CSP section of + `docs/specs/standalone.md` completely. +2. Inspect the worktree and preserve unrelated user changes. Determine whether + any files from this runbook already exist; resume and verify rather than + overwriting a partial setup. +3. Recheck the linked official documentation. This runbook was updated on + 2026-08-17; dashboards and CLI syntax can change. +4. Explain the current checkpoint, carry it out, verify it, and only then move + to the next checkpoint. +5. Never ask the user to paste the setup password or any other bearer + credential into chat. Generate the setup password on the laptop and leave it + in the installer-owned mode-`0600` config file. +6. Do not commit, push, merge, or delete installed state without first showing + the exact change and obtaining the user's approval. +7. If the user needs a relay that stays up while this laptop is asleep, stop and + read `## Future` with them rather than improvising cloud infrastructure. + +Keep a small worksheet in the conversation and fill it in as values become +known: + +| Value | Default / example | +| --- | --- | +| Laptop OS | must be macOS | +| Laptop Tailscale DNS name | derive from `tailscale status --json` | +| External origin | `https://.` | +| Install root | `~/Library/Application Support/Dormouse Server` | +| State directory | `~/Library/Application Support/Dormouse Server/state` | +| LaunchAgent | `~/Library/LaunchAgents/sh.dormouse.server.plist` | +| Loopback port | `3100` | + +## Prerequisites + +- **A tailnet.** The user needs a Tailscale account with MagicDNS and HTTPS + certificates enabled, Tailscale running on this Mac, and Tailscale on the + phone that will run Pocket. A tailnet-only origin is not reachable merely + because the laptop is on the tailnet. +- **macOS.** The installer below is macOS-only. On another OS, stop and design + the native service manager with the user rather than translating LaunchAgent + commands blindly. +- **A Host build that can reach a `*.ts.net` origin.** The shipped standalone + and VS Code Hosts bake in the SaaS-only relay allowlist, so a self-host relay + needs a local build of whichever Host the user runs: + + ```sh + DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone + DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode + ``` + + `standalone/scripts/build-sidecar-proxy.mjs` and + `vscode-ext/scripts/esbuild.mjs` bake that variable into their respective + Node Host bundles. The relay socket no longer lives in either webview, so + changing a webview CSP does not widen this allowlist. + +## Architecture + +### What gets installed + +```text +user runs ./deploy/local/install-macos.sh + | + v +build exact current checkout into a self-contained release + | + v +macOS LaunchAgent (RunAtLoad + KeepAlive) + | + v +Dormouse Node server on 127.0.0.1:3100 + | + v +tailscale serve --bg terminates private HTTPS + | + v +https://..ts.net + +~/Library/Application Support/Dormouse Server/state + account.json + hosts.json + push-subscriptions.json + vapid.json +``` + +The LaunchAgent starts after the user logs in and restarts the process if it +crashes. Tailscale's background Serve configuration survives Tailscale and +machine restarts. The service is unavailable while the laptop sleeps, is shut +down, or has no logged-in user; that is normally fine because there is then no +local Dormouse Host to control. + +### Invariants + +- Run exactly one server replica. Challenges, sessions, WebSocket bindings, and + relay state are in memory. Multiple uncoordinated replicas are incorrect. +- An update is a short intentional restart. Existing Host and Pocket WebSockets + disconnect and reconnect; do not attempt a zero-downtime swap for this + protocol. +- Persist the entire state directory outside the installed release, including + `account.json`, `hosts.json`, `push-subscriptions.json`, and the generated + `vapid.json`. Code replacement must never replace state. +- Bind the server only to loopback. Do not make plain HTTP port 3100 reachable + from the LAN or the tailnet. Tailscale terminates HTTPS. +- Port 3100 is deliberately not 3000: `pnpm dev:server` and + `pnpm dev:pocket-server` both run the server on 3000, and the installed + service shares this laptop with that dev loop. +- Treat `DORMOUSE_ORIGIN` as durable WebAuthn identity. It is the laptop's + Tailscale DNS name; renaming or re-registering that node can require passkey + and Host re-enrollment. +- The installed release must contain both `server/dist` and `lib/dist-pocket`. + Building the `server` package alone is insufficient. +- The setup password remains only in a mode-`0600` local configuration file. + +## Definition of done + +- `https://..ts.net/api/hello` succeeds from a tailnet device + and is unreachable when that device leaves the tailnet. +- The Pocket app is served at the same HTTPS origin. +- Port 3100 is bound only to `127.0.0.1`. +- Every persistent state file survives replacement of the running release. +- One installer invocation builds and installs the exact current checkout. +- The LaunchAgent is loaded, starts at login, and restarts the server after an + intentional process kill. +- `tailscale serve --bg` is configured for the laptop's HTTPS name. +- Rerunning the installer updates the release and preserves state; a failed + update restores the prior release. +- `manage verify` exits zero and reports every check above that it can observe + locally. +- The repository specs describe the installed behavior. + +## Phase 0: preflight + +Inspect and report: + +- `git status --short`, current branch, and origin. +- The exact Node version in root `package.json` under + `devEngines.runtime.version`, and the pnpm version in `packageManager`. + `SECURITY.md` keys a mechanical `FAIL IF` to the `devEngines` field, so read + that field specifically rather than `engines`. +- The host OS and architecture. If this is not macOS, stop; see Prerequisites. +- Whether `tailscale` is installed, signed in, and on `PATH`; on macOS also + check the known application-bundle CLI paths. When invoking the bundled + macOS app executable from a script, set `TAILSCALE_BE_CLI=1` so it cannot + launch the GUI instead of acting as the CLI. +- Whether HTTPS and MagicDNS are enabled for the tailnet. +- The laptop's stable Tailscale DNS name. +- That port 3100 is available on loopback. +- That the user wants the currently checked-out worktree installed. Report the + Git SHA and whether it is dirty; do not silently switch or pull branches. + +Confirm that the user's phone runs Tailscale. + +## Install on this Mac + +This runbook is intentionally independent of GitHub and cloud hosting. Its only +remote dependency is the user's existing Tailscale account. The current checkout is +the release source; rerunning the installer is the update mechanism. + +### 1: author the local installer + +Create and review: + +```text +deploy/local/install-macos.sh +``` + +The installer may generate stable helper files inside its install root, but do +not require the user to maintain hand-edited plists or shell wrappers. The +normal command is exactly: + +```sh +./deploy/local/install-macos.sh +``` + +Running that command a second time updates the installed release from the +current checkout. It must not run `git pull`, switch branches, fetch a release, +or install a scheduled updater. + +The server already supports an explicit loopback bind: `DORMOUSE_BIND_HOST` is +read by `server/src/config.ts` and passed through the `@hono/node-server` listen +option, with the unset default still binding every interface. The local +configuration must set: + +```dotenv +DORMOUSE_BIND_HOST=127.0.0.1 +``` + +Do not reintroduce a generic `HOST` variable for this, and do not change the +unset default — `server/test/bind-host.test.mjs` asserts both halves. + +Update `docs/specs/server.md` above the fold with the installation behavior, +using `Source of truth:` pointers. Add `deploy/local/install-macos.sh` to that +spec's exhaustive Files/Code Map if it has one. Update `SECURITY.md` only if the +installer changes an invariant it audits; this path adds no GitHub workflow or +deployment secret. + +### 2: installer contract + +The script must be idempotent, strict Bash and safe with spaces in paths. It +must refuse non-macOS hosts with a clear message. It should require no `sudo` +and install only into the current user's home directory: + +```text +~/Library/Application Support/Dormouse Server/ + bin/ + run-server + manage + config/ + server.env + current -> releases/ + previous -> releases/ + releases/ + / + runtime/node + server/ + lib/dist-pocket/ + RELEASE + state/ + account.json + hosts.json + push-subscriptions.json + vapid.json + +~/Library/LaunchAgents/sh.dormouse.server.plist +~/Library/Logs/Dormouse Server/ +``` + +On each invocation it must: + +1. Confirm `tailscale` is installed, signed in, and reports a stable DNS name. + Detect both a CLI on `PATH` and supported macOS application-bundle CLI + locations. Export `TAILSCALE_BE_CLI=1` for every invocation of the bundled + app executable. Do not install or reauthenticate Tailscale without the user. +2. Derive the external origin from `tailscale status --json`, remove any + trailing dot, and show it to the user. If an existing installation's origin + differs, stop and explain the WebAuthn migration consequence rather than + silently rewriting it. +3. Report the current Git SHA, branch, architecture, and dirty/clean status. + Ask for confirmation before installing a dirty worktree, but allow it: the + whole point is to install exactly what is currently checked out. +4. Read the exact Node version from root `package.json` under + `devEngines.runtime.version` and the pnpm version from `packageManager`; use + Corepack and the repository versions rather than global floating versions. +5. Install with `pnpm install --frozen-lockfile`, build `lib/dist-pocket`, + `server-lib-common`, and `server`, then create a production-only server tree. + With the current workspace, use the verified `pnpm deploy --prod --legacy` + flow unless injected workspace packages are intentionally adopted. +6. Copy the exact `process.execPath` Node executable used for the build into the + release. The LaunchAgent must not depend on Homebrew, nvm, Volta, pnpm's + cache, the source checkout, or the user's interactive shell `PATH` after + installation. Verify the copied runtime's version and macOS architecture. +7. Copy `lib/dist-pocket` into the layout expected by `server/src/config.ts` + (or point `DORMOUSE_POCKET_DIR` at it). +8. Write a `RELEASE` metadata file containing at least Git SHA, dirty status, + build timestamp, Node version, and source checkout path. Do not claim a dirty + build is reproducibly identified by its SHA alone. +9. On first install, generate a high-entropy hexadecimal setup password on the + Mac and create mode-`0600` `config/server.env` containing: + + ```dotenv + DORMOUSE_SETUP_PASSWORD= + DORMOUSE_ORIGIN=https://. + DORMOUSE_STATE_DIR="/state" + DORMOUSE_BIND_HOST=127.0.0.1 + PORT=3100 + NODE_ENV=production + ``` + + Preserve this file byte-for-byte on updates. Do not print the password + during routine install/update. Provide an explicit `manage show-password` + operation that warns before displaying it locally for setup or enrollment. + Keep the `config` and `state` directories mode `0700`; they contain the setup + password and Host bearer credentials. +10. Install a stable mode-`0700` `bin/run-server` wrapper outside the release. + It must safely load only the installer-owned env file and `exec` the copied + Node runtime with `current/server/dist/index.js`. It must not invoke a + shell-dependent package manager at service startup. +11. Install `~/Library/LaunchAgents/sh.dormouse.server.plist` with absolute + paths and `RunAtLoad` plus `KeepAlive`. Use `ProgramArguments`, a valid + `WorkingDirectory`, bounded restart throttling, and stdout/stderr paths + under `~/Library/Logs/Dormouse Server`. Do not embed the setup password in + the plist. Validate it with `plutil -lint`. +12. Stage the new release without touching `current`, run a disposable + loopback health check against the candidate, and only then switch the + symlink atomically. +13. Use modern `launchctl bootout`, `bootstrap`, and `kickstart` commands in the + current `gui/$UID` domain. Treat “not currently loaded” during a first + install as benign; treat other launchd errors as failures. +14. Wait for `http://127.0.0.1:3100/api/hello` and the Pocket index. If the new + release fails, restore `current` to `previous`, restart it, verify it is + healthy, and exit nonzero. Never report an update successful merely because + rollback worked. +15. Retain the current and previous releases and remove older releases only + after success. Never remove `state` or `config` during cleanup. +16. Inspect the node's existing Serve configuration, then configure the current + equivalent of `tailscale serve --bg 3100`. This is a node-scoped Serve + endpoint, not a Tailscale Service. Do not reset or overwrite unrelated Serve + paths; if another app already owns the root HTTPS mapping, stop and resolve + the hostname/path conflict with the user. Allow Tailscale's HTTPS consent + flow to open if the tailnet has not enabled certificates. +17. Verify Serve reports the same HTTPS origin written to `server.env`. + +The installed `bin/manage` helper should support at least: + +```text +status show LaunchAgent, process, health, Serve origin, and release +verify run the Definition of done checks and exit nonzero on any failure +logs tail the local server logs +restart kickstart the LaunchAgent and wait for health +show-password warn, then display the setup password locally +rollback switch to the retained previous release, preserving state +uninstall remove LaunchAgent and installed code only after confirmation +``` + +Uninstall must default to preserving `config` and `state`, explicitly report +their locations, and turn off only the Serve mapping owned by this installer. +Provide a separate explicit purge operation for irreversible state deletion; +require the user to type a confirmation phrase. Never make purge part of a +normal reinstall or uninstall. + +### 3: test before installing + +Before the user runs the installer against their real state: + +1. Run `bash -n` and a shell linter if one is already available. +2. Run `pnpm lint:specs` and the server tests. +3. Exercise installation with a temporary `HOME` or an installer test mode so + path quoting, plist generation, release switching, and cleanup can be tested + without loading a real LaunchAgent. Do not fake the final live validation. +4. Confirm the release starts without the repository or package-manager paths + on `PATH`. +5. Confirm plain HTTP is reachable at `127.0.0.1:3100` and not at the laptop's + LAN or Tailscale IP on port 3100. + +Show the exact repository diff and test results. Ask before committing; installing the +current checkout does not require a commit. + +### 4: install and validate + +With the user's approval, run: + +```sh +./deploy/local/install-macos.sh +``` + +The script may require the user to approve Tailscale HTTPS in a browser. It +must otherwise finish without a checklist of manual service-manager commands. + +Verify: + +```sh +"$HOME/Library/Application Support/Dormouse Server/bin/manage" verify +``` + +That command must perform, at minimum, the equivalent of: + +```sh +launchctl print "gui/$UID/sh.dormouse.server" +curl --fail http://127.0.0.1:3100/api/hello +tailscale serve status +lsof -nP -iTCP:3100 -sTCP:LISTEN +``` + +Then, from another tailnet-connected device: + +1. Request the HTTPS `/api/hello` endpoint. +2. Open the Pocket application at the same origin. +3. Temporarily leave Tailscale on that test device and verify it becomes + unreachable. + +Kill the server process once and verify LaunchAgent restarts it. Restart the +laptop only if the user approves the interruption; otherwise explain that +`RunAtLoad` plus the loaded LaunchAgent has been verified but the reboot test +was skipped. After a real login/reboot, verify both the process and background +Serve mapping return without rerunning the installer. + +Complete Pocket passkey setup and Host enrollment using a standalone or VS Code +build whose `DORMOUSE_REMOTE_CONNECT_SRC` includes +`https://*.ts.net wss://*.ts.net`. After `account.json`, `hosts.json`, and +`vapid.json` exist (and `push-subscriptions.json` too if push was enabled): + +1. Record ownership and checksums of every present state file without printing + contents. +2. Rerun the same installer from the same or a newer checkout. +3. Confirm the release changed as expected and state/checksums survived. +4. Exercise the retained-release rollback and return to the desired release. + +### 5: operational expectations and backup + +Make these limitations explicit: + +- The relay is unavailable while the Mac sleeps, is shut down, Tailscale is + disconnected, or the user is logged out. A LaunchAgent is a per-login agent, + not a pre-login system daemon. +- The installer does not follow `main`. To update: choose the checkout, inspect + it, and rerun `./deploy/local/install-macos.sh`. +- The HTTPS origin is tied to the laptop's Tailscale node name. Do not rename or + delete/re-enroll the node casually after registering passkeys. +- Tailscale network policy still controls which tailnet members can reach the + laptop. Review existing grants if the tailnet contains other users. + +Confirm that the install root, especially `config` and `state`, is covered by +Time Machine or another encrypted backup outside the laptop. A second directory +on the same disk is not a backup. Perform a small restore rehearsal without +overwriting live state. + +Give the handoff and stop. + +## Final handoff + +Give the user a concise final report. Include: + +- The Pocket URL and its WebAuthn-origin significance. +- The exact installed Git SHA and whether the build was dirty. +- Where runtime config, state, release metadata, and logs live. +- The rollback command. +- Backup status and restore location. +- Any skipped acceptance test or remaining manual Host/Pocket setup. +- That updates happen only when the user reruns + `./deploy/local/install-macos.sh`, plus the sleep/shutdown/logout + availability limitation. +- The installed `manage status`, `manage verify`, `manage logs`, and + `manage restart` commands. + +Do not print the setup password or any credential in the handoff. + +## Official references + +- Dormouse runtime and state contract: `docs/specs/server.md` +- Dormouse trust model: `docs/specs/remote-security-model.md` +- Host installations: `docs/specs/standalone.md`, `docs/specs/vscode.md` +- [Install Tailscale on macOS](https://tailscale.com/docs/install/mac) +- [Tailscale variants on macOS](https://tailscale.com/docs/concepts/macos-variants) +- [Manage scripts with launchd](https://support.apple.com/guide/terminal/script-management-with-launchd-apdc6c1077b/mac) +- [Tailscale Serve](https://tailscale.com/docs/features/tailscale-serve) + +## Troubleshooting boundaries + +- **Local install works only while the source checkout exists:** the LaunchAgent + was pointed into the repository instead of the self-contained install root. + Fix the installer; do not paper over it with a permanent checkout path. +- **Local LaunchAgent loops or will not load:** run `plutil -lint`, inspect + `launchctl print gui/$UID/sh.dormouse.server`, and read the configured stdout + and stderr files. Check absolute paths and permissions; launchd does not run + the user's interactive shell startup files. +- **Local HTTPS URL returns 502:** first check the loopback health endpoint, + then `tailscale serve status`. The LaunchAgent and Serve configuration have + separate lifecycles. +- **Port 3100 is visible on LAN or the Tailscale IP:** stop and fix + `DORMOUSE_BIND_HOST=127.0.0.1` before continuing. Tailscale access control is + not a reason to expose the plaintext backend. +- **Local origin changed:** do not overwrite the stored origin and continue. + Determine whether the Tailscale node was renamed/re-enrolled and plan passkey + and Host re-enrollment explicitly. +- **Pocket loads but passkey setup fails:** compare the browser URL byte-for-byte + with normalized `DORMOUSE_ORIGIN`; confirm HTTPS and the chosen node/Service + hostname. +- **Host cannot connect while Pocket can:** that Host build likely lacks the + `*.ts.net` `DORMOUSE_REMOTE_CONNECT_SRC` setting. +- **State disappears:** verify the absolute Application Support state path and + the installed config. Do not initialize a new account until old state has been + located or restored. + +## Future + +**Scope: always-on-relay** — run the coordinating server on a cloud host +instead of the laptop, so the relay stays reachable while the Mac is asleep, +shut down, or logged out. Nothing below is implemented. It matters only for a +user who controls a Host that is not this laptop; a laptop that must be awake +to be controlled gains little from an always-on relay. + +The design below carries its own origin: `https://dormouse.` +via a Tailscale Service, not the laptop's machine name. Moving from the local +install to this one changes `DORMOUSE_ORIGIN`, which means redoing passkey setup +and Host enrollment. + +### Architecture + +```text +push/merge to main + | + v +existing CI workflow succeeds + | + v +deploy-server workflow builds an amd64 Docker image on GitHub's runner + | + | short-lived OIDC identity; no reusable Tailscale or SSH key + v +ephemeral tag:dormouse-ci node --Tailscale SSH--> tag:dormouse-server Droplet + | + v + one Dormouse container + 127.0.0.1:3000 only + | + v + Tailscale Service HTTPS: svc:dormouse :443 + | + v + phone and Dormouse Host on the tailnet + +/var/lib/dormouse on the Droplet + account.json + hosts.json + push-subscriptions.json + vapid.json +``` + +### Definition of done + + +- The container is healthy, non-root, and read-only except for `/data`. +- A successful `CI` run for a new `main` SHA automatically deploys that exact + SHA. +- A failed CI run does not deploy. +- A failed container health check automatically restores the prior image and + makes the deployment workflow fail visibly. +- Tailscale SSH from `tag:dormouse-ci` can log in only to the deployment node as + `deploy`; it is not granted broad tailnet access. +- Public SSH is closed after Tailscale SSH has been tested. +- DigitalOcean backups are enabled, or the user has explicitly chosen and + tested a different off-Droplet backup for `/var/lib/dormouse`. +- `SECURITY.md` describes and audits the CI deployment path. + +### Preflight + +Inspect existing workflows, especially the exact workflow name in +`.github/workflows/ci.yml` (this runbook expects `CI`; use the actual name), and +whether Docker, `gh`, an SSH client, GitHub, and DigitalOcean are available. +Ask only for choices that cannot be derived: + +1. DigitalOcean region. +2. Whether to enable DigitalOcean Droplet backups. Recommend yes; it is a paid + option. +3. Which Tailscale user identity should retain interactive administrative SSH + access to the Droplet. + +Confirm that the user's phone runs Tailscale. A tailnet-only Pocket web app is +not reachable merely because the laptop is on the tailnet. + +### Build order + +Staged order for building this out. It is more operationally involved than the +local install and is not the recommendation for a single laptop that must +already be awake to be controlled. + +### Step 1: author the repository deployment artifacts + +Create and review the following files. Follow existing repository conventions +and do not commit yet: + +```text +.dockerignore +server/Dockerfile +deploy/digitalocean/compose.yml +deploy/digitalocean/deploy.sh +.github/workflows/deploy-server.yml +``` + +Also update: + +- `docs/specs/server.md`: promote the actual production self-host deployment + behavior above the fold, with `Source of truth:` pointers to these files. +- `SECURITY.md`: document the `dormouse-production` environment, its two + environment-scoped Tailscale WIF values, its `main`-only deployment policy, + the least-privilege tailnet path, and why the workflow has `id-token: write`. + Amend mechanical `FAIL IF` rules where necessary so this production path is + audited rather than merely described. +- `docs/specs/deploy.md` only if its exhaustive Files/Code Map or release scope + actually claims these files. Do not conflate self-host server deployment with + signed desktop releases. + +#### Dockerfile contract + +`server/Dockerfile` must: + +- Use a multi-stage Debian-based Node image pinned to the exact Node version in + root `package.json`; do not use `latest` or a bare major. +- Use the root repository as build context. +- Enable the exact pnpm version from root `packageManager` through Corepack. +- Install with `pnpm install --frozen-lockfile`. +- Run `pnpm --filter dormouse-lib build:pocket` and + `pnpm --filter server build`. +- Produce a production-only server tree. With the current pnpm workspace, + `pnpm --filter server deploy --prod --legacy /out/server` is required unless + the repository intentionally adopts injected workspace packages. Do not + silently change pnpm workspace semantics just for this image. +- Copy `lib/dist-pocket` into the runtime layout expected by + `server/src/config.ts`. +- Run as a fixed unprivileged UID/GID such as `10001:10001` and document that + the Droplet state directory must have matching ownership. +- Expose port 3000, include a health check against `/api/hello`, and start + `node dist/index.js`. +- Add the OCI source label and accept a build argument for the Git commit SHA. +- Contain no Tailscale client, setup password, auth key, source-control + credential, or build output copied from the developer's workstation. + +`.dockerignore` must at minimum exclude `.git`, all `node_modules`, all local +`dist`/build outputs, `.env*`, state/data directories, editor/agent metadata, +and native build targets. Check that it does not exclude source or package +manifests required by the build. + +#### Compose contract + +`deploy/digitalocean/compose.yml` must define one service and: + +- Use `${DORMOUSE_IMAGE:?DORMOUSE_IMAGE is required}` as its image. +- Use a stable container name such as `dormouse-server`. +- Load `/etc/dormouse/server.env`. +- Bind `/var/lib/dormouse:/data`. +- Publish `127.0.0.1:3000:3000`, never `3000:3000`. +- Set `restart: unless-stopped`, `init: true`, a reasonable stop grace period, + bounded local log rotation, and use the image health check. +- Set a read-only root filesystem, a small `/tmp` tmpfs, drop all Linux + capabilities, and set `no-new-privileges` unless testing proves a specific + relaxation is required. +- Declare no database and no second server replica. + +#### Deployment script contract + +`deploy/digitalocean/deploy.sh` must be a strict Bash script and must be tested, +not sketched. It must: + +- Accept only a local image reference of the form + `dormouse-server:<40-hex-main-sha>`. +- Serialize deployments with `flock`. +- Verify the image exists and its OCI revision label equals the requested SHA. +- Validate the candidate Compose configuration before changing the live one. +- Atomically record the candidate image in a non-secret env file under + `/opt/dormouse`. +- Run `docker compose up -d --wait` with a bounded timeout, then independently + request `http://127.0.0.1:3000/api/hello`. +- On failure, restore the previous image/configuration, wait for it to become + healthy, and exit nonzero. Never report success merely because rollback + succeeded. +- On success, record current and previous image references and remove older + `dormouse-server:` images while retaining those two for rollback. +- Provide a documented manual rollback invocation using the recorded previous + image. +- Never read, print, copy, rewrite, or back up the setup password. + +If the workflow also updates `compose.yml` or `deploy.sh`, stage them in a +SHA-specific incoming directory, syntax-check and Compose-validate them, and +retain the last working copies. Do not replace the working deployment controls +before validation. A simpler acceptable initial implementation is to require +manual reinstallation of changed deployment-control files, but then state that +limitation clearly; changes to application code and `server/Dockerfile` must +still autodeploy. + +#### GitHub workflow contract + +`.github/workflows/deploy-server.yml` must: + +- Trigger from `workflow_run` only after the existing main-branch `CI` workflow + completes successfully, plus `workflow_dispatch` for recovery/testing. +- Check out exactly `github.event.workflow_run.head_sha` for a workflow-run + event, and `github.sha` for manual dispatch. Never build an implicit moving + branch tip. +- Use `environment: dormouse-production`. +- Grant only `contents: read` and `id-token: write`. In particular, do not add + `packages: write`, `actions: write`, or repository write access. +- Use a single production concurrency group. Do not cancel a deployment while + it may be replacing the container. Immediately before remote activation, + detect a stale SHA relative to `origin/main` and skip it; if main advances + just after that check, allow the newer queued run to deploy afterward. +- Build `linux/amd64` from `server/Dockerfile`, tag it + `dormouse-server:`, and set its OCI revision label. +- Compress `docker save` output and stream it over `tailscale ssh` to + `deploy@dormouse-relay`, where it is decompressed into `docker load`. Do not + publish it to a registry or upload it as a long-lived Actions artifact. +- Join the tailnet using the official Tailscale GitHub Action, pinned to a full + commit SHA, workload identity federation, `tag:dormouse-ci`, and the `ping` + input for `dormouse-relay`. +- Invoke `/opt/dormouse/deploy.sh` with the immutable SHA-tagged image. +- Apply sensible timeouts and ensure secret values never appear in logs. + +Use shell quoting that treats all GitHub context values as data passed through +environment variables, not interpolated shell source. Remember that a commit +on `main` can already alter this workflow and execute commands on the deployment +node; branch protection and the production environment are part of the trust +boundary. + +#### Local verification + +Before cloud changes: + +1. Build the image for `linux/amd64` if the local Docker installation supports + it. If emulation is unavailable, let the GitHub runner perform the first full + build, but still lint the Dockerfile. +2. Start it with a temporary state directory and temporary generated setup + password, bound to an unused loopback port. +3. Verify `/api/hello`, the Pocket index, health status, non-root UID, read-only + root, and writes under `/data`. +4. Run the server tests and the repository's spec/security lint relevant to the + changed files. +5. Run `pnpm lint:specs`, plus the proportional package tests. Run the full + `pnpm test` if practical before proposing a commit. + +### Step 2: create the protected GitHub environment + +In GitHub repository settings, create the environment +`dormouse-production`: + +- Configure deployment branches/tags to allow only `main`. +- Do not add required reviewers because the requested behavior is automatic + deployment after CI. Explain this tradeoff to the user. +- Do not put the Dormouse setup password, a DigitalOcean token, an SSH private + key, or a reusable Tailscale auth key in GitHub. + +The environment will later contain: + +- `TS_OAUTH_CLIENT_ID` +- `TS_AUDIENCE` + +They are the Client ID and Audience of a narrowly scoped Tailscale federated +identity. Tailscale documents that these values are not secrets, but environment +scope ensures the deployment workflow and its OIDC subject remain coupled. + +Verify the environment's branch policy through the GitHub UI or API. Re-read +the environment rules in `SECURITY.md` and update them if the new environment +would otherwise fail the repository's security audit. + +### Step 3: configure Tailscale policy, Service, and CI identity + +Use the Tailscale admin console and preserve the existing policy. + +#### Tags and least-privilege policy + +Create `tag:dormouse-server` and `tag:dormouse-ci`. Make the user's chosen +Tailscale admin identity their owner. Merge policy entries equivalent to: + +```jsonc +{ + "tagOwners": { + "tag:dormouse-server": [""], + "tag:dormouse-ci": [""], + }, + + "autoApprovers": { + "services": { + "svc:dormouse": ["tag:dormouse-server"], + }, + }, + + "grants": [ + // Tailnet members can use the Pocket/relay HTTPS endpoint. + { + "src": ["autogroup:member"], + "dst": ["svc:dormouse"], + "ip": ["tcp:443"], + }, + + // The ephemeral CI node can reach only SSH on the deployment node. + { + "src": ["tag:dormouse-ci"], + "dst": ["tag:dormouse-server"], + "ip": ["tcp:22"], + }, + + // Keep one human recovery path. Narrow this to the chosen identity. + { + "src": [""], + "dst": ["tag:dormouse-server"], + "ip": ["tcp:22"], + }, + ], + + "ssh": [ + { + "action": "accept", + "src": ["tag:dormouse-ci"], + "dst": ["tag:dormouse-server"], + "users": ["deploy"], + }, + { + "action": "check", + "src": [""], + "dst": ["tag:dormouse-server"], + "users": ["deploy"], + "checkPeriod": "1h", + }, + ], +} +``` + +This is an entry-level example, not a complete replacement policy. Adapt it if +the tailnet uses groups or policy tests. Add tests proving that: + +- `tag:dormouse-ci` reaches `tag:dormouse-server:22`. +- `tag:dormouse-ci` does not reach the server tag on other ports or unrelated + tagged nodes. +- The intended user reaches `svc:dormouse:443`. + +#### Define the Service + +On the Tailscale **Services** page, define: + +- Name: `dormouse` (`svc:dormouse` in policy). +- Endpoint: `tcp:443`. +- Description: the private Dormouse Pocket/relay endpoint. + +Record its MagicDNS hostname and set the worksheet origin to exactly: + +```text +https://dormouse. +``` + +Do not use the Droplet machine name in `DORMOUSE_ORIGIN`. + +#### Create the GitHub workload identity + +In Tailscale **Trust credentials**, create an OpenID Connect federated +credential: + +- Issuer: GitHub Actions (`https://token.actions.githubusercontent.com`). +- Subject: restrict it to this repository's `dormouse-production` environment. + For the repository's current default GitHub OIDC format this is expected to + be `repo:/:environment:dormouse-production`. +- If GitHub immutable OIDC subjects have been enabled, use the actual + owner-ID/repository-ID form instead. Inspect the repository OIDC settings or + a safely decoded token claim; do not guess and do not print the signed token. +- Scope: only `auth_keys`. +- Allowed tag: only `tag:dormouse-ci`. +- Description: `Dormouse production deploy from GitHub Actions`. + +Copy the resulting Client ID and Audience directly into the two +`dormouse-production` GitHub environment values. Do not paste them into chat. + +The subject restriction matters more than the confidentiality of these values: +a feature-branch workflow must not be able to exchange its OIDC token for a +tailnet node. + +### Step 4: provision the DigitalOcean Droplet + +Use a regular Ubuntu 24.04 LTS Droplet, not DigitalOcean App Platform or a +one-click application image. + +Recommended starting shape: + +- Basic shared CPU, amd64. +- At least 1 GiB RAM; 2 GiB is the conservative choice. Builds occur in GitHub, + so the Droplet only runs Docker, Tailscale, and one Node process. +- A region near the user. +- SSH-key authentication, not a root password. +- Monitoring enabled. +- Droplet backups enabled if the user approved the cost. +- No block volume is necessary for two tiny JSON files. + +Create a DigitalOcean Cloud Firewall attached to the Droplet: + +- Initially allow TCP 22 only from the user's current public IP, as a bootstrap + path. +- Allow UDP 41641 from IPv4/IPv6 if the user wants better odds of direct + Tailscale connectivity. Tailscale can still use DERP without it. +- Allow normal outbound traffic required for Ubuntu, Docker image transfer, + Tailscale, DNS, and time synchronization. +- Do not allow inbound TCP 80, 443, 3000, or unrestricted 22. + +After creation, connect over the temporary public SSH rule and: + +1. Apply Ubuntu security updates. +2. Install Docker Engine and the Compose plugin from Docker's current official + Ubuntu repository; do not use an unreviewed convenience image. +3. Install the current stable Tailscale client from Tailscale's official Ubuntu + repository. +4. Create local user `deploy` with no password and a normal shell. Add it to the + `docker` group. Treat Docker-group membership as root-equivalent. +5. Create `/opt/dormouse`, owned by `deploy`. +6. Create a system group that can read `/etc/dormouse/server.env`, add `deploy` + to it, and keep that file mode `0640`. Docker Compose must be able to read the + env file. Do not make it world-readable. +7. Create `/var/lib/dormouse` mode `0700`, owned by the fixed runtime UID/GID + from `server/Dockerfile`. + +Generate a one-off, pre-approved Tailscale auth key carrying only +`tag:dormouse-server`. Have the user enter it into a hidden shell variable on +the Droplet, then run Tailscale with hostname `dormouse-relay` and Tailscale SSH +enabled. Unset the variable immediately and confirm it did not enter shell +history. Prefer a one-off key over a reusable key. + +Verify: + +```sh +tailscale status +tailscale set --ssh +docker version +docker compose version +id deploy +``` + +From the user's tailnet-connected workstation, verify an interactive +`tailscale ssh deploy@dormouse-relay` succeeds and that Tailscale checked the +host key. Only after that succeeds, remove public TCP 22 from the DigitalOcean +Cloud Firewall and test Tailscale SSH again. Keep DigitalOcean's Recovery +Console as the break-glass path. + +### Step 5: install runtime configuration and deployment controls + +On the Droplet, generate the setup password locally with a cryptographically +secure generator; do not transport it through chat or GitHub. Write: + +```dotenv +DORMOUSE_SETUP_PASSWORD= +DORMOUSE_ORIGIN=https://dormouse. +DORMOUSE_STATE_DIR=/data +PORT=3000 +NODE_ENV=production +``` + +to `/etc/dormouse/server.env` with the ownership and `0640` mode established +above. Avoid commands that expose the value in process listings or shell +history. The user will need to retrieve this password locally for initial +passkey setup and Host enrollment; show it only in their terminal when needed. + +Install the reviewed `compose.yml` and executable `deploy.sh` under +`/opt/dormouse`. Validate as `deploy`: + +```sh +bash -n /opt/dormouse/deploy.sh +docker compose --env-file /opt/dormouse/deploy.env \ + -f /opt/dormouse/compose.yml config +``` + +For pre-deploy validation, use a syntactically valid placeholder image in +`deploy.env`; do not start the service before an image has been transferred. + +### Step 6: enable the first automatic deployment + +Show the user the full repository diff, including the security boundary and +workflow permissions. Run the required tests. Then ask whether they want you to +commit and push/open a PR according to their normal workflow. + +When the deployment files reach `main`: + +1. The existing `CI` workflow must finish successfully. +2. The deployment workflow must obtain an environment-scoped GitHub OIDC token. +3. Tailscale must exchange it for an ephemeral `tag:dormouse-ci` node. +4. The job must build and stream `dormouse-server:`. +5. The Droplet must load it, start one healthy container, and report the same + revision. + +If the first `workflow_run` does not fire because the deployment workflow was +introduced by that same commit, use its `workflow_dispatch` trigger once. Do +not weaken the event or environment restrictions. + +Inspect the Actions log and the Droplet together. Verify the running image's +OCI revision label equals the intended `main` SHA. + +Once port 3000 is healthy on loopback, configure and advertise the stable +Tailscale Service on the Droplet using the current equivalent of: + +```sh +sudo tailscale serve --service=svc:dormouse --https=443 3000 +``` + +Approve the HTTPS/Service prompt in the Tailscale admin console if required. +Confirm the Service is advertised by `tag:dormouse-server`, not by the CI node, +and inspect `tailscale serve status` / `tailscale serve get-config --all`. + +### Step 7: end-to-end validation + +From a tailnet-connected device: + +1. Request `https://dormouse./api/hello`; expect HTTP 200 and the + documented JSON response. +2. Open `https://dormouse./`; expect the Pocket application, not the + missing-build fallback message. +3. Confirm the certificate and hostname match the exact `DORMOUSE_ORIGIN`. +4. Temporarily disconnect a test device from Tailscale and verify the origin is + no longer reachable. Do not disable Tailscale on the deployment node. + +On the Droplet: + +```sh +docker ps --filter name=dormouse-server +docker inspect dormouse-server +ss -lntp +sudo tailscale serve status +sudo ls -la /var/lib/dormouse +``` + +Check specifically that: + +- Docker reports `healthy`. +- The process UID is the fixed non-root UID. +- The root filesystem is read-only. +- Host port 3000 listens only on `127.0.0.1`. +- There is exactly one server container. + +Complete initial Pocket setup, then enroll a custom self-host standalone or VS +Code build. After `account.json`, `hosts.json`, and `vapid.json` exist (and +`push-subscriptions.json` too if push was enabled): + +1. Record ownership and checksums of every present state file without printing + contents. +2. Manually dispatch the deployment workflow or restart/replace the container. +3. Verify the files and registered passkey/Host survive. +4. Establish a real Host and Pocket WebSocket session through the Service. + +This is the point at which HTTPS proxying, WebSocket upgrade handling, and the +application security flow have actually been tested together. + +### Step 8: prove deploy and rollback behavior + +The first merge containing the deployment workflow normally proves automatic +deployment from `main`. Also test these cases without manufacturing meaningless +production commits: + +- Use `workflow_dispatch` to prove an idempotent redeploy. +- If a real subsequent server change is available, observe that its successful + main CI run deploys the exact new SHA. +- Exercise rollback deliberately with a temporary image whose health check + fails, or test the deployment script against an isolated Compose project on + the Droplet. Do not intentionally break the live Pocket origin without the + user's approval. +- Confirm that after a failed candidate, the old image is healthy, the workflow + is red, and current/previous metadata is truthful. +- Run the documented manual rollback command and then restore the desired + current image. + +Because server restarts drop in-memory sessions and WebSockets, verify that the +Host reconnects and that Pocket can reconnect after a normal deploy. A few +seconds of reconnect time is expected; pretending this deployment is +zero-downtime is not. + +### Step 9: backup and recovery + +Verify DigitalOcean backups are active if selected. Explain that a Droplet +backup is the off-instance durability layer for `/var/lib/dormouse`; Docker's +bind mount only protects the data from container replacement. + +If the user declines Droplet backups, configure a concrete encrypted backup of +`/var/lib/dormouse` to storage outside the Droplet and perform a restore test. +Do not call a second directory on the same Droplet a backup. These state files +include Host bearer credentials and a VAPID private key, so protect backup +access accordingly. + +Document recovery: + +1. Provision a replacement tagged Droplet. +2. Restore `/var/lib/dormouse` with the fixed runtime UID/GID and mode. +3. Restore `/etc/dormouse/server.env`, preserving the exact + `DORMOUSE_ORIGIN` and setup password. +4. Deploy a known-good image. +5. Drain `svc:dormouse` from the old host if it is still present. +6. Advertise `svc:dormouse` from the replacement. +7. Verify HTTPS, WebSockets, passkey sign-in, and Host enrollment state. + +The Service hostname remains stable; no WebAuthn origin migration should be +needed. + +### Handoff + +Include the Droplet name and DigitalOcean region (not its public IP +unless useful), how to view container and deployment workflow logs, and the +automatic deployment trigger. Do not describe the laptop LaunchAgent as if it +were installed. + +### References + +- [Create a DigitalOcean Droplet](https://docs.digitalocean.com/products/droplets/how-to/create/) +- [DigitalOcean Cloud Firewall rules](https://docs.digitalocean.com/products/networking/firewalls/how-to/configure-rules/) +- [DigitalOcean Droplet backups](https://docs.digitalocean.com/products/backups/how-to/create-and-restore/) +- [Tailscale GitHub Action](https://tailscale.com/docs/integrations/github/github-action) +- [Tailscale workload identity federation](https://tailscale.com/docs/features/workload-identity-federation) +- [GitHub Actions OIDC reference](https://docs.github.com/en/actions/reference/security/oidc) +- [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh) +- [Tailscale Services](https://tailscale.com/kb/1552/tailscale-services) +- [Install Tailscale on Linux](https://tailscale.com/docs/install/linux) +- [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) + +### Troubleshooting boundaries + +- **The CI node joins Tailscale but SSH fails:** inspect both the `grants` entry + for TCP 22 and the separate `ssh` rule. Tagged-node-to-tagged-node automation + must use `action: accept`, not interactive check mode. +- **OIDC exchange fails:** compare the actual GitHub `sub`, `aud`, repository, + environment, and workflow claims with the Tailscale trust credential. Do not + broaden the subject to every branch as a shortcut. +- **Service returns 502/connection refused:** verify the container health and + loopback binding first, then inspect the Service host's Serve configuration. +- **Deploy succeeds but old code runs:** compare the requested SHA, image OCI + label, Compose image value, and running container image ID. Never rely on a + mutable `latest` tag. +- **State disappears:** verify the host bind mount and `DORMOUSE_STATE_DIR=/data`. + Do not initialize a new account until old state has been located or restored. +- **Workflow violates the security audit:** do not suppress the audit. Rework + permissions and secret placement to satisfy `SECURITY.md`, and update its + explicit invariants where the new production path legitimately expands them. diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 4b8e49af..3fdf9676 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -168,19 +168,21 @@ When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelay ### Push notifications -When a Session transitions into `ALERT_RINGING` and is still ringing `pushDelayMs` later, Dormouse sends that Pane's name to every paired phone that has enabled alerts. Source of truth: `lib/src/remote/host/alert-push.ts`, armed by `activateRemoteHost` alongside the remote Host. Desktop shell only, and only where a Host runs — a build with no enrollment has nowhere to push. Living under `remote/host/` keeps the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere. +When a Session transitions into `ALERT_RINGING` and is still ringing `pushDelayMs` later, Dormouse sends that Pane's name to every paired phone that has enabled alerts. Desktop shell only, and only where a Host runs — a build with no enrollment has nowhere to push. + +**The two halves run in different processes.** Ring *detection* is webview state — the activity store, the alarm settings, the Pane's derived label — so `watchPushRings` (`lib/src/remote/host/alert-push.ts`) stays in the webview and fires one `push { sessionId, title }` command at the Host service. *Delivery* needs the enrollment and the ACL, which only the Host holds, so `sendPush` (`lib/src/remote/host/push-delivery.ts`) runs in the service's process and touches no DOM or store. **A webview cannot choose recipients:** it names the Session and what to call it, and the service reads its own active ACL at send time. Watching is armed only while the service reports an enrollment (`enrolled-gate.ts`), so a machine that never enrolls pays no activity-store subscription; a `push` that arrives with no Host running is simply not sent, since there is no ACL to read and nothing the webview could do about it. Both halves live under `remote/host/` to keep the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere. Push and speech are independent: both fire when both are on, each on its own delay. - **The trigger is shared with spoken alarms**, not reimplemented: `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts` owns fresh-ring detection, the delay, the fire-time re-check, and every cancellation rule, with speech and push as two sinks over it. A Session observed for the first time *already* ringing never pushes, which is what keeps a restored session blob from buzzing the phone at every app launch. - **The derived Pane label is the payload**, on the same rule as speech: the ringing `ActivityNotification`'s title/body is not selected as the payload, but terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text can appear when it is the winning Pane label. The body is a fixed string; the Pane name carries the information. - **The label is sanitized by `toPushText` — the sink's cap and fallback over the shared `boundedPushText` — which is deliberately not `toSpokenText`.** The rule keeps angle brackets — the speech restriction exists only because WebKit's synthesizer wedges on them — and instead strips control characters and the Unicode bidi and zero-width format characters (including the Arabic letter mark), which can visually reorder or hide text in an OS notification; the cap counts code points, so a cut never ships half a surrogate pair. `boundedPushText` lives in `server-lib-common/src/security/push.ts` so the Host and the Server run the *same* rule rather than a strong copy and a weak one; `lib/pocket/public/sw.js` mirrors it a third time at the render sink, being a verbatim-copied file that can import nothing. -- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host deliberately does **not** ask which devices are subscribed first: the Server applies that filter anyway, so the target set is identical and the alarm costs one round trip instead of two. +- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read from the running Host at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host deliberately does **not** ask which devices are subscribed first: the Server applies that filter anyway, so the target set is identical and the alarm costs one round trip instead of two. - **One notification per Session at a time.** Each push carries the Session id as a collapse tag, so a Pane that rings, is cleared, and rings again replaces its own notification rather than stacking copies on the lock screen. - **Attending before `pushDelayMs` cancels**, matching speech. A push already delivered is *not* recalled: reaching the phone again means sending a second push, and `userVisibleOnly` guarantees that would itself be visible — so recall would trade one stale notification for one confusing one. - Delivery is an HTTP POST to the Server, not a relay frame ([server.md](./server.md) -> Web Push). The relay routes between two live sockets; a push exists to reach a phone whose app is closed. - A failed send warns and is dropped. That covers both failure classes: a non-2xx response is checked rather than ignored so a revoked host token cannot leave push permanently broken and silent, and a 2xx whose counts report `failed > 0` or `delivered: 0` warns too — the Server answers 200 even when a push service refused every delivery, folding the outcome into the `PushSendResponse` counts (and logging the refusal server-side). There is nothing useful to retry against: by the next ring the alarm is already stale. -- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`). A phone can enable alerts long after this machine booted, so a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. Refresh writes are both Host-generation-fenced and latest-request-wins: a request still in flight when the Host stops (or is replaced by re-enrollment) cannot overwrite `no-host`, and a slow startup request cannot overwrite a newer dialog refresh. +- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`). A phone can enable alerts long after this machine booted, so a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. The list is the Host's join of the Server's subscriptions against its own ACL labels, so it comes back over the same bridge as a `pushDevices` command and answers `null` — rendered `no-host` — when no Host is running. Writes are latest-request-wins, fenced on request order, so a slow startup refresh cannot overwrite a newer dialog refresh. The same fence carries "the Host went away": when the enrolled gate disarms it calls `invalidatePushDeviceRefreshes()` and `clearPushDevices()`, so a request already on the wire cannot resolve afterwards and repopulate the dialog with phones there is no longer anything to push to. `clearPushDevices` returns the store to `no-host` and *keeps* the refresher, which stays installed on an un-enrolled machine so the dialog can still ask and be told `no-host`; `resetPushDevices` drops the refresher too and is full teardown (a Storybook story, a test). ### Settings dialog @@ -265,7 +267,9 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/lib/alert-ring-watch.ts` | The shared unattended-ring machine: fresh-ring detection, the delay, the re-check, cancellation | | `lib/src/lib/alert-speech.ts` | The speech sink and `toSpokenText` | | `lib/src/lib/alert-speech-state.ts` | Transient per-Session `speaking` / `spoken` delivery state | -| `lib/src/remote/host/alert-push.ts` | The push sink, `toPushText`, and the ACL-intersected target list | +| `lib/src/remote/host/alert-push.ts` | Webview half: `watchPushRings` ring detection, `toPushText`, and the device-list commit | +| `lib/src/remote/host/push-delivery.ts` | Service half: `sendPush` / `loadPushDevices`, the ACL-intersected recipients, and `boundedPushText` | +| `lib/src/remote/host/enrolled-gate.ts` | `armWhileEnrolled`: the edge-triggered gate that arms ring watching only while the service reports an enrollment | | `lib/src/remote/host/activation.ts` | Arms the push sink for the lifetime of the remote Host (start, stop, re-enroll) | | `lib/src/lib/push-devices.ts` | Renderer-only store of the devices a push would reach, read by the settings dialog | | `lib/src/lib/session-label.ts` | `deriveSessionLabel`: the id-keyed Surface label over the live stores | diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 183735d8..e3848637 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -53,6 +53,30 @@ Everything else — including browser-surface remoting — is staged in optional field — so nothing in the shipped protocol changes shape when it lands. +### Where the Host runs + +The Host is a **Node-side service in the process that owns the PTYs**, never a +webview: `RemoteHostService` in `lib/src/host/remote/service.ts`, installed in +the Tauri sidecar (`docs/specs/standalone.md`) and in the VS Code extension host +(`docs/specs/vscode.md`). It holds everything an access decision depends on — +the relay socket, the enrollment, the ACL, the pairing ceremony — so nothing a +webview says can widen access (`docs/specs/remote-security-model.md`). + +`RemoteApiSession` speaks this protocol and nothing else: surface ids, PTY ids, +sizes, and bytes. *Where* a named surface lives — this window's webviews, +another window's, another process's — is a deployment fact rather than a +protocol concept, so every environment-specific answer sits behind +`HostSurfaceProvider` (`lib/src/remote/host/host-surface-provider.ts`): +`collectDirectory` / `watchDirectory`, `resolveSurface` returning a +`SurfaceHandle`, and `writePty` / `resizePty` / `streamPty`. The session +therefore imports no platform adapter, no store, and no `document`, and both +installations share the ask-backed half of the provider +(`lib/src/host/remote/ask-surface-provider.ts`) so an attach cannot be answered +differently in one host than the other. `SurfaceHandle.ptyId` is a +provider-local routing key, not necessarily the PTY process's own id; the VS +Code provider uses an opaque per-peer key so a cold-restored id collision cannot +move an attachment's stream or input to another window. + ## Terminology `docs/specs/glossary.md` is canonical for **Pane** and **Surface**; the wire @@ -164,6 +188,25 @@ any change the Host coalesces (150ms window, `DIRECTORY_DEBOUNCE_MS`) and resends the whole thing. Delta events are a future optimization there is no current reason to pay for. +**One snapshot per collect.** The provider answers for every surface the Host +can reach, so there is no subset that is known sooner than the rest and the +session emits exactly one `directory.snapshot` per collect. A collect that +finishes after its subscription was replaced or torn down is dropped rather than +sent, and so is one that is no longer the newest: collects overlap whenever +something changes during a slow round trip and can settle in either order, so a +per-collect generation (the same shape as the per-attach one) keeps a stale +answer — including one that timed out to an empty list — from landing on top of +a fresh snapshot and blanking the picker until the next change. +A provider collection that rejects emits nothing and leaves the last good +snapshot standing. The rejection is contained inside the session, and the next +invalidation or `directory.watch` retries the collection. + +Invalidation reaches the session through `watchDirectory`: webviews announce +that their pane state, activity, or focus changed, and membership changes (a +webview attaching or disposing, a peer window joining or dropping) invalidate +unconditionally. Both feed the same coalescer, which re-collects from every +answerer before sending the replacement snapshot. + The picker renders from titles, activity, and the `ringing`/`hasTODO` badges; thumbnails are staged (see [Future](#future)). Browser panes are not listed; iframe surfaces additionally refuse attachment by design (see @@ -256,6 +299,32 @@ 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. +Disposing the Viewer, and any newer `surface.attach`, invalidate an in-flight +peer surface resolution: a handle that resolves late is released immediately and +never becomes an attachment. That is what keeps last-attach-wins true when the +two resolutions take different lengths of time — a sibling window's pane is a +round trip away while a local one resolves immediately, so without it the older, +slower attach would land last and take the attachment. A superseded attach is +answered with an error rather than left pending, since the client holds a +request open until it is answered; a disposed session has no transport left to +answer on. +Provider resolution and resize are asynchronous process/window boundaries. +An attach is not acknowledged until its required resize settles; rejected +surface resolution, attach resize, and `terminal.resize` are returned as +protocol errors and are contained inside the session rather than becoming +unhandled Host-process rejections. The stream is subscribed before that resize +settles. Subscription also observes liveness atomically: each production +provider replays a recorded exit when the PTY died while `resolveSurface` was in +flight, before the session had its sink. Local providers do that synchronously; +a VS Code peer sends a subscription acknowledgement after installing the sink +and checking liveness, on the same ordered socket and after any replay. The +session waits for that readiness before resizing or acknowledging. Either kind +of exit therefore tears the attachment down first, so the attach is answered +`surface closed while attaching` and the buffered `terminal.closed` is dropped +rather than flushed, since the client is never given the subscription it would +arrive on. Source of truth: `HostSurfaceProvider.streamPty`, +`RemoteApiSession.#beginAttach`, and the peer `subscribe` / `subscribed` frames +in `vscode-ext/src/peer-link.ts`. #### Size authority: last-attach-wins diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 154ddd75..01790765 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -144,8 +144,9 @@ Each Host maintains a local authorization list. **The ACL is authoritative**; the Server cannot unilaterally grant access. The record schema (source of truth: `HostAclRecord` / `HostAcl` in -`server-lib-common/src/security/acl.ts`; persisted on the Host in webview -`localStorage` via `lib/src/lib/local-json-store.ts`, `docs/specs/server.md`): +`server-lib-common/src/security/acl.ts`; persisted by the Host service through +its `HostStateStore` — a 0600 file in standalone, `globalState` in VS Code — +never in a webview realm, `docs/specs/server.md`): ```ts interface HostAclRecord { @@ -180,6 +181,13 @@ side); the user approves locally on the Host; the Host writes the `HostAclRecord` binding the passkey credential identity to the device public key. The Client is now trusted by that Host and no other. +Each displayed approval is bound to the ceremony ticket's immutable +`pairingId`. If a Client replaces its pending request while the old modal or +its click command is still in flight, the Host rejects that stale action; it +never selects a request by mutable `clientId` alone. Source of truth: +`RemoteHostService.#pendingPairing` in `lib/src/host/remote/service.ts` and the +service/webview contract in `lib/src/host/remote/service-protocol.ts`. + **Presence for pairing is server-attested plus Host-approved.** The Server relays a pairing request only while the session's last server-verified passkey assertion is within `PAIRING_PRESENCE_WINDOW_MS` (30 seconds; @@ -217,6 +225,15 @@ device signature, and the ACL against the Host's `ConnectionPolicy` Server claims to have already checked. Host challenges are 32-byte, single-use, TTL-bounded values from `HostChallengeIssuer` (`server-lib-common/src/security/challenge.ts`, default 2-minute TTL). +Every new `connect` / `connect2` closes that Client's established message gate +and disposes its prior control session before this evaluation, and only the +newest evaluation may re-open that gate: each attempt carries an authorization +generation, and one that has been superseded while it awaited verification sends +no decision at all — otherwise an older `allowed` landing last would re-open the +gate its successor had just closed. A structurally malformed request from the +relay is contained as a denied decision rather than an async failure in the Node +Host process. Source of truth: `RemoteHost.#onConnect` / `RemoteHost.#onConnect2` +in `lib/src/remote/host/remote-host.ts`. One host challenge feeds both the passkey assertion and the device-key signature, so connecting costs the user a single biometric prompt per diff --git a/docs/specs/server.md b/docs/specs/server.md index c3935f6d..5cc375a8 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -33,7 +33,8 @@ UI lives in `lib`/`standalone`. | `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. | +| `PORT` | Default 3000. Blank is unset — `Number('')` is 0, which would ask the OS for an ephemeral port and move the server out from under whatever proxy is pointed at it. An explicit `PORT=0` is a `ConfigError` for the same reason: nothing can be pointed at a port that changes every restart. | +| `DORMOUSE_BIND_HOST` | Interface to listen on. Unset binds every interface (what a container wants); set `127.0.0.1` when a TLS proxy on the same machine is the front door. | | `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair. Set both or neither. At startup the Server decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, the server mints a pair on first boot and persists it to `vapid.json`. | | `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292). Defaults to `DORMOUSE_ORIGIN` when that origin is https and not loopback; otherwise there is no default and push stays off. The Server parses and validates it at startup and exits on an invalid value — including a loopback contact, which Apple rejects. | @@ -42,24 +43,97 @@ 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. +Because the server always speaks plain HTTP, the listen interface is a security +boundary whenever the TLS proxy is local: `tailscale serve` reaches the app over +loopback, so leaving the socket on every interface would also publish the +plaintext port to the LAN and to the tailnet itself. `DORMOUSE_BIND_HOST` exists +to close that, and the selfhost install sets it. The default stays unbound so a +container — where the namespace is the boundary and the port is published +explicitly — keeps working unchanged. + +Source of truth: `server/src/config.ts` (`readConfig`) maps the environment to +the entrypoint's config and is unit-tested in `server/test/config.test.mjs`; +`server/test/bind-host.test.mjs` spawns the real entrypoint and asserts the +plaintext port is unreachable off-loopback when `DORMOUSE_BIND_HOST=127.0.0.1`. +`readConfig` also reads the `DORMOUSE_VAPID_*` vars — the both-or-neither +keypair rule as a `ConfigError`, and the subject with its origin-derived +fallback — because that part is a pure mapping like the rest. What stays in +`server/src/index.ts` is only the half that touches disk: with no keypair +configured it mints one and persists `vapid.json`, then validates the pair and +the subject before building the app. + `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. -## 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. +## Where a Host may reach a relay server (self-host builds) + +> Code comments and older specs call this section "Host webview CSP", from when +> the allowlist was a webview CSP directive. + +Neither Host renders the relay socket in a webview any more: standalone's runs +in the Node sidecar and VS Code's in the extension host, so no CSP fences either +of them. The same CSP-shaped source list is therefore **baked into the Node +bundle** and enforced there — one syntax, one build-time variable +(`DORMOUSE_REMOTE_CONNECT_SRC`), whichever process ends up holding the socket. +The webview CSPs carry no relay sources at all (`docs/specs/vscode.md` → "CSP +policy"; `standalone/scripts/tauri-conf.test.mjs` asserts the standalone one). + +Both bundles default to the SaaS origin only and take the same override: + +```sh +DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone +DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode +``` + +`scripts/csp-defaults.mjs` holds the one definition of the default and the +override rule; `standalone/scripts/build-sidecar-proxy.mjs` and +`vscode-ext/scripts/esbuild.mjs` each esbuild-`define` it into their bundle, and +`assertConnectSrcBaked` fails the build if the define did not reach it — a lost +define compiles fine and would only show up as a Host silently using the shipped +default instead of the selfhoster's origins. `bakedConnectSrc()` in +`lib/src/host/remote/connect-src.ts` is the single place the value is read. + +`resolveRemoteConnectSrc` also **fails the build on an override the matcher +could never read** — a trailing slash, a path, a bare host with no scheme, a +scheme outside `http`/`https`/`ws`/`wss`, or a numeric port outside 1–65535. +Numeric ports are canonicalized the same way as `URL` (so leading zeroes do not +turn a valid source into a silent miss). The grammar is one regex duplicated +into the build script, since an `.mjs` build script cannot import TypeScript; +`connect-src.test.ts` asserts the two pattern strings are identical, the same +way it pins the two copies of the default. + +**Enforcement is `originAllowedByConnectSrc`, at two points:** the service +refuses `enroll` for an origin outside the list — before the setup password +leaves the machine — and refuses to *start* from a persisted enrollment naming +one, staying idle with a warning rather than connecting (a binary downgraded +from a custom build, or a server that moved). Matching is deliberately narrower +than a browser's: `https`/`wss` are one scheme class and `http`/`ws` the other, +host matches exactly or by a leading `*.` wildcard covering any depth of +sub-domain but never the bare domain, ports must match unless the source says +`*`, and anything unparseable fails closed. Enrollment and Host-authenticated +push fetches use `redirect: 'error'`: unlike the former webview CSP, a Node +process does not re-check a redirect target, so following one could carry the +setup password, Host bearer token, or notification metadata outside the baked +allowlist. + +The shipped binary is scoped to the SaaS origin only +(`https://*.dormouse.sh wss://*.dormouse.sh`). A self-host server on a different +origin is therefore reached only by a custom build: set +`DORMOUSE_REMOTE_CONNECT_SRC` when building (e.g. +`pnpm --filter dormouse-standalone tauri build`) to the sources for your server, +such as `https://dormouse.example.com wss://dormouse.example.com` or a tailnet +wildcard `https://*.ts.net wss://*.ts.net`. It **replaces** the default SaaS +sources rather than adding to them. The default is deliberately not +internet-wide — widening it is an explicit, per-build opt-in. + +The default carries **no localhost entry**, and `http`/`ws` are a different +scheme class from `https`/`wss`, so a Host built with the default refuses to +enroll against a plaintext `http://localhost:3000` dev server — see +"Running it" for the override a local loop needs. (This is narrower than the old +webview CSP, which allowed localhost for the app's own loopback proxies; that +allowance is still in the webview CSP, where it is about the agent-browser and +iframe proxies rather than about relays.) Reserved: the `https://*.dormouse.sh wss://*.dormouse.sh` entries are *wildcards* on purpose. The BYOT posture (`## Future`, Scope: saas-multitenant) @@ -82,8 +156,9 @@ $DORMOUSE_STATE_DIR/ ``` That is the entire persistent state. The Host's ACL is not here — it lives on -the Host, in webview `localStorage` (`lib/src/lib/local-json-store.ts`), -which is the whole point of the security model. +the Host, in the process that owns the PTYs +(`lib/src/host/remote/host-state-store.ts`), which is the whole point of the +security model. `push-subscriptions.json` is the one store that deletes rather than appends: a push service reports a dead subscription with 404/410, and a browser that @@ -286,7 +361,7 @@ sessions are cleared, and the old socket is closed with `WS_CLOSE_HOST_REPLACED` (4000) / `WS_CLOSE_HOST_REPLACED_REASON`. Both constants live in `server-lib-common` rather than in `server` because the code is a contract, not a log line: the evicted Host keys its stand-down on it (see -[Host side](#host-side-lib--standalone)), and if the two sides disagreed on the +[Host side](#host-side-lib--the-two-node-hosts)), and if the two sides disagreed on the number the two Hosts would evict each other forever. Source of truth: `server/src/relay.ts` (`registerHost`). @@ -365,14 +440,42 @@ Exactly the protocol-v1 scope of [remote-api.md](./remote-api.md) out, `terminal.write`/`terminal.resize` in. (Host→client size-authority and semantic events are staged in remote-api.md `## Future`.) -## 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 }` in webview `localStorage` (`local-json-store.ts` — deliberately no - platform-adapter dependency); open and maintain `GET /ws/host`. +## Host side (`lib` + the two Node hosts) + +The Host is a service in the process that owns the PTYs — never a webview: +`RemoteHostService` in `lib/src/host/remote/service.ts`, installed in the Tauri +sidecar (`docs/specs/standalone.md` → "Remote Host service") and in the VS Code +extension host (`docs/specs/vscode.md` → "Remote Host: a service in the +extension host"). The webview holds only UI — the pairing modal, the +`window.dormouseRemoteHost` console hook, and answering what its own panes are +called — and reaches the service over the `remoteHost:*` bridge, so the console +API's shape is unchanged and its calls are now promises one round trip further +away. + +* **Enrollment** (console hook, once): server URL + setup password → + `POST /api/host/enroll` → the service persists `{ serverUrl, hostId, + hostToken, origin, rpId }` through its `HostStateStore` — a 0600 JSON file + under the app-data dir in standalone, `SecretStorage` in VS Code — then opens + and maintains `GET /ws/host`. `hostToken` is a bearer credential and never + enters a webview realm. Enrollment is refused outright for a server outside + this build's allowlist (above), before the password leaves the machine. + **Order matters, and the store goes first.** The `hostToken` this exchange + mints exists nowhere else and cannot be minted again from the same password + exchange, so the save is awaited before any Host is stopped: a failed write + then leaves the old Host running and every answer it gives still true, instead + of stranding the machine with no Host, a status that says otherwise, and a + credential lost to the failure. Replacing a *running* Host emits + `{ name: 'status', enrolled: false }` between the two, because the webview gate + that arms on it is edge-triggered and everything it holds — the mirrored + pairing queue, the push device list — belongs to the server being left + (`lib/src/remote/host/enrolled-gate.ts`). `clearEnrollment` is the same rule + read backwards: the delete is awaited first and nothing else happens unless it + succeeded, since reporting un-enrolled over a delete that failed would leave + the credential on disk for the next launch to read back. The enroll request + itself carries a 10 s `AbortSignal.timeout` — under the webview's own 15 s + command budget, so the console sees the real error — because it runs on the + service's lifecycle chain, where every later start/stop command queues behind + it and a black-holed relay would otherwise wedge them all. * **Relay socket policy**: one socket at a time, reconnected with exponential backoff (1s, doubling to 30s) after any close — except a close carrying `WS_CLOSE_HOST_REPLACED`, which is **terminal**. That code means another @@ -385,20 +488,40 @@ A `remote-host` module in `lib`, active in standalone: `displaced` in the UI yet; `window.dormouseRemoteHost.status()` reports it as `connection`, distinct from the retrying `disconnected`. A close event from a socket the controller no longer owns is ignored, so a dead socket's late - eviction cannot stand down the live one. Source of truth: - `lib/src/remote/host/remote-host.ts`, `lib/src/remote/host/activation.ts`. -* **Security**: `HostAcl` (persisted to `localStorage` 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.) 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 - the existing resize path and subscribes to its data stream; + eviction cannot stand down the live one. Disposing the service is terminal: + an enrollment or ACL read already in flight cannot construct a relay socket + after its owning sidecar/extension instance has torn down. Source of truth: + `lib/src/remote/host/remote-host.ts`, `lib/src/host/remote/service.ts` + (lifecycle + the console commands), `lib/src/remote/host/activation.ts` (the + webview's client half). +* **Security**: `HostAcl` (persisted through the `HostStateStore` as + `records()`/`fromRecords`, keyed per `hostId` so a re-enrollment cannot + inherit a stale ACL), `HostChallengeIssuer`, `PairingCeremony`, and + `authorizeConnection` — all straight from `server-lib-common`, running in the + service's process. Nothing a webview says can widen access. +* **Pairing approval modal**: the queue is service-side; webviews mirror a + serializable projection of it + (`{ clientId, pairingId, request, requestedAt }[]`, pushed whole on every + change) and echo both ids on Approve / Deny, so the approve/deny closures + never leave the Host's process. **Approval is bound to the displayed + `pairingId`, not whichever request currently occupies `clientId`.** The + service coalesces a re-sent pair under one `clientId` by *replacing* what it + holds, but rejects an old modal action whose immutable ticket id no longer + matches. The mirror likewise includes `pairingId` in its content comparison, + replaces the old item, and remounts the modal keyed by that id. An unchanged + item is left alone: every snapshot arrives as fresh JSON, so identity + comparison would re-render the modal on every event. The modal shows the + requested label + account; + Approve / 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. In VS Code the + queue is broadcast to every window, since any of them may be the one in front + of the user. +* **Terminal bridge**: served through a `HostSurfaceProvider` + (`docs/specs/remote-api.md`). `directory.watch` snapshots are collected from + the webviews that own the panes (title, activity, cwd, exitCode, ringing, + hasTODO — all already tracked there); `surface.attach` resizes through the + owning webview's live xterm and streams the PTY from the process that owns it; `terminal.write` feeds the existing input path. * **Size authority**: last-attach-wins holds at the PTY level through the existing resize path. The "tethering to \" grey-out display on the @@ -464,15 +587,26 @@ DORMOUSE_SETUP_PASSWORD=hunter2 DORMOUSE_VAPID_SUBJECT=mailto:you@example.com \ pnpm dev:pocket-server ``` -**2. Host** (the laptop being controlled): `pnpm dev:standalone`, then enroll -once from the devtools console of the standalone webview: +**2. Host** (the laptop being controlled). The Host runs in the sidecar / the +extension host and refuses any origin outside the allowlist baked into that +bundle — by default the SaaS origin only, with no localhost and no plaintext +scheme. A local server therefore needs the override at build time, which +`dev:standalone` picks up because it re-stages the sidecar bundles on the way: + +```sh +DORMOUSE_REMOTE_CONNECT_SRC='http://localhost:3000 ws://localhost:3000' 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 +The console hook forwards to the Host service, so these are promises; enrollment +persists in the service's own store (a 0600 file under the app-data dir in +standalone) and on later launches the Host connects by itself. (`status()` / +`reconnect()` / `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 and serves the same synthetic echo terminals as the @@ -552,16 +686,16 @@ liftable: ### The `*.dormouse.sh` pin — the constraint everything obeys -The shipped signed client scopes its webview CSP `connect-src` to -`https://*.dormouse.sh wss://*.dormouse.sh` (Host webview CSP, above), and +The shipped signed client bakes `https://*.dormouse.sh wss://*.dormouse.sh` into +its Host bundle as the only origins that Host may reach (above), and passkeys bind to the served origin (`DORMOUSE_ORIGIN` → `rpId`/`origin`) with Pocket served same-origin ([pocket-app.md](./pocket-app.md)). This is why a selfhoster must produce a custom build (`DORMOUSE_REMOTE_CONNECT_SRC`) — the stock client refuses any other origin — and it is the hard constraint on BYOT: whatever a stock client connects to must present a `*.dormouse.sh` origin over TLS. A raw `100.x` tailnet IP or a `*.ts.net` MagicDNS name is a different -origin and breaks both the CSP and the passkey binding, so BYOT cannot simply -point the client at the tailnet node. +origin and breaks both the allowlist and the passkey binding, so BYOT cannot +simply point the client at the tailnet node. ### BYOT — a per-tenant tailnet node diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 895eea54..11ec2e82 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -17,6 +17,7 @@ Tauri app process (Rust — standalone/src-tauri/src/lib.rs) ├── dor-control-server.js — dor CLI control socket (docs/specs/dor-cli.md) ├── iframe-proxy.cjs — bundled from lib/src/host/iframe-proxy.ts (docs/specs/dor-browser.md) ├── agent-browser-host.cjs — bundled from lib/src/host/agent-browser-host.ts (docs/specs/dor-browser.md) + ├── remote-host.cjs — bundled from lib/src/host/remote/sidecar-entry.ts: the remote Host service (§Remote Host service) ├── clipboard-ops.js — OS clipboard: paste-read tiers for macOS/Linux (Windows reads go native in Rust); agent-browser clipboard writes on all platforms (docs/specs/mouse-and-clipboard.md §8.6, docs/specs/dor-browser.md) └── shell-integration/ — injected shell hook scripts (docs/specs/terminal-escapes.md) ``` @@ -24,8 +25,8 @@ Tauri app process (Rust — standalone/src-tauri/src/lib.rs) The Rust layer is deliberately thin: it spawns and supervises the sidecar, bridges the webview to it, and owns the OS-integration edges (window events, file drop, dock icon, logging). Everything with real logic — PTYs, the dor -control server, the iframe proxy, the agent-browser host — runs in the Node -sidecar, sharing the same modules the VS Code host runs +control server, the iframe proxy, the agent-browser host, the remote Host — +runs in the Node sidecar, sharing the same modules the VS Code host runs (`build-sidecar-proxy.mjs` bundles the `lib/src/host/` sources into the sidecar's `.cjs` copies, so the two hosts cannot drift). @@ -39,20 +40,27 @@ Source of truth: `standalone/src/main.tsx` (`bootstrap()`). 2. `setPlatform(platform)` then `await platform.init()` **before** `resumeOrRestore` — init registers the event listeners that resume replay arrives on. -3. `initAlertStateReceiver()`, `restoreActiveTheme()` (`docs/specs/theme.md`). -4. `getAvailableShells()` seeds the shell store (`lib/src/lib/shell-store.ts`), +3. `installPeerSurfaceResponder()`, so the sidecar's Host can ask this webview + what its panes are called and how big their xterms are (§Remote Host + service). **After `init()`, not before:** the responder seeds itself with a + `status` command, and nothing could carry the answer back until the adapter + has its listeners. +4. `initAlertStateReceiver()`, `restoreActiveTheme()` (`docs/specs/theme.md`). +5. `getAvailableShells()` seeds the shell store (`lib/src/lib/shell-store.ts`), which restores the persisted selection (`dormouse:selected-shell`) and publishes it via `setDefaultShellOpts` (the default-shell slot used by split/spawn/restore paths, `docs/specs/layout.md`). The call is *started* right after `init()` so its webview → Rust → sidecar round trip overlaps - step 3, and awaited here: seeding must complete before the Wall mounts, so - the first restored pane already spawns with that shell. -5. `resumeOrRestore(platform)` runs the priority-based recovery from + steps 3–4, and awaited here: seeding must complete before the Wall mounts, + so the first restored pane already spawns with that shell. +6. `resumeOrRestore(platform)` runs the priority-based recovery from `docs/specs/transport.md`. -6. `startUpdateCheck()` (`docs/specs/auto-update.md`), then render `AppBar` + - `App` with `enableRemoteHost` (activating the remote Host module — - enrollment, pairing modal, relay socket; `docs/specs/server.md` Host side), - threading `` through the `baseboardNotice` slot. +7. `startUpdateCheck()` (`docs/specs/auto-update.md`), then render `AppBar` + + `App` with `enableRemoteHost` — the mount gate for the lazily-imported + remote-Host UI chunk: the pairing modal, the console hook, and ring + detection for push (`docs/specs/server.md` Host side). The Host itself is + already running in the sidecar, independent of this. Threads + `` through the `baseboardNotice` slot. ## Rust ↔ sidecar bridge @@ -67,7 +75,8 @@ stderr, which Rust appends to the log file). Webview → Rust is the Tauri `pty_request_init` / `pty_get_cwd` / `pty_get_open_ports` / `pty_get_scrollback` / `pty_graceful_kill_all` / `get_available_shells`, `dor_control_response`, `iframe_create_proxy_url`, the `agent_browser_*` family, -the `clipboard` readers, `read_update_log`, and `kill_sidecar_now` — each a thin +the `clipboard` readers, `read_update_log`, `remote_host_command` +(§Remote Host service), and `kill_sidecar_now` — each a thin forwarder to the corresponding sidecar message. `load_session` / `save_session` / `clear_session` are the exception that is *not* forwarded: they read, write, and delete the per-window session file directly in Rust (§Persistence). Two further carve-outs: on Windows the @@ -109,6 +118,114 @@ the webview, where `TauriAdapter` converts dor control requests into the `resource_dir()` once at the boundary so every derived path is plain — the reasons live in `docs/specs/dor-cli.md` (Bundling And PATH). +### Remote Host service + +The remote Host — the relay socket, the enrollment, the ACL, the pairing +ceremony, remote-api v1 — runs **in the sidecar**, the process that owns the +PTYs. It is the same `RemoteHostService` the VS Code extension host runs +(`lib/src/host/remote/service.ts`, bound here by +`lib/src/host/remote/sidecar-entry.ts` and bundled to `sidecar/remote-host.cjs` +by `build-sidecar-proxy.mjs`, which bakes the relay-origin allowlist into it — +`docs/specs/server.md`). The webview keeps only what a webview is for: the +pairing modal, the console hook, ring detection for push, and answering for its +own panes. Nothing it says can widen access +(`docs/specs/remote-security-model.md`). + +**State.** Rust creates the app-data directory and passes it as +`DORMOUSE_STATE_DIR`; the sidecar keeps enrollment and ACL there as one +`remote-host.json`, written 0600 into a 0700 directory via temp-then-rename. +Tightening a directory Rust already created is best-effort: where POSIX modes do +not exist the file's own 0600 is the protection that matters, and failing the +save over the directory would lose the Host instead. The in-memory view advances +only after that rename succeeds, so a failed save cannot be mistaken for durable +state by a later adoption. **Reads fail closed.** Only `ENOENT` — nothing +written yet — and a file that was read but cannot be parsed answer empty; the +parse failure warns, because an empty ACL silently de-pairs every device. Any +other read error (EACCES, EIO, a held handle on Windows) says nothing about what +the file holds, so it is neither answered nor memoized: the load rejects, and +because every change is a read-modify-write of the whole file, the save behind it +rejects too rather than overwriting state it could not see with nothing. A later +read of the same file still recovers. One file rather than +one per value, so a write is one atomic rename and the +enrollment can never end up describing a different Host than the records +approved under it. `hostToken` is a bearer credential and never enters a webview +realm. If the directory cannot be created, Rust passes an empty value and the +sidecar falls back to an ephemeral store — a Host can be enrolled and used for +the session, because the store holds both values **in memory** rather than +dropping the writes: reads that answered empty would de-pair each device the +moment it was approved, since the ACL a Host authorizes against is the one it +just wrote. Nothing survives the process, and it warns once. That store reports +`persistent: false`, which is what an `adopt` answers back to the webview so the +webview keeps its own copy of the Host rather than clearing the only one that +outlives the run. Every `HostStateStore` states that flag outright — a store +that omitted it would read as durable and could cost the webview its only +surviving copy. The browser dev harness passes a per-run temp directory +instead, so a dev enrollment lives and dies with that run. + +**The bridge.** Webview → sidecar is one generic passthrough invoke, +`remote_host_command(payload)`, which writes `{"event":"remoteHost:command", +"data":payload}` to stdin; the sidecar's dispatch table hands it to +`handleCommand`. Sidecar → webview is three ordinary stdout events — +`remoteHost:result`, `remoteHost:ask`, `remoteHost:event` — forwarded by Rust's +generic `handle.emit`. **The correlation field is `rhId`, never `requestId`:** +Rust swallows any sidecar line whose `data.requestId` matches a pending invoke +in order to resolve it, so a `requestId` here would make results vanish at +random. The contract is shared by both ends +(`lib/src/host/remote/service-protocol.ts`), and the webview half of it — the +pending-command table, the 15s timeout, the always-answer rule for asks — is +`lib/src/host/remote/link-client.ts`, shared with VS Code and the browser dev +harness so no host settles a command differently. + +**Asks and answers.** What the sidecar cannot know — what a pane is called, +whether it is focused, how big its xterm is — it asks over `remoteHost:ask`, and +the responder in `lib/src/remote/host/peer-surfaces.ts` answers as an ordinary +`answer` command naming the ask's own `rhId`. The **first answer settles** the +ask: standalone ships one window, so there is exactly one answerer. That is the +seam where a multi-window standalone would instead collect until the budget +(`ASK_BUDGET_MS`, 1s), which otherwise only bounds a webview that is reloading — +an attach must not hang on one, and a directory that missed a pane re-collects +on the next change. + +An answer for an ask the bridge no longer holds **invalidates the directory** +rather than being dropped. The ask settled empty, so the snapshot the Host +already rendered is missing whatever that answer names — an empty picker on a +machine that does have terminals — and nothing re-opens a settled ask, so the +next collect is the only repair and an idle machine has no other reason to run +one. VS Code's in-window fan-out does the same (`docs/specs/vscode.md`). + +**Stripping.** Unlike VS Code's extension host, the sidecar hands the webview +*raw* PTY bytes and the webview's own parser strips them for its xterm +(`docs/specs/terminal-escapes.md` → the `pty:data` strip semantics). The phone +must see the same stream the laptop's xterm renders, so the service runs its own +strip-only `TerminalProtocolParser` over each PTY it streams — one parser per +PTY rather than per attachment, because what an incomplete escape sequence +leaves behind belongs to that PTY's byte boundaries, and a late joiner inheriting +that state beats a fresh parser starting mid-sequence. **Every event the parser +produces is discarded, responses included:** the webview that owns the terminal +already answers its queries, and a second answer from this process would write +duplicate bytes into the PTY's input and corrupt whatever the program was +parsing. Semantic events (cwd, prompt, title) stay the webview's for the same +reason. + +"Discarded" and "not parsed" are different things, and the difference is the one +place the parser needs a colour: with no colour provider it *declines* an OSC +10/11/12 `?` query, which leaves it in `visibleData`, reaches the phone's xterm, +and gets answered a second time. So the strip parser is built with a constant +provider whose value is never sent anywhere — it exists only to make the query +be consumed, and its generated response is thrown away with every other event. + +The tap is inside `pty-core`'s event callback in `main.js`, ahead of the send to +the webview, and is wrapped: **a remote listener must never break the local +pipe**, so a throw is logged to stderr and the webview's `pty:*` event is sent +either way. With nothing attached, data still returns after cheap id/map checks; +exit codes are retained so a stream installed after surface resolution can +replay liveness before attach acknowledgement. + +Source of truth: `standalone/sidecar/main.js` (the tap and the +`remoteHost:command` case), `remote_host_command` / `remote_host_state_dir` in +`standalone/src-tauri/src/lib.rs`, `lib/src/host/remote/sidecar-entry.ts`, and +`lib/src/host/remote/pty-strip.ts`. + ### Windows node subsystem On Windows the app carries **two** subsystem variants of the same `node.exe`, @@ -158,7 +275,9 @@ ordered: orphans a headed Chrome window and a hung agent-browser cannot wedge the exit (mirrors the VS Code host's `deactivate()`; `docs/specs/dor-browser.md`). 2. Close the dor control socket. -3. `mgr.killAll()` (all PTYs), then `process.exit(0)`. +3. Dispose the remote Host service (drops the relay socket and settles every + outstanding ask, so nothing is left waiting on a webview that is going away). +4. `mgr.killAll()` (all PTYs), then `process.exit(0)`. A parent-PID watchdog polls every 2s and self-triggers shutdown if the Tauri process disappears: stdin EOF is not always delivered when the host is @@ -474,11 +593,13 @@ root `package.json` for the `dev:standalone*` orchestration. - `stage` = `stage:dor-cli` (build + stage the dor CLI, `docs/specs/dor-cli.md`) plus `stage:sidecar-proxy` (`build-sidecar-proxy.mjs` bundles the `lib/src/host/` sources into the sidecar `.cjs` files). -- The `tauri` script runs `standalone/scripts/tauri.mjs`, which rewrites the - webview CSP via `standalone/scripts/csp.mjs` when the - `DORMOUSE_REMOTE_CONNECT_SRC` build-time override for self-host relay - origins is set (`docs/specs/server.md`, Host webview CSP), then delegates - to the Tauri CLI. +- The `tauri` script stages, then runs `standalone/scripts/tauri.mjs`, which + delegates to the Tauri CLI. The `DORMOUSE_REMOTE_CONNECT_SRC` build-time + override for self-host relay origins is baked into the sidecar's remote-host + bundle by `build-sidecar-proxy.mjs` — the Host runs in the sidecar, so the + webview CSP has no relay sources at all, which + `standalone/scripts/tauri-conf.test.mjs` asserts against `tauri.conf.json` + (`docs/specs/server.md`, "Where a Host may reach a relay server"). - The Tauri bundle ships the whole sidecar via the `../sidecar/**/*` resources glob — including node-pty's prebuilds + bundled ConPTY and the shell-integration scripts (`docs/specs/terminal-escapes.md`). @@ -497,7 +618,7 @@ root `package.json` for the `dev:standalone*` orchestration. | `standalone/src-tauri/src/lib.rs` | Rust backend: sidecar spawn/supervision, invoke commands, event forwarding, per-window session file store (`save_session` / `load_session`), quit interception (`QuitState`, `request_quit`, `quit_ack` / `quit_progress` / `quit_cancel` / `quit_proceed`, §Quit flow), file drop, logging, dock icon, exit teardown | | `standalone/src-tauri/src/clipboard_win.rs` | Native Win32 clipboard reads on Windows (owned by `docs/specs/mouse-and-clipboard.md`) | | `standalone/src-tauri/src/pe_subsystem.rs` | Shared PE-subsystem byte-flip (offset lookup + read/set) used by `build.rs` (GUI-patch the bundled sidecar node) and `lib.rs` (derive the console-subsystem `dor` node) — §Windows node subsystem | -| `standalone/scripts/tauri.mjs`, `csp.mjs` | Tauri CLI wrapper assembling the webview CSP (`DORMOUSE_REMOTE_CONNECT_SRC`) | +| `standalone/scripts/tauri.mjs` | Tauri CLI wrapper; stages the sidecar bundles first (the relay allowlist is baked there, not into the webview CSP) | | `standalone/src-tauri/tauri.conf.json` | Window config, dev/build commands, sidecar resources glob, updater config | | `standalone/src/main.tsx` | Webview bootstrap (boot sequence above); initializes the quit orchestrator and installs the confirmation gate on the Tauri branch, mounts `` via Wall's `dialogHost` prop | | `standalone/src/quit.ts` | Quit orchestrator: listens for `dormouse://quit-requested`, runs the graceful teardown, calls `quit_ack` / `quit_progress` / `quit_proceed` / `quit_cancel` (§Quit flow) | @@ -511,5 +632,6 @@ root `package.json` for the `dev:standalone*` orchestration. | `standalone/sidecar/pty-core.js` | Shared PTY manager (owned by `docs/specs/transport.md`) | | `standalone/sidecar/dor-control-server.js` | dor CLI control socket (owned by `docs/specs/dor-cli.md`) | | `standalone/sidecar/clipboard-ops.js` | OS clipboard tiers (owned by `docs/specs/mouse-and-clipboard.md`) | +| `lib/src/host/remote/sidecar-entry.ts` | Sidecar binding of the remote Host service, bundled to `sidecar/remote-host.cjs` (§Remote Host service; protocol owned by `docs/specs/remote-api.md`) | | `standalone/scripts/build-sidecar-proxy.mjs` | Bundles `lib/src/host/` into the sidecar `.cjs` copies | | `standalone/scripts/dev-agent-browser.mjs` | `dev:standalone:ab` entry (owned by `docs/specs/transport.md`) | diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index c53b0e85..08538123 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -25,6 +25,8 @@ State-driving and security-sensitive OSCs are parsed at the PTY data boundary in - VS Code: in the extension host (`message-router.ts` / `pty-manager.ts`), before `pty:data` is forwarded to the webview. - Standalone and fake adapters: in the frontend adapter, before xterm.js sees the bytes. +There is one further parse site, and it is **strip-only**: the remote Host in the Tauri sidecar runs a second parser over each PTY it streams to a phone (`lib/src/host/remote/pty-strip.ts`, tapped in `lib/src/host/remote/sidecar-entry.ts`). The phone must see what the laptop's own xterm sees, and in standalone the stripping happens in the frontend adapter, which the sidecar's stream never passes through. Every event that parser produces is discarded — **responses included**: the webview that owns the terminal already answers, and a second answer would write duplicate bytes into the PTY's input. That is why it is constructed with a constant color provider: a query the parser *declines* stays in `visibleData` and reaches the phone's xterm, which answers it, so OSC 10/11/12 queries must be consumed here even though the reply is thrown away. The VS Code Host needs no such parser — the extension host already parsed the chunk once and streams the processed output (see [vscode.md](vscode.md)). + After parsing, state-driving supported sequences are consumed and not re-emitted. `OSC 8` hyperlinks are the exception: the parser leaves them in `pty:data` so xterm.js owns hyperlink regions and hover rendering, while Dormouse supplies the activation-confirmation handler. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview: - `pty:data` — terminal output with state-driving supported OSCs already parsed/stripped and `OSC 8` hyperlinks preserved. Feeds xterm.js. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 438a0733..6812405e 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -40,7 +40,9 @@ The browser-dev bridge is intentionally a transport shim over the same sidecar p - Host → webview events use `GET /__dormouse_dev_host/events` as an SSE stream. - Browser console calls are mirrored to `POST /__dormouse_dev_host/console` so a single `pnpm dev:standalone:ab` terminal shows sidecar logs, Vite logs, and in-browser diagnostics. -The harness may omit native-only desktop chrome such as window controls and update checks, but it must preserve the `PlatformAdapter` PTY, control-request, clipboard, iframe-proxy, and agent-browser contracts used by the app. Tauri APIs must not be required at static module-evaluation time when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set, because the page is loaded by a normal browser rather than the Tauri WebView. +The remote Host rides the same shim: `remote_host_command` is one more invoke that writes `remoteHost:command` to the sidecar, and the sidecar's `remoteHost:*` events arrive on the SSE stream, so the harness runs a real Host against a per-run temp state directory (`docs/specs/standalone.md` → "Remote Host service"). + +The harness may omit native-only desktop chrome such as window controls and update checks, but it must preserve the `PlatformAdapter` PTY, control-request, clipboard, iframe-proxy, remote-Host, and agent-browser contracts used by the app. Tauri APIs must not be required at static module-evaluation time when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set, because the page is loaded by a normal browser rather than the Tauri WebView. ## PTY lifecycle @@ -109,6 +111,17 @@ Non-obvious message contracts: VS Code-only workbench chord mirroring uses `dormouse:runWorkbenchCommand` from webview to host. The host validates the requested command against the allowlist in `lib/src/lib/vscode-keybindings.ts` (see [the VS Code host spec](vscode.md)) before calling `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. +**Reaching the remote Host is one optional adapter member.** The Host is a service in the process that owns the PTYs, so a webview only talks to it: `remoteHost?: RemoteHostLink` (`lib/src/lib/platform/types.ts`) is present exactly when there is such a process behind the webview — standalone's sidecar, VS Code's extension host — and absent on the website, where the remote modules stay inert. Its four calls are `command` (run a service command, resolve its result), `respond` (answer one op for this webview's own surfaces), `notify` (announce that future answers may differ — argless, because the directory is the only thing a peer answers), and `on` (subscribe to a pushed service event). The webview half of it — command correlation, the 15s timeout, and the rule that an ask is *always* answered even when nothing matches — is `lib/src/host/remote/link-client.ts`, shared by all three adapters so no host settles a command differently. The wire contract both ends compile against is `lib/src/host/remote/service-protocol.ts`. Nothing crossing this seam carries authority: the service asks a webview only what its own panes are called and how big its terminals are (`docs/specs/remote-security-model.md`). + +Each host maps those calls onto its own transport, and the message names differ: + +| Host | command out | result / event in | ask in | answer / notify out | +| --- | --- | --- | --- | --- | +| VS Code | `remoteHost:command { rhId, cmd, params }` | `remoteHost:result { rhId, result \| error }`, `remoteHost:event { name, … }` (both broadcast to every webview in the window) | `peer:ask { requestId, op, params }` | `peer:answer { requestId, results }`, `peer:notify` | +| Standalone (Tauri, and the browser-dev harness) | `remote_host_command(payload)` invoke → sidecar stdin `remoteHost:command` | sidecar stdout `remoteHost:result` / `remoteHost:event` | sidecar stdout `remoteHost:ask { rhId, op, params }` | the same command channel, as `cmd: 'answer' \| 'notify'` | + +Two rules the table encodes. VS Code broadcasts results because an `rhId` is minted with a per-adapter random tag and is therefore globally unique, so only the adapter that asked can settle one — which is also what lets a losing window forward a command to the broker window and get its answer back (`docs/specs/vscode.md` → "Peer surfaces across windows"). Standalone's correlation field is `rhId` and **never** `requestId`, because Rust swallows any sidecar line whose `data.requestId` matches a pending invoke (`docs/specs/standalone.md` → "Remote Host service"). + Workspace union status (`docs/specs/alert.md`) adds no new message. Standalone computes it in-webview — the app bar's workspace strip and the Walls share one webview, so the strip reads the activity store and browser-surface state directly. VS Code computes only the host-visible native-chrome projection from the module-level `AlertManager` filtered to each router's `ownedPtyIds`, then writes it onto native chrome; the host already receives every PTY's alert state, but it does not receive browser-surface TODO (the webview→host Surface-state message is staged — see `docs/specs/vscode.md` `## Future`). | Direction | Message | Source type | Contract | diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index b462d945..4668a538 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -22,6 +22,11 @@ Extension Host (vscode-ext/src/) ├── agent-browser-host.ts — extension-host wiring + stream relay for the agent-browser surface ├── iframe-proxy-host.ts — VS Code binding for the iframe transparent proxy (injects the logger) ├── webview-html.ts — CSP injection, nonce + message-token generation, asset URI rewriting +├── remote-host.ts — the remote Host service in this window: provider, command routing, storage +├── remote-host-store.ts — `VsCodeHostStateStore`: enrollment in SecretStorage, ACL in globalState +├── peer-link.ts — socket between windows: bind-as-lease arbitration, broker serves, clients report in +│ (in-window peer-surface brokering lives in message-router.ts) +├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the Host's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -216,6 +221,10 @@ TUIs query the terminal's foreground/background/cursor colors with `OSC 10/11/12 Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (`randomSecret()` + the directive list). +**The webview CSP carries no relay sources.** Its `connect-src` is `webview.cspSource` plus loopback `ws:` for the agent-browser stream relay — the remote Host holds its `/ws/host` socket from the *extension host*, which no CSP fences, so the origin allowlist is enforced there instead (see "Remote Host: a service in the extension host"). + +That allowlist is still a build-time constant, not a runtime value: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into `dist/extension.js`, defaulting to the SaaS origin (`https://*.dormouse.sh wss://*.dormouse.sh`), and `assertConnectSrcBaked` fails the build if the define did not reach the bundle — a lost define would otherwise surface only as a Host silently using the shipped default. `lib/src/host/remote/connect-src.ts` reads it through `bakedConnectSrc()`, as a `declare const` rather than an import, so the value is a literal in the bundle and nothing at runtime can move it. A selfhoster widens it for their own build with `DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode` — the same variable and the same per-build opt-in as the standalone binary (`docs/specs/server.md` → "Where a Host may reach a relay server"). + `unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to all script tags, and injects initial state via a nonce-gated inline script. ### Webview message authentication @@ -239,6 +248,146 @@ Scope is VS Code. The standalone adapters receive the equivalent events over Tau Source of truth: `lib/src/lib/vscode-message-token.ts` (constants + `isHostMessage`), `vscode-ext/src/webview-messaging.ts` (`WebviewChannel` + `serveWebview`), `vscode-ext/src/webview-html.ts` (mint + injection), `lib/src/lib/platform/vscode-adapter.ts` (both guards). Tests: the `host message authentication` block in `lib/src/lib/platform/vscode-adapter.test.ts`. +### Remote Host: a service in the extension host + +VS Code is a first-class remote Host, and the Host is not a webview thing. It is `RemoteHostService` — the relay socket, the enrollment, the ACL, the pairing ceremony, and remote-api v1 served through a `HostSurfaceProvider` — running in the extension host, the process that already owns the PTYs. The service itself is shared with the Tauri sidecar and specified elsewhere: `lib/src/host/remote/` for the service, `docs/specs/remote-api.md` for what it speaks. This section covers what is VS Code's: where its state lives, which window runs it, and what the webviews still do. + +A webview is a **surface responder plus UI**: it answers what its own panes are called and how big its terminals are, renders the pairing modal, and carries the `window.dormouseRemoteHost` console hook. Nothing a webview says can widen access — the ACL and the access decision never leave the extension host (`docs/specs/remote-security-model.md`). + +**The store.** The Host's enrollment (`{ serverUrl, hostId, hostToken, origin, rpId }`) and its ACL are split by sensitivity: `hostToken` is a bearer credential that grants the `/ws/host` socket, so the enrollment goes to `SecretStorage` (OS keychain), while the ACL is public-key records with no secret in them and goes to `globalState`. Both are global rather than workspace-scoped, because a Host identity belongs to the machine and not to a folder. + +The service reads both **in-process** — no hydration tier, no synchronous write-through cache, no prefix claim, no cross-webview snapshot broadcast. Those existed only because a webview needed a synchronous `local-json-store` view of extension-host state; the store interface (`HostStateStore`) is async because the places state lives are. The enrollment is read once and kept, since `SecretStorage` is a keychain round trip and both the activation probe and the service want the same answer. + +That memo is only safe because it is invalidated across windows: `SecretStorage` is shared by every window of an extension and `secrets.onDidChange` fires in all of them, so the store drops the memo whenever the enrollment key changes anywhere. Without it a promoted broker could resurrect an enrollment another window cleared, or never see one another window created. The ACL is deliberately **not** memoized — it is read from `globalState` on every load, which is in-process and free. All mutations are serialized in call order; in particular, two rapid pairing approvals write successively larger ACL snapshots, and the older snapshot must not finish last and erase the newer approval on restart. A failed write rejects its caller but does not wedge later mutations. That rule holds for every Host store, so it is one helper (`createSerialQueue` in `lib/src/host/remote/serial-queue.ts`) shared by this store, the sidecar's file store, and the service's own start/stop chain. The same subscription is what lets a window that was un-enrolled at activation join a Host a sibling just created: `initRemoteHost` re-checks on the event and contends then, with no reload. + +The keys and JSON values are the ones the webview-resident Host wrote before the service existed (`ENROLLMENT_KEY` in `lib/src/remote/host/store.ts`, `ACL_KEY_PREFIX` in `lib/src/remote/host/acl.ts`, one entry per `hostId` so a re-enrollment cannot inherit a stale ACL), so an already-enrolled installation is picked up with no migration step. Both names are imported rather than mirrored: a key that drifted between the two sides would strand an enrollment that is still on disk. + +Source of truth: `VsCodeHostStateStore` in `vscode-ext/src/remote-host-store.ts` against the `HostStateStore` interface in `lib/src/host/remote/host-state-store.ts`. + +**Which window: bind-as-lease.** One extension host runs per window, so left alone every enrolled window would start a Host against the same enrollment, all of them would connect `/ws/host`, and the server would close the displaced socket (`server/src/relay.ts`) whose `close` handler reconnects and displaces the next one — an endless fight, with each window arming its own alarm push. + +Arbitration is therefore the socket itself: **the bind is the lease**. Every contending window tries to bind one fixed path — `.sock` inside a per-user `dormouse-peer-` directory in the temp dir, or `\\.\pipe\dormouse-peer-` on Windows — where the hash is derived from `context.globalStorageUri.fsPath`. Derived rather than random because it must be *the same* in every window; hashed rather than joined because macOS caps a unix socket path near 104 bytes and the globalStorage path is most of that alone. The winner is the broker and runs the service; everyone else connects to it as a client. + +The invariants are what make this simpler than the heartbeat lease it replaced: + +- **Roles never flip downward.** A broker is the broker for the rest of the process's life. There is deliberately no `onRole(false)` after a `true`, so the whole class of mid-transition races a TTL lease had — start serving, lose the lease, tear down, win it back while tearing down — is unrepresentable rather than handled. A client only ever changes role *upward*. +- **Contend on broker death, not on a timer.** When the broker exits, every client's socket closes and they all race to bind; exactly one wins, because `bind` is the arbiter. No TTL, no heartbeat file, no filesystem watcher. +- **A corpse is cleared, then the bind is re-checked.** `EADDRINUSE` → dial it → `ECONNREFUSED`/`ENOENT` means the path exists but nothing listens (a broker that died without unlinking). Every client of a broker that just died reaches that point at the same instant, so the unlink is jittered by up to `RECLAIM_JITTER_MS` and the path is dialled **again** afterwards — one of them may have rebound it while we waited, and unlinking a live broker's socket would strand every window dialling it. A second refusal is what makes the unlink safe. Two windows can still find the same corpse, both unlink, and the second bind silently displaces the first, leaving the loser serving a socket no client can reach; nothing on the bind path detects that, so `stillOurs` re-stats the path after `RECLAIM_VERIFY_MS` and compares its filesystem identity (device, inode, and nanosecond change timestamp). Inode alone is insufficient because Linux may immediately recycle a removed socket's inode for its replacement. A window whose socket identity was replaced — **or whose path has gone entirely**, which on unix means somebody unlinked it after our bind — stands down and the loop re-runs. Only Windows reads an unreadable path as ours: named pipes are not filesystem objects, cannot be stat-ed, and die with the process that made them. +- **A bind is not a role until it is believed.** Everything that answers "is this window the broker" — `ensurePeerNet`'s shortcut, `isPeerBroker`, `isPeerLinkSettled`, `remoteNotifyPeerChange` — reads `brokerConfirmed`, set only where `settle(true)` runs and cleared by `closeServer`. During the `RECLAIM_VERIFY_MS` window above the socket is bound but may still be given up, and a command landing inside it (an `enroll`, a `secrets.onDidChange`) that was told "broker" would start a service the stand-down path never tears down: two Hosts under one hostId, displacing each other on the relay forever. Unverified reads as unsettled, so such a command is held for the verdict instead. +- **Attempts are spaced.** A refused hello would otherwise turn reconnection into a spin, so the loop waits `RETRY_MS` between rounds, and a bind or connect that lands after disposal is undone rather than left to outlive its window. +- **Errors after `listen` are logged, not thrown.** A listening `net.Server` emits `'error'` for accept-time failures (EMFILE, a broken pipe), and an `EventEmitter` with no `'error'` listener rethrows out of a libuv callback — which would take the whole extension host down. `listenServer` installs a permanent logging listener the moment the bind succeeds; the sockets already accepted are unaffected, and a listener that has genuinely died is noticed by the windows that can no longer reach it. + +**Trust.** The socket path is derived, not secret — it has to be the same in every window, so anything running as any user on the machine can compute it. Two layers stand between that and this installation's terminals. + +*The directory.* On unix the sockets live in a `dormouse-peer-` directory created 0700, and before every bind and every connect it is `lstat`-ed and required to be a directory, owned by this uid, at exactly mode 0700, and not a symlink. A loose directory we own is tightened; anything else is somebody else's, no retry makes it ours, and the peer link stands down for good rather than spinning against it (callers waiting on the contention are released rather than left hanging). Windows named pipes carry their own ACL and skip this layer. + +*The handshake.* The shared secret is a mode-0600 `remote-host.peer-token` in `globalStorageUri`, created once with an exclusive `wx` write rather than a rename so two windows starting together agree — the loser reads the winner's token instead of overwriting it under a client that already read the old one. A `globalStorageUri` where it can be neither read nor created latches the same permanent stand-down as an unsafe socket directory, for the same reason: it is not a transient failure, and retrying at `RETRY_MS` forever would make every command wait out its whole queue budget on every attempt instead of being told there is nothing to reach. It **never crosses the socket**. Instead three frames prove mutual knowledge of it: + +1. `challenge { nonce }` — the *server* speaks first, on accept. A client that has not yet seen proof of the token must not volunteer one into whatever bound the path. +2. `hello { nonce, proof }` — the client answers with `HMAC-SHA256(token, "client:" + serverNonce)` and a fresh nonce of its own. +3. `welcome { proof }` — the server verifies in constant time, then answers `HMAC-SHA256(token, "server:" + clientNonce)`. + +The `client:` / `server:` domain separation is load-bearing: without it the two proofs are the same function of the same key, and a fake server could reflect the client's own proof back as its welcome. The client verifies the welcome **before** it sends or answers anything else — until then it forwards no notifies (they queue), answers no requests, streams no PTY, and forwards no commands, and a welcome it cannot verify closes the socket. So squatting the path buys nothing: the squatter gets one HMAC over a nonce it chose, which is not the token, and is served nothing. Fresh nonces per connection make a captured proof worthless on the next one. Parseable JSON values that are not frame objects are rejected on both ends, a first frame that is not a valid hello drops the socket, and each side bounds the opening handshake to `HANDSHAKE_BUDGET_MS` so a silent connection cannot live forever. + +**Nothing starts until there is a Host to run.** Contention begins when activation finds an enrollment in `SecretStorage`, when `secrets.onDidChange` reports that another window created one, or on the first `enroll` command from any webview — the bootstrap for an un-enrolled machine. A user who never enrolls never sees a socket. The service also runs independently of webview lifetime: a broker window with zero Dormouse webviews still relays, contributing an empty directory of its own. + +**A command that arrives mid-contention is held, not refused.** While the contention runs this window is neither a broker nor a client, and a bind plus a handshake is not instant. Refusing there would tell an enrolled machine's webview it has no Host seconds before it gets one, and the gates that arm on that answer would stay down. So commands queue (bounded at a dozen, oldest refused on overflow) and drain when a role settles — to the service if this window brokered, over the link if it did not. Each carries its own deadline, under the adapter's own 15 s timeout, so a contention that never settles still produces a reason rather than a timeout. `enroll` is the one command that may *start* the contention; everything else refuses only where there is genuinely nothing to reach. + +Source of truth: `vscode-ext/src/remote-host.ts` (the service glue, provider, and command routing) and `ensurePeerNet` / `attempt` / `stillOurs` in `vscode-ext/src/peer-link.ts`, tested in `vscode-ext/test/remote-host.test.ts` and `vscode-ext/test/peer-link.test.ts`. + +**The webview bridge.** A webview reaches the service over `RemoteHostLink` (`lib/src/lib/platform/types.ts`), implemented in `vscode-adapter.ts` on three messages: `remoteHost:command { rhId, cmd, params }` out, `remoteHost:result { rhId, result | error }` and `remoteHost:event { name, … }` back. Everything but those three `postMessage` shapes is the shared client in `lib/src/host/remote/link-client.ts`, so VS Code, Tauri, and the browser dev harness cannot settle a command differently. + +Results are **broadcast to every webview in the window** rather than replied to one. That is safe because an `rhId` is minted with a per-adapter random tag and is therefore globally unique — only the adapter that asked holds a pending command for it — and it is what lets one correlation id serve both the in-window fan-out and the cross-window forward below. + +Two events are pushed rather than answered: `pairing-queue` (the complete queue snapshot; the service is authoritative, so the mirror replaces rather than merges) and `status { enrolled }`. Because the queue is pushed only when it *changes*, the webview also asks for it once on every transition to enrolled — joining a Host that is already mid-pairing would otherwise show no modal at all until that pairing was answered somewhere else. + +**Volunteering is enrollment-gated; answering is not.** Answering an ask is free — a webview replies and goes back to sleep — but *announcing* costs a crossing per pane-state change, activity change, and focus move, plus an activity-store subscription for ring watching, on a machine whose owner may never enroll. So the service announces `{ name: 'status', enrolled }` whenever its lifecycle changes that, and `armWhileEnrolled` (`lib/src/remote/host/enrolled-gate.ts`) arms the outbound half only while a Host exists, seeded by one `status` command at install time for a webview that opens after the enrollment. The seed cannot lose a race with the event: both travel the same ordered channel. + +**The relay socket.** `globalThis.WebSocket` arrived in Node 22, and `engines.vscode` here is `^1.85.0` — VS Code 1.85 shipped Node 18, so the supported range spans that boundary and an older extension host has no global to use. The service is therefore constructed with a factory that prefers `globalThis.WebSocket` and falls back to the bundled `ws`, whose socket satisfies exactly the surface `RemoteHost` reads (`send`, `close`, `readyState`, `addEventListener`, `message` events with `.data`, `close` events with `.code`). esbuild inlines `ws` lazily; its optional native accelerators `bufferutil` / `utf-8-validate` are marked external and are neither installed nor shipped — a `.node` addon cannot be bundled — so `ws` falls through its own `try`/`catch` to the JS paths. Source of truth: `createRelaySocket` in `vscode-ext/src/remote-host.ts`, the `external` list in `vscode-ext/scripts/esbuild.mjs`. + +**Lifetime.** `retainContextWhenHidden: true` is set on both hosting modes, so hiding the panel keeps a webview's terminals answerable. Closing every Dormouse view no longer takes the Host offline — the service outlives them. + +### Peer surfaces + +The service owns the PTYs but not the *view* of them: a window's terminals are spread across however many webviews are open, and each webview is its own JS realm with its own xterm registry (`lib/src/lib/terminal-store.ts`). Only a webview knows what a pane is called, whether it is focused, and how big its xterm is. So the service asks, and every webview answers for its own. + +`message-router.ts` is the in-window fan-out: `brokerRequest(op, params)` posts `peer:ask { requestId, op, params }` to every live webview and settles with everything they answered. Webviews reply `peer:answer { requestId, results }` and announce `peer:notify`, which carries no subject: the directory is the only thing a peer answers, so the announcement is the whole message. The asker is always the extension-host service (its own, or the broker window's over the link) and never a webview, which is why it is a plain promise rather than message plumbing. + +Every webview installs the responder (`installPeerSurfaceResponder` in `lib/src/remote/host/peer-surfaces.ts`, wired from `lib/src/main.tsx`) whether or not its window is the broker. It carries none of the relay, enrollment, or pairing machinery — a registry lookup, the directory collector, a read-only surface resolve, and a resize. Installing is idempotent *per link*: answering already is (a responder replaces the one before it), but the announcing half is not — each install adds a `status` subscription, and each arming under it adds pane-state, activity, and focus listeners with no handle left to remove them, so a second call would cross into the Host's process twice per change forever. Keyed by the link rather than a flag, because the platform adapter is what owns one. + +**One generic seam, one fan-out rule.** A peer request is `(op, params)` and an answer is *zero or more results*; that is the whole contract the adapter, the extension-host broker, and the cross-window socket implement. `op` is opaque to all three, because *what* a peer may be asked belongs to the remote Host and not to the transport: the operation map — `directory` and `surfaceOp`, with their real parameter and result types — lives in `lib/src/remote/host/peer-surfaces.ts` alongside the responder that answers them, so adding an operation is one entry there plus its caller, not a parallel ladder of types at every layer. + +**Presence is ownership.** A webview that owns nothing the request named answers with no results, so there is no `ok` flag anywhere and every field of a result that does come back is required. Every webview answers regardless — even with no responder installed, even to say nothing — which is what lets a fan-out settle as fast on a miss as on a hit; silence would instead wait out the full budget on what is usually a miss. It settles when all of them have replied or the service's `ASK_BUDGET_MS` (1 s) expires, so a webview mid-reload cannot hang an attach or the phone's picker. That is the *inner* budget, and `PEER_REPLY_BUDGET_MS` — what the broker allows a peer *window* — must stay strictly larger, because it contains a whole run of this plus two socket hops. Equal budgets make a slow sibling look like a timeout on the broker's side and discard results that were on their way, so unifying the two constants is a regression rather than a simplification (a guard test in `vscode-ext/test/peer-link-protocol.test.ts` says so). A webview disposed mid-fan-out is removed from the outstanding set, which can settle the request immediately. + +**Each webview counts once, and a late answer repairs the snapshot.** The router removes a webview from the outstanding set *before* taking its results, so a duplicate answer cannot contribute the same panes twice. An answer for a request that has already settled — the budget expired while that webview was busy — arrives after the Host rendered a directory missing whatever it owns, and nothing can re-open a settled request; so it triggers a directory invalidation instead, and the next collect asks again and repairs it. Without that an idle machine has no other reason to re-collect and the phone's picker stays wrong indefinitely. The sidecar's ask bridge does the same on an answer for an ask it no longer holds (`docs/specs/standalone.md`). + +An asynchronous peer answer is bound to the authenticated broker socket that +issued it. If that broker disappears while a webview fan-out is pending, the +answer is dropped even when this window has already connected to a replacement; +request ids restart per broker, so forwarding the old result through the new +socket could satisfy unrelated work that reused the same id. A rejected fan-out +is contained in the peer handler and contributes an empty answer rather than an +unhandled extension-host rejection. + +The one field the transport itself reads out of an answer is a reserved `ptyId` (`routedPtyId`): an answer naming a PTY is claiming it, which is how the cross-window broker learns which window that PTY lives in. Nothing else about an answer is interpreted below the Host. + +Directory answers are snapshots, so the same seam carries invalidation. A webview announces a change when its pane state, activity, or focus changes; membership changes (a webview attaching or disposing, a peer window joining or dropping) announce one too. `notifyDirectoryChanged` fans that to the service's watchers, which coalesce a fresh collect rather than retaining the old directory. The webview coalesces on its own side too — one pending flag drained on a microtask — because those sources fire in bursts (a focus move alone is a `focusout` and a `focusin`) and a burst is worth exactly one crossing. + +**Attach-is-the-resize goes through the live xterm.** `attach` and `resize` are the same mutating operation on the owner (`docs/specs/remote-api.md`), and both drive the owner's xterm rather than the PTY directly, so the owning pane's own view stays consistent with the size the phone asked for. Cross-window attach first fans out a read-only `resolve`, selects its first answer, then sends the mutating `attach` only to that answer's tier and peer; duplicated cold-restored windows therefore do not both resize before one is selected. The owner replies with the size it settled at plus the `ptyId`; the service then streams that PTY. There is no `detach` op — the service stops streaming on its side and the pane keeps whatever size it was left at, which is what last-attach-wins means. + +**Which webview owns a pane never reaches the protocol layer.** `resolveSurface(surfaceId, size)` answers with a `SurfaceHandle` — provider-local `ptyId` routing key, the size it stands at, `resize`, `release` — or `null` if nobody owns it, and `remote-api.ts` holds one of those per attachment. One surface normally has one owner, so the first read-only resolve answer is the answer; when duplicated cold-restored windows temporarily answer for the same ids, the mutating attach and every later handle resize are addressed only to that selected tier/window. A resize nobody answered leaves the last known size standing. The shared half of that provider — the ask-backed directory and the handle construction — is `createAskSurfaceProvider` in `lib/src/host/remote/ask-surface-provider.ts`, so a Host cannot answer an attach differently in VS Code than in standalone. + +**No second strip parser.** The extension host already runs the terminal-protocol parser once per PTY chunk and answers its queries (`message-router.ts`); webviews receive the stripped `visibleData` via `onProcessedPtyData` / `onProcessedPtyExit`, and that is exactly what the service's `streamPty` taps. A second parser here would answer every query twice and corrupt the PTY. (The sidecar, which hands raw bytes to its webview's own parser, does strip — `docs/specs/standalone.md`.) + +Local streams go through **one keyed registry and one listener pair for the whole window**, shared by the Host provider and the peer-link forwarder and dispatching by id to registered sinks. These listeners run on every chunk of every terminal, so separate or per-attachment pairs would tax every keystroke of every PTY once per consumer. The pair is installed on the first attachment and removed when the last one goes, so a window with no remote viewer pays nothing. Source of truth: `vscode-ext/src/processed-pty-streams.ts`. + +### Peer surfaces across windows + +The same problem one level out, and it cannot be solved the same way: VS Code runs one extension host per window, so there is no shared process to broker through. The broker window — the one that won the bind — listens on that socket and every other window connects to it. + +Traffic runs both ways over it, and each direction is the half its end alone can do. The broker asks client windows for their directory and their surfaces and streams their PTYs; client windows forward their webviews' Host commands to the broker, which is the only process running a service, and take back its results and UI events. + +**Both tiers are asked at once.** `askBothTiers` runs `brokerRequest` (this window's webviews) and `remoteRequest` (every peer window) in parallel and concatenates, this window's first. Whatever is asked about normally lives in exactly one webview of one window, so asking in series would spend a whole tier's budget — or a hung window's — before the owner is asked at all. The results carry no tier marker because nothing downstream needs one: a directory is a concatenation deduplicated by `surfaceId`, and the first surface answer is selected. The dedup and the selection keep the same first-from-the-concatenation order on purpose — duplicated cold-restored windows can hold panes with identical ids, and the row the phone's picker shows must be the owner an attach would reach (`createAskSurfaceProvider` in `lib/src/host/remote/ask-surface-provider.ts`). Within the remote tier, all peers are asked at once for the same reason. + +A client window answers a `request` frame by running its **own in-window** fan-out — never the cross-window one, or a request would loop back out. That is why the fan-out `configurePeerLink` hands the link is `brokerRequest` and never `askBothTiers`, and why the link is injected with what it needs rather than importing the router (which imports the link). + +**Routed PTYs arrive pre-stripped.** A client window forwards `onProcessedPtyData` / `onProcessedPtyExit`, so what crosses the link is what that window's own xterm renders — the same stream shape as the local branch, and the reason the provider's two branches are interchangeable. + +**Cross-window streams are reference-counted per routed PTY.** Two attachments to the same foreign surface share one `subscribe` frame; only zero-to-one starts the owner forwarding and only one-to-zero stops it, so a second viewer never restarts a live stream and one viewer detaching cannot silence the other. The owner answers the first `subscribe` with `subscribed` only after its sink and atomic liveness check are installed. A recorded exit is sent first on the same ordered socket, and the remote API waits for `subscribed`, so an exit that landed during surface resolution cannot be overtaken by a successful attach response. The last unsubscribe stops the forwarding but **keeps the route**: "nobody is watching it" is not "it moved". Re-attaching an already-attached surface resolves the new route first and only then tears the old attachment down, so dropping the route on unsubscribe would delete the fresh one and strand every later write. Routes are refreshed by every resolve and dropped by the two events that really mean the terminal is gone — an `exit` frame, and the owning window disconnecting (`forgetPeerRoutes`). + +Once an answer names a `ptyId`, the broker replaces that owner-local id with a stable opaque route handle for the `(peer socket, ptyId)` pair before returning the result. Pane and PTY ids are unique only within a window: "Duplicate Workspace in New Window" can cold-restore identical surface and PTY ids into several windows, so a raw `ptyId → latest answering peer` table would acknowledge the first surface answer while streaming and writing to the last. The selected `SurfaceHandle` instead retains its peer-specific routing key; follow-up surface asks address that peer alone, while `subscribe`, `write`, and PTY-only `resize` translate it back to the owner's real id only on that socket. The generated key is checked against this window's PTYs and remains in the peer namespace after its route closes, so a stale handle fails closed rather than falling through to a later local PTY collision. When a peer disconnects, every handle routed to it is dropped and reported as exited (`forgetPeerRoutes`) — a terminal in a closed window is gone, and a later write must not be posted into a dead socket. + +**Command forwarding.** Three frames carry the Host to windows that do not run it: a client sends `{ kind: 'command', payload }`, the broker answers that one window with `{ kind: 'commandResult', payload }`, and service UI events go out as `{ kind: 'uiEvent', payload }` to every authenticated window. `commandResult` needs no frame id of its own because `rhId` already is one. + +A result is never sent both ways. The broker keeps a `commandRoutes` table of which window is owed each in-flight `rhId`; an answer with an entry goes to that socket alone, and one without goes to this window's webviews. Broadcasting another window's answer would settle nothing anywhere (ids are globally unique) and would put that window's Host state in front of webviews that never asked. A window that disconnects has its outstanding routes dropped and its commands left deliberately unanswered — the socket that would carry the answer is the one that closed, and the asking adapter's own timeout is the backstop. It also has whatever the broker was still *asking* it settled empty on the spot, rather than left to spend the full `PEER_REPLY_BUDGET_MS`: a directory or an attach every surviving window already answered must not stall behind a window that is already gone, and "gone" and "owns nothing" look the same to the caller. A `result` frame is taken only from the window the request was put to, since ids are per-broker. + +Pairing UI events are the opposite: unaddressed and broadcast to every window's webviews, because the approval modal must appear wherever the user happens to be looking. + +**A window with no Host at all still answers the read-only commands.** Reaching the terminal refusal means this window sees no enrollment — it contends when one exists and again the moment another window writes one — so that is the ordinary un-enrolled state, not a failure. `status`, `pushDevices`, and `pairingQueue` are therefore answered with exactly what an idle service returns (the un-enrolled `RemoteHostConsoleStatus`, `null`, `[]`), because each caller reads the difference: `pushDevices` answers `null` for "nowhere to push" and rejects only when the server could not be asked, so an error there had the Settings dialog reporting an unreachable server on a machine that had simply never enrolled, and `enrolled-gate.ts` seeds itself from `status`. Everything else refuses with an error rather than dropping it, so the console hook fails fast instead of hanging for its whole timeout. + +One UI event *is* addressed: when a window completes the handshake the broker sends it the current `{ name: 'status', enrolled }`. `status` is emitted when the Host's lifecycle changes it, and a window connecting changes nothing — so a window opened after the enrollment would otherwise sit disarmed, announcing no directory changes and watching for no rings, until the user reloaded it. + +Socket bind errors reject startup and are handled as an unavailable peer link; they never leave the listen promise pending or surface as an uncaught extension host error. + +Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and arbitration; `vscode-ext/src/peer-link-protocol.ts` for the frame shapes, framing, handshake, budget, and PTY routing table (tested in `vscode-ext/test/peer-link-protocol.test.ts`); `vscode-ext/src/processed-pty-streams.ts` for the window-wide processed stream registry; `vscode-ext/src/remote-host.ts` for `askBothTiers`, the provider, and command routing; `brokerRequest` and the `peer:*` / `remoteHost:command` cases in `vscode-ext/src/message-router.ts`; the operation map and responder in `lib/src/remote/host/peer-surfaces.ts` (tested in `lib/src/remote/host/peer-surfaces.test.ts`); and the attachment it backs in `lib/src/remote/host/remote-api.ts`. + +### Testing the extension host + +`vscode-ext` runs vitest (`pnpm --filter dormouse test`, which typechecks first). The `vscode` module only exists inside a running editor, so `vitest.config.mts` aliases it to a stub providing just the output channel `log.ts` opens — most modules worth testing import `vscode` as `import type`, which erases. + +The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`. Six files, all under `vscode-ext/test/`: + +- **`peer-link.test.ts`** stands up a broker and a client over a real socket: bind-as-lease (first binder wins, idempotent re-announce, taking over a socket whose broker died without unlinking, re-binding when the reclaimed socket is unlinked out from under it, a reclaimed bind answering no role until it is verified, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies, an accept-time server error logged rather than thrown, and the permanent stand-down when the shared token can be neither read nor created), the handshake (the three frames over a raw socket with the token never on the wire, a wrong-token proof dropped, a proof replayed from another connection rejected, and a squatter that took the path being served nothing), the socket directory being kept private, cross-window directory and surface ops, provider-local handles for colliding PTY ids, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, what a disconnect does to in-flight terminals, forwarded commands, and requests still outstanding against it, and that a client whose socket died reports *unsettled* before its `close` lands, so it agrees with `forwardCommand`. +- **`peer-link-protocol.test.ts`** is that link's socket-free half: frame shapes and framing (splits, oversized frames, malformed lines), the PTY routing table, the handshake proof primitives, and the guard that keeps `PEER_REPLY_BUDGET_MS` strictly larger than the `ASK_BUDGET_MS` fan-out it contains. +- **`remote-host.test.ts`** covers the VS Code half of the service: `VsCodeHostStateStore` round-tripping through the stubbed `SecretStorage`/`globalState` (including reading what the webview-resident Host left behind, re-reading after a cross-window change, and serializing ACL snapshots), the enroll bootstrap, commands held while the contention settles and refused at once when it can never settle, the read-only commands answered exactly as a real un-enrolled `RemoteHostService` answers them, contending when another window enrolls, command forwarding and answering, the status event a joining window is greeted with, the relay-socket factory's `ws` fallback, and the provider's streaming, duplicate restored-id owner binding, asking, and directory invalidation. +- **`message-router.test.ts`** covers the in-window fan-out with the link and the service stubbed out: one answer counted per webview however many it sends, and a late answer for a settled request marking the directory stale instead of being dropped. +- **`processed-pty-streams.test.ts`** covers the window's one keyed registry: exactly one listener pair however many attachments exist, none at all with none, per-PTY fan-out, and teardown on exit. +- **`helpers.ts`** holds what the socket suites need — a throwaway `globalStorageUri`, the mirrored socket-path derivation, a poll-with-deadline, `freshModule`, and `fakeWindow`, one window as the link sees it. + +Separate module instances come from `vi.resetModules()` plus a dynamic import, which is what makes one process able to play two windows. + +Not covered: anything needing the real editor — command registration, webview hosting, the theme observer. Those would need `@vscode/test-electron`. + ### Build and development Source of truth: diff --git a/lib/src/host/remote/ask-surface-provider.test.ts b/lib/src/host/remote/ask-surface-provider.test.ts new file mode 100644 index 00000000..e1afee26 --- /dev/null +++ b/lib/src/host/remote/ask-surface-provider.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import type { DirectoryEntry } from '../../remote/host/host-surface-provider'; +import { createAskSurfaceProvider } from './ask-surface-provider'; + +const entry = (surfaceId: string, title: string): DirectoryEntry => ({ + paneRef: surfaceId, + surfaceId, + type: 'terminal', + title, + focused: false, + alive: true, + ringing: false, + hasTODO: false, +}); + +const inertPty = { + writePty: () => {}, + resizePty: () => {}, + streamPty: () => () => {}, +}; + +describe('createAskSurfaceProvider directory', () => { + it('keeps the first of two answerers claiming one surface id', async () => { + // Duplicated cold-restored windows can both hold a pane id. The first + // answer is the owner the attach path's resolve probe selects, so the row + // the phone shows must be that one — not a duplicate lottery. + const { provider } = createAskSurfaceProvider( + async () => [ + entry('pane-1', 'local copy'), + entry('pane-2', 'only one'), + entry('pane-1', 'far copy'), + ], + inertPty, + ); + + const entries = await provider.collectDirectory(); + expect(entries.map((e) => e.title)).toEqual(['local copy', 'only one']); + }); +}); diff --git a/lib/src/host/remote/ask-surface-provider.ts b/lib/src/host/remote/ask-surface-provider.ts new file mode 100644 index 00000000..7a19da92 --- /dev/null +++ b/lib/src/host/remote/ask-surface-provider.ts @@ -0,0 +1,139 @@ +/** + * The half of a {@link HostSurfaceProvider} that is the same wherever the Host + * runs: everything it has to *ask* for, because only a webview knows what its + * panes are called and how big its terminals are. + * + * The two installations — the Tauri sidecar (`sidecar-entry.ts`) and the VS Code + * extension host (`vscode-ext/src/remote-host.ts`) — differ in how an ask + * travels and in who owns the PTYs, and in nothing else. So those two are + * injected and the protocol-shaped middle lives here once: a Host that answered + * an attach differently in one host than the other would be a protocol-v1 + * divergence nobody would see until a phone attached. + */ + +import type { + DirectoryEntry, + HostSurfaceProvider, + SurfaceHandle, +} from '../../remote/host/host-surface-provider'; +import type { PeerSurfaceResult } from '../../remote/host/peer-surfaces'; + +/** + * Fan one operation out to whoever can answer it and collect the answers. Who + * that is — one webview over a JSON line, every webview of every window over a + * broker and a socket — is the installation's business. Follow-up operations + * carry the selected handle's provider-local PTY key so an installation with + * multiple answerers can address only that owner. + */ +export type SurfaceAsk = ( + op: string, + params: unknown, + ownerPtyId?: string, +) => Promise; + +export interface AskSurfaceProvider { + provider: HostSurfaceProvider; + /** + * Something a future {@link HostSurfaceProvider.collectDirectory} could depend + * on changed — a pane, an alert, a focus move, a peer joining. The directory + * is the only thing a peer answers, so there is nothing to name: the cheap + * direction is always to re-collect. + */ + notifyDirectoryChanged(): void; +} + +export function createAskSurfaceProvider( + ask: SurfaceAsk, + pty: Pick, +): AskSurfaceProvider { + const directoryWatchers = new Set<() => void>(); + + const provider: HostSurfaceProvider = { + async collectDirectory(): Promise { + // Each answerer replies with its whole snapshot, so the results *are* the + // entries — with one exception: duplicated cold-restored windows can hold + // panes with identical ids, and two identical rows would make the phone's + // picker (keyed by surfaceId) a lottery over which window an attach + // reaches. Keep the first — answerers arrive local-tier-first, which is + // the same owner the mutating attach's read-only resolve probe selects, + // so the row shown is the surface attached. + const entries = (await ask('directory', {})) as DirectoryEntry[]; + const seen = new Set(); + return entries.filter((entry) => { + if (seen.has(entry.surfaceId)) return false; + seen.add(entry.surfaceId); + return true; + }); + }, + + watchDirectory(onChange) { + directoryWatchers.add(onChange); + return () => { + directoryWatchers.delete(onChange); + }; + }, + + async resolveSurface(surfaceId, size): Promise { + // Attach-is-the-resize: the owner applies the size inside this round trip, + // because there is no way to reach into its xterm afterwards without a + // second one (docs/specs/remote-api.md). One surface has one owner, so the + // first answer is the answer. + const [owner] = (await ask('surfaceOp', { + surfaceId, + op: 'attach', + cols: size.cols, + rows: size.rows, + })) as PeerSurfaceResult[]; + if (!owner) return null; + + let cols = owner.cols; + let rows = owner.rows; + return { + ptyId: owner.ptyId, + get cols() { + return cols; + }, + get rows() { + return rows; + }, + // The owner is the only one that can read the pane back, so remember + // what it reported; a resize nobody answered leaves the last known size + // standing. + resize: async (nextCols, nextRows) => { + const [settled] = (await ask( + 'surfaceOp', + { + surfaceId, + op: 'resize', + cols: nextCols, + rows: nextRows, + }, + owner.ptyId, + )) as PeerSurfaceResult[]; + if (settled) { + cols = settled.cols; + rows = settled.rows; + } + return { cols, rows }; + }, + // Nothing to unwind: the stream is owned by the `streamPty` + // subscription, not by holding the surface. + release: () => {}, + }; + }, + + writePty: pty.writePty, + resizePty: pty.resizePty, + streamPty: pty.streamPty, + }; + + return { + provider, + + notifyDirectoryChanged() { + // Iterated live: a watcher may unsubscribe itself here, which a Set + // tolerates mid-iteration, and this runs on every pane-state change. + for (const watcher of directoryWatchers) watcher(); + }, + }; +} diff --git a/lib/src/host/remote/connect-src.test.ts b/lib/src/host/remote/connect-src.test.ts new file mode 100644 index 00000000..87e4aeae --- /dev/null +++ b/lib/src/host/remote/connect-src.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +// The build scripts read the `.mjs` and the Host service reads the `.ts`; the +// last describe here is what keeps them one fact. +import { + CONNECT_SRC_SOURCE_PATTERN as BUILD_PATTERN, + DEFAULT_REMOTE_CONNECT_SRC as BUILD_DEFAULT, + resolveRemoteConnectSrc, +} from '../../../../scripts/csp-defaults.mjs'; +import { + CONNECT_SRC_SOURCE_PATTERN, + DEFAULT_REMOTE_CONNECT_SRC, + originAllowedByConnectSrc, +} from './connect-src'; + +const SAAS = DEFAULT_REMOTE_CONNECT_SRC; + +describe('originAllowedByConnectSrc', () => { + it('allows a sub-domain at any depth under a wildcard', () => { + expect(originAllowedByConnectSrc('https://relay.dormouse.sh', SAAS)).toBe(true); + expect(originAllowedByConnectSrc('https://a.b.dormouse.sh', SAAS)).toBe(true); + }); + + it('does not let a wildcard match the bare domain', () => { + // `*.dormouse.sh` is a wildcard on purpose (per-tenant subdomains), and CSP + // reads it as sub-domains only. + expect(originAllowedByConnectSrc('https://dormouse.sh', SAAS)).toBe(false); + }); + + it('does not match a domain that merely ends with the source text', () => { + expect(originAllowedByConnectSrc('https://evildormouse.sh', SAAS)).toBe(false); + expect(originAllowedByConnectSrc('https://relay.dormouse.sh.evil.com', SAAS)).toBe(false); + }); + + it('treats https and wss as one scheme', () => { + // A Host reaches the same server over both, and the source list names both; + // either entry must answer for either scheme. + expect(originAllowedByConnectSrc('https://x.example', 'wss://x.example')).toBe(true); + expect(originAllowedByConnectSrc('wss://x.example', 'https://x.example')).toBe(true); + }); + + it('keeps http and https apart', () => { + expect(originAllowedByConnectSrc('http://x.example', 'https://x.example')).toBe(false); + expect(originAllowedByConnectSrc('https://x.example', 'http://x.example')).toBe(false); + expect(originAllowedByConnectSrc('http://x.example', 'ws://x.example')).toBe(true); + }); + + it('matches an exact host', () => { + expect(originAllowedByConnectSrc('https://x.example', 'https://x.example')).toBe(true); + expect(originAllowedByConnectSrc('https://y.example', 'https://x.example')).toBe(false); + }); + + it('is case-insensitive about the host', () => { + expect(originAllowedByConnectSrc('https://Relay.Dormouse.SH', SAAS)).toBe(true); + }); + + it('reads a portless source as the scheme default port', () => { + expect(originAllowedByConnectSrc('https://x.example:443', 'https://x.example')).toBe(true); + expect(originAllowedByConnectSrc('https://x.example:8443', 'https://x.example')).toBe(false); + expect(originAllowedByConnectSrc('http://x.example:80', 'http://x.example')).toBe(true); + }); + + it('honours an explicit port and the `*` port', () => { + expect(originAllowedByConnectSrc('https://x.example:8443', 'https://x.example:8443')).toBe(true); + expect(originAllowedByConnectSrc('https://x.example:8443', 'https://x.example:*')).toBe(true); + expect(originAllowedByConnectSrc('https://x.example', 'https://x.example:*')).toBe(true); + }); + + it('accepts any one source in the list', () => { + const sources = 'https://a.example wss://b.example'; + expect(originAllowedByConnectSrc('https://b.example', sources)).toBe(true); + expect(originAllowedByConnectSrc('https://c.example', sources)).toBe(false); + }); + + it('fails closed on junk', () => { + expect(originAllowedByConnectSrc('not a url', SAAS)).toBe(false); + expect(originAllowedByConnectSrc('https://x.example', '')).toBe(false); + expect(originAllowedByConnectSrc('https://x.example', "'self'")).toBe(false); + // A scheme a Host cannot speak is never a relay. + expect(originAllowedByConnectSrc('file:///etc/passwd', 'file://')).toBe(false); + }); + + it('is the same default the build scripts bake in', () => { + expect(DEFAULT_REMOTE_CONNECT_SRC).toBe(BUILD_DEFAULT); + }); +}); + +describe('the build-time check on a self-hoster’s override', () => { + it('reads a source with exactly the grammar the matcher does', () => { + // `scripts/csp-defaults.mjs` is a build script and cannot import this file, + // so it keeps a copy. A copy that drifted would either fail a build over a + // source the Host accepts, or pass one it silently never matches. + expect(BUILD_PATTERN.source).toBe(CONNECT_SRC_SOURCE_PATTERN.source); + expect(BUILD_PATTERN.flags).toBe(CONNECT_SRC_SOURCE_PATTERN.flags); + }); + + it('fails the build on an override the Host could never match', () => { + // Each silently matches nothing at runtime, so the binary builds green and + // then refuses to enroll against the server it was built for. + for (const bad of [ + 'https://relay.example.ts.net/', + 'relay.example.ts.net', + 'ftp://relay.example.ts.net', + 'htps://relay.example.ts.net', + 'https://relay.example.ts.net:0', + 'https://relay.example.ts.net:65536', + ]) { + expect(() => + resolveRemoteConnectSrc({ DORMOUSE_REMOTE_CONNECT_SRC: bad }, 'test'), + ).toThrow(/DORMOUSE_REMOTE_CONNECT_SRC/); + } + expect( + originAllowedByConnectSrc('https://relay.example.ts.net', 'ftp://relay.example.ts.net'), + ).toBe(false); + expect( + originAllowedByConnectSrc('https://relay.example.ts.net', 'htps://relay.example.ts.net'), + ).toBe(false); + expect( + originAllowedByConnectSrc( + 'https://relay.example.ts.net:65535', + 'https://relay.example.ts.net:65536', + ), + ).toBe(false); + // And one entry of a list is enough to fail it. + expect(() => + resolveRemoteConnectSrc( + { DORMOUSE_REMOTE_CONNECT_SRC: 'https://a.example wss://b.example/' }, + 'test', + ), + ).toThrow(); + }); + + it('passes a well-formed override through, and an unset one to the default', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const custom = 'https://relay.example.ts.net wss://relay.example.ts.net'; + expect(resolveRemoteConnectSrc({ DORMOUSE_REMOTE_CONNECT_SRC: custom }, 'test')).toBe(custom); + expect(resolveRemoteConnectSrc({}, 'test')).toBe(DEFAULT_REMOTE_CONNECT_SRC); + expect(resolveRemoteConnectSrc({ DORMOUSE_REMOTE_CONNECT_SRC: ' ' }, 'test')).toBe( + DEFAULT_REMOTE_CONNECT_SRC, + ); + expect( + resolveRemoteConnectSrc( + { DORMOUSE_REMOTE_CONNECT_SRC: 'http://localhost:1 wss://relay.example:*' }, + 'test', + ), + ).toBe('http://localhost:1 wss://relay.example:*'); + expect( + originAllowedByConnectSrc('https://relay.example', 'wss://relay.example:00443'), + ).toBe(true); + log.mockRestore(); + }); +}); diff --git a/lib/src/host/remote/connect-src.ts b/lib/src/host/remote/connect-src.ts new file mode 100644 index 00000000..182bf24f --- /dev/null +++ b/lib/src/host/remote/connect-src.ts @@ -0,0 +1,140 @@ +/** + * Where a Host is allowed to reach a relay server, enforced in the process that + * holds the socket (docs/specs/server.md → "Host webview CSP"). + * + * The allowlist is written as a CSP source list because that is what it used to + * be: while the Host lived in a webview, `connect-src` was the enforcement. A + * Node-resident Host has no CSP, so the same source list is baked into its + * bundle and checked here instead — one syntax, one build-time variable + * (`DORMOUSE_REMOTE_CONNECT_SRC`), whichever process ends up holding the socket. + * + * Matching is deliberately narrower than a browser's: only the sources a Host + * can meaningfully be pointed at (scheme + host + port) are understood, and + * anything else fails closed. + */ + +/** + * The remote-server sources baked into published builds. Kept equal to + * `scripts/csp-defaults.mjs` by `connect-src.test.ts` — the build scripts read + * the `.mjs`, the service reads this, and a drift between them would ship a + * binary that refuses the origin its own CSP allows. + */ +export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +/** Substituted by esbuild at build time; see `scripts/csp-defaults.mjs`. */ +declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; + +/** + * The allowlist this build was compiled with — the one place the baked value is + * read, whichever process holds the socket. + * + * A `define` substitutes the identifier wherever it appears in the bundle, + * imported lib modules included, and both host bundles pass it + * (`standalone/scripts/build-sidecar-proxy.mjs`, `vscode-ext/scripts/esbuild.mjs`), + * so declaring it here rather than at each entry point keeps the value a literal + * in the bundle with no second copy of the fallback to drift. The `typeof` guard + * is for the test runners, which have no define. + */ +export function bakedConnectSrc(): string { + return typeof __DORMOUSE_REMOTE_CONNECT_SRC__ === 'string' + ? __DORMOUSE_REMOTE_CONNECT_SRC__ + : DEFAULT_REMOTE_CONNECT_SRC; +} + +/** https and wss are one scheme to a Host: the relay is reached over both. */ +function schemeClass(scheme: string): 'secure' | 'insecure' | null { + if (scheme === 'https:' || scheme === 'wss:') return 'secure'; + if (scheme === 'http:' || scheme === 'ws:') return 'insecure'; + return null; +} + +function defaultPort(schemeGroup: 'secure' | 'insecure'): string { + return schemeGroup === 'secure' ? '443' : '80'; +} + +interface ParsedSource { + group: 'secure' | 'insecure'; + host: string; + /** `*` means any port; otherwise the literal port the source names. */ + port: string; +} + +/** + * The grammar one source must have: `scheme://host`, optionally `:port` or + * `:*`. Anything else is silently no match here, which for a self-hoster's + * `DORMOUSE_REMOTE_CONNECT_SRC` means a build that succeeds and then refuses + * every origin at enrollment — a trailing slash or a bare host is enough. + * + * Exported because `scripts/csp-defaults.mjs` checks the override against it at + * build time and fails the build instead. A build script cannot import + * TypeScript, so it keeps a copy, and `connect-src.test.ts` asserts the two + * patterns are the same string. + */ +export const CONNECT_SRC_SOURCE_PATTERN = /^((?:https?|wss?):)\/\/([^/:]+)(?::(\*|\d+))?$/i; + +function parseSource(source: string): ParsedSource | null { + const match = CONNECT_SRC_SOURCE_PATTERN.exec(source); + if (!match) return null; + const group = schemeClass(match[1]!.toLowerCase()); + if (!group) return null; + const rawPort = match[3]; + let port = defaultPort(group); + if (rawPort === '*') { + port = '*'; + } else if (rawPort !== undefined) { + const numericPort = Number(rawPort); + if (!Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65_535) { + return null; + } + // URL canonicalizes numeric ports, including leading zeroes, before the + // origin reaches this matcher. Canonicalize the source the same way. + port = String(numericPort); + } + return { + group, + host: match[2]!.toLowerCase(), + port, + }; +} + +/** + * A source's host matches exactly, or by a leading-`*.` wildcard that covers + * every sub-domain at any depth but never the bare domain itself — `*.x.y` + * reaches `a.x.y` and `a.b.x.y`, not `x.y`. That is CSP's rule, and the + * shipped default depends on it: per-tenant subdomains of `dormouse.sh` are in + * scope while `dormouse.sh` itself is not. + */ +function hostMatches(sourceHost: string, host: string): boolean { + // `*.x.y` -> the suffix `.x.y`, which `x.y` itself cannot end with. + if (sourceHost.startsWith('*.')) return host.endsWith(sourceHost.slice(1)); + return sourceHost === host; +} + +/** + * Whether `origin` is one this build's Host may connect to. `sources` is a + * whitespace-separated CSP source list; an unparseable origin or source is + * never a match. + */ +export function originAllowedByConnectSrc(origin: string, sources: string): boolean { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + const group = schemeClass(url.protocol); + if (!group || url.hostname === '') return false; + const host = url.hostname.toLowerCase(); + const port = url.port || defaultPort(group); + + for (const raw of sources.split(/\s+/)) { + if (!raw) continue; + const source = parseSource(raw); + if (!source) continue; + if (source.group !== group) continue; + if (!hostMatches(source.host, host)) continue; + if (source.port !== '*' && source.port !== port) continue; + return true; + } + return false; +} diff --git a/lib/src/host/remote/host-state-store.test.ts b/lib/src/host/remote/host-state-store.test.ts new file mode 100644 index 00000000..bbe36cf6 --- /dev/null +++ b/lib/src/host/remote/host-state-store.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Watches the two filesystem steps a save is made of, so a test can see whether + * two saves interleave. Only the temp writes are timed — the tests' own + * `writeFile` calls go straight through. + */ +const fsProbe = vi.hoisted(() => ({ + steps: [] as string[], + tmpWriteDelayMs: 0, + /** Stand in for a filesystem with no POSIX modes. */ + chmodFails: false, + /** Stand in for a read that fails for a reason other than "no file yet". */ + readFileError: null as (Error & { code?: string }) | null, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + chmod: async (path: string, mode: number) => { + if (fsProbe.chmodFails) throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); + return real.chmod(path, mode); + }, + readFile: async (path: string, options: never) => { + if (fsProbe.readFileError) throw fsProbe.readFileError; + return real.readFile(path, options); + }, + writeFile: async (path: string, data: never, options: never) => { + if (String(path).endsWith('.tmp')) { + fsProbe.steps.push('write'); + if (fsProbe.tmpWriteDelayMs) { + await new Promise((resolve) => setTimeout(resolve, fsProbe.tmpWriteDelayMs)); + } + } + return real.writeFile(path, data, options); + }, + rename: async (from: string, to: string) => { + fsProbe.steps.push('rename'); + return real.rename(from, to); + }, + }; +}); +import type { HostAclRecord } from 'server-lib-common'; +import type { HostEnrollment } from '../../remote/host/enrollment'; +import { createEphemeralHostStateStore, FileHostStateStore } from './host-state-store'; + +const ENROLLMENT: HostEnrollment = { + serverUrl: 'https://relay.example', + hostId: 'host-1', + hostToken: 'tok', + origin: 'https://relay.example', + rpId: 'relay.example', +}; + +function aclRecord(hostId: string, devicePublicKey: string): HostAclRecord { + return { + hostId, + accountId: 'owner', + passkeyCredentialId: 'cred', + passkeyPublicKeyHash: 'hash', + devicePublicKey, + approvedAt: 1, + approvedBy: 'host-user', + label: 'iPhone', + revokedAt: null, + }; +} + +let dir: string; +const file = (): string => join(dir, 'remote-host.json'); + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dormouse-host-state-')); + fsProbe.steps.length = 0; + fsProbe.tmpWriteDelayMs = 0; + fsProbe.chmodFails = false; + fsProbe.readFileError = null; +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('FileHostStateStore', () => { + it('round-trips the enrollment and the ACL across instances', async () => { + const store = new FileHostStateStore(dir); + await store.saveEnrollment(ENROLLMENT); + await store.saveAcl('host-1', [aclRecord('host-1', 'device-1')]); + + const reopened = new FileHostStateStore(dir); + expect(await reopened.loadEnrollment()).toEqual(ENROLLMENT); + expect(await reopened.loadAcl('host-1')).toHaveLength(1); + }); + + it('answers empty before anything was written', async () => { + const store = new FileHostStateStore(dir); + expect(await store.loadEnrollment()).toBeNull(); + expect(await store.loadAcl('host-1')).toEqual([]); + }); + + it('keeps ACLs apart by hostId', async () => { + const store = new FileHostStateStore(dir); + await store.saveAcl('host-1', [aclRecord('host-1', 'device-1')]); + await store.saveAcl('host-2', [aclRecord('host-2', 'device-2')]); + expect(await store.loadAcl('host-1')).toEqual([aclRecord('host-1', 'device-1')]); + // A record filed under the wrong host is dropped rather than failing the + // whole load — `HostAcl.fromRecords` would reject the mismatch. + await writeFile( + file(), + JSON.stringify({ version: 1, enrollment: null, acl: { 'host-1': [aclRecord('other', 'x')] } }), + ); + expect(await new FileHostStateStore(dir).loadAcl('host-1')).toEqual([]); + }); + + it('clearing the enrollment leaves the records alone', async () => { + const store = new FileHostStateStore(dir); + await store.saveEnrollment(ENROLLMENT); + await store.saveAcl('host-1', [aclRecord('host-1', 'device-1')]); + await store.clearEnrollment(); + + const reopened = new FileHostStateStore(dir); + expect(await reopened.loadEnrollment()).toBeNull(); + expect(await reopened.loadAcl('host-1')).toHaveLength(1); + }); + + it('writes the file 0600 and creates its directory 0700', async () => { + // The enrollment carries `hostToken`, a bearer credential. + const nested = join(dir, 'nested'); + const store = new FileHostStateStore(nested); + await store.saveEnrollment(ENROLLMENT); + + expect((await stat(join(nested, 'remote-host.json'))).mode & 0o777).toBe(0o600); + expect((await stat(nested)).mode & 0o777).toBe(0o700); + }); + + it.runIf(process.platform !== 'win32')( + 'tightens a state directory the host created first', + async () => { + // Production Tauri creates app_data_dir before spawning the sidecar, so + // mkdir's creation mode alone cannot make this directory private. + await chmod(dir, 0o755); + const store = new FileHostStateStore(dir); + await store.saveEnrollment(ENROLLMENT); + + expect((await stat(dir)).mode & 0o777).toBe(0o700); + }, + ); + + it('still saves where the directory mode cannot be set', async () => { + // A filesystem with no POSIX modes (a mounted share, some containers). The + // 0600 on the file is the protection that matters, so failing the whole save + // over the directory would lose the Host to no benefit. + fsProbe.chmodFails = true; + const store = new FileHostStateStore(dir); + + await expect(store.saveEnrollment(ENROLLMENT)).resolves.toBeUndefined(); + expect(await new FileHostStateStore(dir).loadEnrollment()).toEqual(ENROLLMENT); + }); + + it('leaves no temp file behind, and overwrites in place', async () => { + const store = new FileHostStateStore(dir); + await store.saveEnrollment(ENROLLMENT); + await store.saveEnrollment({ ...ENROLLMENT, hostId: 'host-2' }); + + const { readdir } = await import('node:fs/promises'); + expect(await readdir(dir)).toEqual(['remote-host.json']); + const parsed = JSON.parse(await readFile(file(), 'utf8')) as { enrollment: HostEnrollment }; + expect(parsed.enrollment.hostId).toBe('host-2'); + }); + + it('serializes concurrent saves instead of interleaving their writes', async () => { + // Two saves in flight at once is the normal case — the ACL is written in + // the background while a command writes the enrollment. Overlapping them + // used to share one temp path per process, so the first rename moved the + // file out from under the second, which then failed with ENOENT. + fsProbe.tmpWriteDelayMs = 30; + const store = new FileHostStateStore(dir); + + await Promise.all([ + store.saveAcl('host-1', [aclRecord('host-1', 'device-1')]), + store.saveEnrollment(ENROLLMENT), + ]); + + expect(fsProbe.steps).toEqual(['write', 'rename', 'write', 'rename']); + // And the file the last one left is whole, with both changes in it. + const parsed = JSON.parse(await readFile(file(), 'utf8')) as { + enrollment: HostEnrollment; + acl: Record; + }; + expect(parsed.enrollment).toEqual(ENROLLMENT); + expect(parsed.acl['host-1']).toHaveLength(1); + const { readdir } = await import('node:fs/promises'); + expect(await readdir(dir)).toEqual(['remote-host.json']); + }); + + it('keeps saving after one write fails', async () => { + // The chain must not wedge on a single unwritable moment, and the caller + // still has to see the failure. + const store = new FileHostStateStore(dir); + // Read the (absent) file first, so what fails below is the write: a read + // that fails for a reason other than ENOENT refuses to save at all, which + // is the case above rather than this one. + expect(await store.loadEnrollment()).toBeNull(); + await rm(dir, { recursive: true, force: true }); + const blocker = join(dir); + await writeFile(blocker, 'not a directory'); + + await expect(store.saveEnrollment(ENROLLMENT)).rejects.toBeTruthy(); + // A failed flush is not a successful in-memory save. Adoption reads this + // value to decide whether the webview may discard its legacy copy. + expect(await store.loadEnrollment()).toBeNull(); + await rm(blocker, { force: true }); + await expect(store.saveEnrollment(ENROLLMENT)).resolves.toBeUndefined(); + expect(await store.loadEnrollment()).toEqual(ENROLLMENT); + }); + + it('refuses to answer — or to write — from a read it could not explain', async () => { + // EACCES/EIO says nothing about what the file holds. Answering empty would + // be memoized, and the next save is a read-modify-write of the whole file: + // it would durably overwrite the enrollment and every ACL record with the + // nothing we invented, de-pairing every device for good. + const seeded = new FileHostStateStore(dir); + await seeded.saveEnrollment(ENROLLMENT); + await seeded.saveAcl('host-1', [aclRecord('host-1', 'device-1')]); + const before = await readFile(file(), 'utf8'); + + const store = new FileHostStateStore(dir); + fsProbe.readFileError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + await expect(store.loadEnrollment()).rejects.toMatchObject({ code: 'EACCES' }); + await expect(store.loadAcl('host-1')).rejects.toMatchObject({ code: 'EACCES' }); + await expect(store.saveEnrollment({ ...ENROLLMENT, hostId: 'host-2' })).rejects.toMatchObject({ + code: 'EACCES', + }); + await expect(store.clearEnrollment()).rejects.toMatchObject({ code: 'EACCES' }); + + // Nothing reached the disk while the state could not be read. + fsProbe.readFileError = null; + expect(await readFile(file(), 'utf8')).toBe(before); + // And the failure was not memoized: the same store recovers on the next read. + expect(await store.loadEnrollment()).toEqual(ENROLLMENT); + expect(await store.loadAcl('host-1')).toHaveLength(1); + }); + + it('starts empty and warns on a malformed file', async () => { + // Fail closed but loudly: an empty ACL silently de-pairs every device. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await writeFile(file(), '{ not json'); + + const store = new FileHostStateStore(dir); + expect(await store.loadEnrollment()).toBeNull(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('does not warn about a file that simply is not there yet', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await new FileHostStateStore(dir).loadEnrollment(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('ignores an enrollment that does not have the shape', async () => { + await writeFile(file(), JSON.stringify({ version: 1, enrollment: { hostId: 'x' }, acl: {} })); + expect(await new FileHostStateStore(dir).loadEnrollment()).toBeNull(); + }); +}); + +describe('createEphemeralHostStateStore', () => { + it('keeps the Host for the session, and says once that it goes no further', async () => { + // Reads that answered empty would de-pair every device the moment it was + // approved: the ACL this Host authorizes with is the one it just wrote. + const warnings: string[] = []; + const store = createEphemeralHostStateStore((message) => warnings.push(message)); + + await store.saveEnrollment(ENROLLMENT); + await store.saveAcl('host-1', [aclRecord('host-1', 'device-1')]); + expect(await store.loadEnrollment()).toEqual(ENROLLMENT); + expect(await store.loadAcl('host-1')).toHaveLength(1); + expect(warnings).toHaveLength(1); + + await store.clearEnrollment(); + expect(await store.loadEnrollment()).toBeNull(); + }); + + it('declares that nothing here survives, so an adopting webview keeps its copy', () => { + expect(createEphemeralHostStateStore(() => {}).persistent).toBe(false); + expect(new FileHostStateStore(dir).persistent).toBe(true); + }); + + it('files records under their own host, like the real store', async () => { + const store = createEphemeralHostStateStore(() => {}); + await store.saveAcl('host-1', [aclRecord('other', 'device-1')]); + expect(await store.loadAcl('host-1')).toEqual([]); + }); +}); diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts new file mode 100644 index 00000000..abca9e44 --- /dev/null +++ b/lib/src/host/remote/host-state-store.ts @@ -0,0 +1,238 @@ +/** + * Where a Node-resident Host keeps the two things it must survive a restart + * with: the enrollment (which carries `hostToken`, a bearer credential) and the + * ACL (the authorization primitive, which per the security model lives on the + * Host and nowhere else — docs/specs/remote-security-model.md). + * + * The interface is async because the hosts that implement it are: a file the + * sidecar owns here, VS Code `SecretStorage` later. {@link FileHostStateStore} + * is the sidecar's: one file, 0600, under a directory the app passes in. + */ + +import { randomUUID } from 'node:crypto'; +import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { HostAclRecord } from 'server-lib-common'; +import { filterAclRecords } from '../../remote/host/acl'; +import { isEnrollment, type HostEnrollment } from '../../remote/host/enrollment'; +import { createSerialQueue } from './serial-queue'; + +// Re-exported so an implementor can name the record type without depending on +// `server-lib-common` itself; vscode-ext's project does not resolve it. +export type { HostAclRecord }; + +export interface HostStateStore { + /** + * Whether a write survives this process. Only the dev-harness store (no state + * directory) says `false`, and an adopting webview reads it to decide whether + * it may drop its own copy of the Host (`service.ts` → `#adopt`). Required + * rather than optional: a store that forgot to answer would be read as durable + * and could cost the webview the only copy that outlives the process. + */ + readonly persistent: boolean; + loadEnrollment(): Promise; + saveEnrollment(enrollment: HostEnrollment): Promise; + clearEnrollment(): Promise; + loadAcl(hostId: string): Promise; + saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise; +} + +const FILE_NAME = 'remote-host.json'; + +interface HostStateFile { + version: 1; + enrollment: HostEnrollment | null; + /** Keyed by hostId so a re-enrollment cannot inherit a stale ACL. */ + acl: Record; +} + +function emptyState(): HostStateFile { + return { version: 1, enrollment: null, acl: {} }; +} + +function parseState(raw: string): HostStateFile { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') throw new Error('not an object'); + const { enrollment, acl } = parsed as { enrollment?: unknown; acl?: unknown }; + const state = emptyState(); + if (isEnrollment(enrollment)) state.enrollment = enrollment; + if (acl && typeof acl === 'object') { + for (const [hostId, records] of Object.entries(acl as Record)) { + if (Array.isArray(records)) state.acl[hostId] = records as HostAclRecord[]; + } + } + return state; +} + +/** + * One JSON file holding both values. A single file rather than one per value so + * a write is one atomic rename: the enrollment and the records approved under it + * can never end up describing different Hosts. + */ +export class FileHostStateStore implements HostStateStore { + readonly persistent = true; + + readonly #dir: string; + readonly #path: string; + #state: Promise | null = null; + /** + * Serializes mutations, the way `server/src/state.ts` does: every save is a + * read-modify-write of the whole file, so two of them running together can + * interleave their writes and renames and land the older one last. + */ + readonly #serialize = createSerialQueue(); + + constructor(stateDir: string) { + this.#dir = stateDir; + this.#path = join(stateDir, FILE_NAME); + } + + async loadEnrollment(): Promise { + return (await this.#read()).enrollment; + } + + saveEnrollment(enrollment: HostEnrollment): Promise { + return this.#mutate((state) => { + state.enrollment = enrollment; + }); + } + + clearEnrollment(): Promise { + return this.#mutate((state) => { + state.enrollment = null; + }); + } + + async loadAcl(hostId: string): Promise { + return filterAclRecords(hostId, (await this.#read()).acl[hostId] ?? []); + } + + saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { + return this.#mutate((state) => { + state.acl[hostId] = [...records]; + }); + } + + /** Apply one change to the in-memory state and flush it, one at a time. */ + #mutate(change: (state: HostStateFile) => void): Promise { + return this.#serialize(async () => { + // A read that failed rejects here and takes the whole save with it: every + // change is a read-modify-write of the whole file, so writing without + // having read it would replace state we could not see with state we + // invented (`#read`). + const current = await this.#read(); + // Do not expose a mutation through later reads until its atomic rename + // has succeeded. In particular, a failed enrollment save must not make a + // later adoption believe the Host is durable and discard the webview's + // only surviving copy. Changes replace top-level enrollment / ACL slots, + // so a shallow copy of the map is the required transaction boundary. + const next: HostStateFile = { ...current, acl: { ...current.acl } }; + change(next); + await this.#write(next); + this.#state = Promise.resolve(next); + }); + } + + #read(): Promise { + // Read once and keep it: this process is the only writer, so the in-memory + // copy is the file, and a save is a full rewrite of what we already hold. + this.#state ??= this.#readOnce().catch((error: unknown) => { + // A read that failed for a reason other than "there is no file yet" says + // nothing about what the file holds — EACCES, EIO, an open handle on + // Windows. Memoizing empty for it would make the very next `#mutate` + // read-modify-write from nothing and durably overwrite the enrollment and + // every ACL record with it, de-pairing every device for good. So forget + // the attempt instead: the caller fails closed, `#mutate` refuses to + // write because it never got a state to modify, and a later read of the + // same file can still recover. + this.#state = null; + throw error; + }); + return this.#state; + } + + async #readOnce(): Promise { + let raw: string; + try { + raw = await readFile(this.#path, 'utf8'); + } catch (error) { + // Nothing written yet is the ordinary state of a machine that never + // enrolled, and it is the one failure that genuinely means "empty". + if ((error as { code?: string } | null)?.code === 'ENOENT') return emptyState(); + throw error; + } + try { + return parseState(raw); + } catch (error) { + // We did read the file and there is nothing in it to preserve. Start + // empty but loudly, like `loadHostAcl`: an empty ACL silently de-pairs + // every device, so it must at least be explicable from a log. + console.warn(`[remote-host] could not read ${this.#path}; starting empty`, error); + return emptyState(); + } + } + + async #write(state: HostStateFile): Promise { + // 0700 dir + 0600 file: the enrollment is a bearer credential, and the app + // data directory is not otherwise private on a shared machine. + await mkdir(this.#dir, { recursive: true, mode: 0o700 }); + // `mkdir` applies its mode only when it creates the final component. Tauri + // creates app_data_dir before spawning us, commonly under a 0755 umask, so + // tighten an existing directory too. Windows ACLs are not Unix modes. + // Best-effort, like `peer-link.ts`'s: on a filesystem with no POSIX modes + // the 0600 on the file below is the protection that matters, and failing + // the whole save over the directory would lose the Host instead. + if (process.platform !== 'win32') await chmod(this.#dir, 0o700).catch(() => {}); + // Temp-then-rename in the same directory, so a crash mid-write leaves the + // previous state intact rather than a truncated file that reads as "no Host". + // Unique per write rather than per process: `#mutate` already keeps this + // process's saves apart, and a second Dormouse sharing the state directory + // would otherwise rename a file the first one is still writing. + const tmp = `${this.#path}.${randomUUID()}.tmp`; + let renamed = false; + try { + await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); + await rename(tmp, this.#path); + renamed = true; + } finally { + // A failed rename must not accumulate bearer-credential temp files. + if (!renamed) await rm(tmp, { force: true }).catch(() => {}); + } + } +} + +/** + * The store for a run with no state directory (the browser dev harness). + * + * Held in memory rather than dropped: a Host enrolled here has to keep working + * for the rest of the session — its ACL is what authorizes every pairing it + * then approves, and reads that answered empty would de-pair each device the + * moment it was approved. Nothing survives the process, which `persistent` + * says out loud so the webview keeps its own copy of an adopted Host. + */ +export function createEphemeralHostStateStore(onWarn: (message: string) => void): HostStateStore { + let warned = false; + const warnOnce = (): void => { + if (warned) return; + warned = true; + onWarn('[remote-host] no state directory; the Host is in memory and will not survive a restart'); + }; + let enrollment: HostEnrollment | null = null; + const acl = new Map(); + return { + persistent: false, + loadEnrollment: async () => enrollment, + saveEnrollment: async (next) => { + warnOnce(); + enrollment = next; + }, + clearEnrollment: async () => { + enrollment = null; + }, + loadAcl: async (hostId) => filterAclRecords(hostId, acl.get(hostId) ?? []), + saveAcl: async (hostId, records) => { + warnOnce(); + acl.set(hostId, [...records]); + }, + }; +} diff --git a/lib/src/host/remote/link-client.test.ts b/lib/src/host/remote/link-client.test.ts new file mode 100644 index 00000000..d939d0de --- /dev/null +++ b/lib/src/host/remote/link-client.test.ts @@ -0,0 +1,171 @@ +/** + * The webview's end of the bridge, minus the transport — the part every host + * shares, so a rule proved here holds in the Tauri app, the dev harness, and VS + * Code alike. What each host adds on top (which message carries what) is + * covered by its own adapter test. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + answerAskCommand, + createRemoteHostLinkClient, + notifyCommand, + REMOTE_HOST_COMMAND_TIMEOUT_MS, + type RemoteHostLinkClient, +} from './link-client'; +import type { RemoteHostCommand } from './service-protocol'; + +function fakeTransport() { + const sent: RemoteHostCommand[] = []; + const answers: Array<{ askId: string; results: unknown[] }> = []; + let notified = 0; + return { + sent, + answers, + notified: () => notified, + client(): RemoteHostLinkClient { + return createRemoteHostLinkClient({ + sendCommand: (command) => void sent.push(command), + answerAsk: (askId, results) => void answers.push({ askId, results }), + notify: () => void (notified += 1), + }); + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('commands', () => { + it('resolves the one command its rhId names', async () => { + const transport = fakeTransport(); + const client = transport.client(); + + const pending = client.link.command('status'); + const rhId = transport.sent[0]!.rhId; + expect(transport.sent[0]).toMatchObject({ cmd: 'status' }); + + // A result for somebody else's command must not settle this one. + client.onResult({ rhId: 'other', result: { enrolled: false } }); + client.onResult({ rhId, result: { enrolled: true } }); + expect(await pending).toEqual({ enrolled: true }); + }); + + it('rejects with the error the service reported', async () => { + const transport = fakeTransport(); + const client = transport.client(); + const pending = client.link.command('enroll', { serverUrl: 'https://nope' }); + client.onResult({ rhId: transport.sent[0]!.rhId, error: 'outside the allowed sources' }); + await expect(pending).rejects.toThrow('outside the allowed sources'); + }); + + it('mints ids no sibling webview can collide with', () => { + // Every webview sees every result, so two of them minting `rh-1` would + // settle each other's commands. + const a = fakeTransport(); + const b = fakeTransport(); + void a.client().link.command('status'); + void b.client().link.command('status'); + expect(a.sent[0]!.rhId).not.toBe(b.sent[0]!.rhId); + }); + + it('gives up at the timeout rather than hanging', async () => { + vi.useFakeTimers(); + const transport = fakeTransport(); + const pending = transport.client().link.command('status'); + const rejected = expect(pending).rejects.toThrow('timed out'); + await vi.advanceTimersByTimeAsync(REMOTE_HOST_COMMAND_TIMEOUT_MS); + await rejected; + }); + + it('ignores a result nothing is waiting for', () => { + const client = fakeTransport().client(); + expect(() => client.onResult({ rhId: 'nope', result: {} })).not.toThrow(); + expect(() => client.onResult(undefined)).not.toThrow(); + }); + + it('rejects everything outstanding when the bridge closes', async () => { + const client = fakeTransport().client(); + const pending = client.link.command('status'); + client.dispose(); + await expect(pending).rejects.toThrow('remote host bridge closed'); + }); +}); + +describe('asks', () => { + it('answers with what the responder claims', () => { + const transport = fakeTransport(); + const client = transport.client(); + client.link.respond('surfaceOp', (params) => [ + { ptyId: 'pty-1', surfaceId: (params as { surfaceId: string }).surfaceId }, + ]); + + client.onAsk('ask-1', 'surfaceOp', { surfaceId: 's1' }); + expect(transport.answers).toEqual([ + { askId: 'ask-1', results: [{ ptyId: 'pty-1', surfaceId: 's1' }] }, + ]); + }); + + it('always answers — with no responder, and with a responder that threw', () => { + // The service holds the ask open for its whole budget otherwise, and an + // attach waits on it. An empty answer claims nothing, so it cannot beat the + // webview that really owns the surface. + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const transport = fakeTransport(); + const client = transport.client(); + + client.onAsk('ask-1', 'directory', {}); + client.link.respond('directory', () => { + throw new Error('registry exploded'); + }); + client.onAsk('ask-2', 'directory', {}); + + expect(transport.answers).toEqual([ + { askId: 'ask-1', results: [] }, + { askId: 'ask-2', results: [] }, + ]); + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); +}); + +describe('events and notifies', () => { + it('fans an event to its own subscribers only, until they unsubscribe', () => { + const client = fakeTransport().client(); + const seen: unknown[] = []; + const unsubscribe = client.link.on('pairing-queue', (data) => void seen.push(data)); + + client.onEvent({ name: 'pairing-queue', queue: [{ clientId: 'c1' }] }); + client.onEvent({ name: 'something-else', queue: [] }); + client.onEvent(null); + expect(seen).toEqual([{ name: 'pairing-queue', queue: [{ clientId: 'c1' }] }]); + + unsubscribe(); + client.onEvent({ name: 'pairing-queue', queue: [] }); + expect(seen).toHaveLength(1); + }); + + it('sends a notify through the transport, not as a command', () => { + const transport = fakeTransport(); + transport.client().link.notify(); + expect(transport.notified()).toBe(1); + expect(transport.sent).toEqual([]); + }); +}); + +describe('tunnelled envelopes', () => { + it('carries the ask’s own id in the params, never in the envelope', () => { + // The envelope's `rhId` is answered by nobody; the service settles the ask + // named inside it. + const answer = answerAskCommand('ask-1', [{ ptyId: 'pty-1' }]); + expect(answer).toMatchObject({ + cmd: 'answer', + params: { rhId: 'ask-1', results: [{ ptyId: 'pty-1' }] }, + }); + expect(answer.rhId).not.toBe('ask-1'); + // A notify names nothing: the directory is the only answer a peer gives. + expect(notifyCommand()).toMatchObject({ cmd: 'notify' }); + expect(notifyCommand().params).toBeUndefined(); + }); +}); diff --git a/lib/src/host/remote/link-client.ts b/lib/src/host/remote/link-client.ts new file mode 100644 index 00000000..d2610665 --- /dev/null +++ b/lib/src/host/remote/link-client.ts @@ -0,0 +1,171 @@ +/** + * The webview's end of the Host service bridge, minus the transport. + * + * Every host that runs a Host service gives its webview the same + * {@link RemoteHostLink}: commands out with a bounded wait for their result, + * asks in with an answer that always comes back, and pushed events fanned to + * whoever subscribed. What differs between the Tauri app, the browser dev + * harness, and VS Code is only how a message travels — one Rust invoke, one dev + * WebSocket, one `postMessage` — so that is the injected part and everything + * else lives here. Three copies of the correlation and timeout rules is three + * chances for one host to settle a command the others would not. + * + * The contract on the wire is `service-protocol.ts`; this is the client half of + * it. + */ + +import type { RemoteHostLink } from '../../lib/platform/types'; +import type { RemoteHostCommand, RemoteHostResult } from './service-protocol'; + +/** + * How long a command may wait for the service. Generous — `enroll` makes an + * HTTP round trip to the relay server — but finite, so a sidecar that died or a + * broker window that closed surfaces as a rejected promise instead of a hung + * console call. + */ +export const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; + +/** How this host moves a message to the service. */ +export interface RemoteHostLinkTransport { + /** Send one command; its result arrives back through {@link RemoteHostLinkClient.onResult}. */ + sendCommand(cmd: RemoteHostCommand): void; + /** Answer an outstanding ask. `askId` is the ask's own id, never a new one. */ + answerAsk(askId: string, results: unknown[]): void; + /** Announce that future answers may differ. */ + notify(): void; +} + +export interface RemoteHostLinkClient { + link: RemoteHostLink; + /** A `remoteHost:result` arrived. */ + onResult(payload: RemoteHostResult | undefined): void; + /** A `remoteHost:ask` arrived; answering is this client's job, not the caller's. */ + onAsk(askId: string, op: string, params: unknown): void; + /** A `remoteHost:event` arrived, carrying its own `name`. */ + onEvent(payload: unknown): void; + /** The bridge is gone: nothing will ever answer what is outstanding. */ + dispose(): void; +} + +/** + * A short random component for this client's `rhId`s. Results are broadcast to + * every webview the service can reach, so a plain counter would let two of them + * mint the same id and settle each other's commands. + */ +function randomTag(): string { + const uuid = globalThis.crypto?.randomUUID?.(); + return uuid ? uuid.slice(0, 8) : Math.random().toString(36).slice(2, 10); +} + +let envelopeSeq = 0; + +/** + * Wrap an answer as an ordinary command, for a transport with one channel to + * the service (standalone's single passthrough invoke). The envelope's own + * `rhId` is never answered — the ask's id travels in the params — so it only + * has to exist. + */ +export function answerAskCommand(askId: string, results: unknown[]): RemoteHostCommand { + return { rhId: `rh-tunnel-${++envelopeSeq}`, cmd: 'answer', params: { rhId: askId, results } }; +} + +/** {@link answerAskCommand}, for a notify — which carries nothing but its name. */ +export function notifyCommand(): RemoteHostCommand { + return { rhId: `rh-tunnel-${++envelopeSeq}`, cmd: 'notify' }; +} + +export function createRemoteHostLinkClient( + transport: RemoteHostLinkTransport, +): RemoteHostLinkClient { + interface Pending { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + const pending = new Map(); + const responders = new Map unknown[]>(); + const listeners = new Map void>>(); + const tag = randomTag(); + let seq = 0; + + const link: RemoteHostLink = { + command(cmd, params) { + const rhId = `rh-${tag}-${++seq}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(rhId); + reject(new Error(`remote host command timed out: ${cmd}`)); + }, REMOTE_HOST_COMMAND_TIMEOUT_MS); + pending.set(rhId, { resolve, reject, timer }); + transport.sendCommand({ rhId, cmd, params }); + }); + }, + + respond(op, handler) { + responders.set(op, handler); + }, + + notify() { + transport.notify(); + }, + + on(name, listener) { + let named = listeners.get(name); + if (!named) { + named = new Set(); + listeners.set(name, named); + } + const subscribed = named; + subscribed.add(listener); + return () => { + subscribed.delete(listener); + }; + }, + }; + + return { + link, + + onResult(payload) { + const settled = payload ? pending.get(payload.rhId) : undefined; + if (!settled || !payload) return; + pending.delete(payload.rhId); + clearTimeout(settled.timer); + if (typeof payload.error === 'string') settled.reject(new Error(payload.error)); + else settled.resolve(payload.result); + }, + + /** + * Answer what this webview's own panes are called and how big they are. + * + * Always answer, even with no responder installed and even to say nothing: + * the service settles once everyone has replied, so silence would make it + * wait out the full budget on what is usually a miss. An empty answer claims + * nothing, so it can never beat the real owner. + */ + onAsk(askId, op, params) { + const handler = responders.get(op); + let results: unknown[] = []; + try { + results = handler ? handler(params) : []; + } catch (error) { + console.error(`[dormouse] remote host ask ${op} failed:`, error); + } + transport.answerAsk(askId, results); + }, + + onEvent(payload) { + const name = (payload as { name?: unknown } | null)?.name; + if (typeof name !== 'string') return; + for (const listener of listeners.get(name) ?? []) listener(payload); + }, + + dispose() { + for (const settled of pending.values()) { + clearTimeout(settled.timer); + settled.reject(new Error('remote host bridge closed')); + } + pending.clear(); + }, + }; +} diff --git a/lib/src/host/remote/pty-strip.test.ts b/lib/src/host/remote/pty-strip.test.ts new file mode 100644 index 00000000..8aa9d7da --- /dev/null +++ b/lib/src/host/remote/pty-strip.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { createPtyStrip } from './pty-strip'; + +const ESC = '\x1b'; +const BEL = '\x07'; + +describe('createPtyStrip', () => { + it('passes ordinary output through untouched', () => { + const strip = createPtyStrip(); + expect(strip('hello\r\n$ ')).toBe('hello\r\n$ '); + }); + + it('removes the semantic OSCs the webview would have stripped', () => { + const strip = createPtyStrip(); + // The phone renders the same bytes the laptop's xterm does, and the laptop + // never sees these (docs/specs/terminal-escapes.md). + expect(strip(`${ESC}]7;file:///tmp${BEL}ready`)).toBe('ready'); + expect(strip(`${ESC}]133;A${BEL}$ `)).toBe('$ '); + expect(strip(`${ESC}]0;my title${BEL}x`)).toBe('x'); + }); + + it('never surfaces a protocol response as output', () => { + const strip = createPtyStrip(); + // The iTerm2 identity query is answered by the webview that owns the + // terminal; a second answer from here would corrupt the PTY's input, so the + // query is stripped and its answer discarded. + expect(strip(`${ESC}[>qdone`)).toBe('done'); + }); + + it('holds an OSC split across two chunks until it completes', () => { + const strip = createPtyStrip(); + expect(strip(`a${ESC}]133;`)).toBe('a'); + expect(strip(`A${BEL}b`)).toBe('b'); + }); + + it('keeps per-stream state to itself', () => { + const first = createPtyStrip(); + const second = createPtyStrip(); + first(`${ESC}]133;`); + // The second stream's bytes must not be swallowed by the first's pending OSC. + expect(second('plain')).toBe('plain'); + }); + + it('swallows a color query rather than passing it to the phone', () => { + const strip = createPtyStrip(); + // The local adapter answers OSC 10/11/12 from the real theme. Left in the + // stream this query reaches the phone's xterm, which answers it too, and + // the second reply is written into the PTY's input — so it is consumed here + // and the answer generated for it is thrown away. + const out = strip(`before${ESC}]11;?${BEL}after`); + expect(out).toBe('beforeafter'); + expect(out).not.toContain('?'); + expect(out).not.toContain('rgb:'); + expect(strip(`${ESC}]10;?${BEL}x${ESC}]12;?${BEL}y`)).toBe('xy'); + }); +}); diff --git a/lib/src/host/remote/pty-strip.ts b/lib/src/host/remote/pty-strip.ts new file mode 100644 index 00000000..9d47fe67 --- /dev/null +++ b/lib/src/host/remote/pty-strip.ts @@ -0,0 +1,43 @@ +/** + * The strip-only terminal-protocol parser a Node-resident Host runs over each + * PTY it streams to a Client. + * + * The phone renders the same bytes the laptop's own xterm renders, and the + * webview strips before rendering (`docs/specs/terminal-escapes.md` → the + * `pty:data` strip semantics). A raw PTY stream would therefore show the phone + * OSC sequences the laptop never sees, so the stream is stripped here too. + * + * Every event the parser produces is discarded, responses included. The webview + * that owns the terminal already answers its queries; a second answer from this + * process would write duplicate bytes into the PTY's input and corrupt whatever + * the program was parsing. Semantic events (cwd, prompt, title) are the + * webview's to record for the same reason — this parser exists only to decide + * which bytes are visible. + * + * "Discarded" and "not parsed" are different things, and the difference is a + * bug: a query the parser declines stays in `visibleData` and reaches the + * phone, which answers it. See {@link CONSUME_COLOR_QUERIES}. + */ + +import { TerminalProtocolParser } from '../../lib/terminal-protocol'; + +/** + * A colour for the parser to answer OSC 10/11/12 queries with. + * + * The value is never sent anywhere: the response the parser generates is + * discarded with every other event, and the local adapter stays the only thing + * that answers a color query. It exists solely so the query is *consumed* — + * without a provider the parser declines and leaves the query in `visibleData`, + * where it reaches the phone's xterm, which answers it too and writes a second + * reply into the PTY's input. + */ +const CONSUME_COLOR_QUERIES = (): string => '#000000'; + +/** + * A per-attachment stripper. Stateful — an OSC split across two PTY chunks is + * held until it completes — so one is created per stream and never shared. + */ +export function createPtyStrip(): (data: string) => string { + const parser = new TerminalProtocolParser(CONSUME_COLOR_QUERIES); + return (data) => parser.process(data).visibleData; +} diff --git a/lib/src/host/remote/serial-queue.ts b/lib/src/host/remote/serial-queue.ts new file mode 100644 index 00000000..755cd371 --- /dev/null +++ b/lib/src/host/remote/serial-queue.ts @@ -0,0 +1,28 @@ +/** + * One-at-a-time execution for the Host's stores and its lifecycle. + * + * Everything that queues here is a read-modify-write of shared state across an + * await — a whole-file rewrite, a keychain round trip, a Host that is read then + * built — so two of them running together can interleave and land the older one + * last, silently de-pairing a device or leaving a second relay socket nobody + * holds a reference to. + */ + +/** + * A queue that runs `work` after everything already queued and hands back its + * result. + * + * The chain continues through a failure — one unwritable moment must not stop + * every later save — while the caller still sees the rejection. + */ +export function createSerialQueue(): (work: () => PromiseLike) => Promise { + let tail: Promise = Promise.resolve(); + return (work: () => PromiseLike): Promise => { + const result = tail.then(work, work); + tail = result.then( + () => {}, + () => {}, + ); + return result; + }; +} diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts new file mode 100644 index 00000000..fe356d33 --- /dev/null +++ b/lib/src/host/remote/service-protocol.ts @@ -0,0 +1,175 @@ +/** + * The bridge between the Node-resident Host service and the webview that shows + * its UI. Shared by both ends so the contract cannot drift: the service imports + * it to dispatch, the webview imports it to speak. + * + * Three message kinds, all JSON: + * + * webview → service `remoteHost:command` { rhId, cmd, params? } + * service → webview `remoteHost:result` { rhId, result } | { rhId, error } + * service → webview `remoteHost:ask` { rhId, op, params } + * service → webview `remoteHost:event` { name, ... } + * + * ⚠ The correlation field is `rhId`, never `requestId`. The standalone Rust + * bridge swallows any sidecar line whose `data.requestId` matches a pending + * invoke (`standalone/src-tauri/src/lib.rs`), so a `requestId` here would make + * results vanish at random. + * + * The service asks the webview only what the webview alone knows: what its + * panes are called and how big its terminals are. Everything else — the relay + * socket, the enrollment, the ACL, the access decision — is the service's, and + * a webview answer can never widen it. + */ + +import type { PairingRequest } from 'server-lib-common'; +import type { RemoteHostStatus } from '../../remote/host/remote-host'; + +/** Transport event names for what the service sends back. */ +export const REMOTE_HOST_RESULT_EVENT = 'remoteHost:result'; +export const REMOTE_HOST_ASK_EVENT = 'remoteHost:ask'; +export const REMOTE_HOST_EVENT_EVENT = 'remoteHost:event'; + +/** + * How long the service waits for the webview to answer an ask before it + * proceeds with what it has. An attach must not hang on a webview that is + * mid-reload, and a directory snapshot that misses a pane is recoverable — the + * next change re-collects. + */ +export const ASK_BUDGET_MS = 1_000; + +/** webview → service. `params` is the command's own shape, below. */ +export interface RemoteHostCommand { + rhId: string; + cmd: string; + params?: unknown; +} + +/** Validate the untrusted edge of either Host bridge before routing a command. */ +export function isRemoteHostCommand(value: unknown): value is RemoteHostCommand { + if (!value || typeof value !== 'object') return false; + const command = value as Partial; + return typeof command.rhId === 'string' && typeof command.cmd === 'string'; +} + +/** service → webview, in reply to a command that has a result. */ +export interface RemoteHostResult { + rhId: string; + result?: unknown; + error?: string; +} + +/** service → webview: answer with `answer` naming this `rhId`. */ +export interface RemoteHostAsk { + rhId: string; + op: string; + params: unknown; +} + +/** One pairing awaiting local approval, as the webview mirrors it. */ +export interface PairingQueueItem { + clientId: string; + /** Immutable ceremony ticket id, echoed by approve/deny. */ + pairingId: string; + request: PairingRequest; + requestedAt: number; +} + +/** + * service → webview, unsolicited. The queue snapshot is complete every time: + * the service is authoritative, so the webview replaces rather than merges. + */ +export interface PairingQueueEvent { + name: 'pairing-queue'; + queue: PairingQueueItem[]; +} + +/** + * service → webview, whenever the Host's lifecycle changes whether there is one + * at all. What a webview does for the Host costs a crossing per pane-state, + * activity, and focus change, so an installation that never enrolled must pay + * none of it (`lib/src/remote/host/enrolled-gate.ts`). + */ +export interface HostStatusEvent { + name: 'status'; + enrolled: boolean; +} + +// --- Command parameter shapes --- + +export interface EnrollParams { + serverUrl: string; + password: string; + label: string; +} + +export interface ApproveParams { + clientId: string; + pairingId: string; + label?: string; +} + +export interface DenyParams { + clientId: string; + pairingId: string; +} + +/** The webview names the Session and what to call it; recipients are never its call. */ +export interface PushParams { + sessionId: string; + title: string; +} + +/** One-shot hand-off of a webview-persisted Host (see `activation.ts`). */ +export interface AdoptParams { + enrollment: unknown; + aclRecords: unknown[]; +} + +/** + * Whether the service now holds this Host somewhere that survives a restart — + * because it just wrote the enrollment, or because it already had one of its + * own. The webview drops its localStorage copy only on `true`: behind an + * in-memory store (the dev harness with no state directory) that copy is the + * only one that outlives the process, and clearing it would lose the Host at + * the next launch. + */ +export interface AdoptResult { + persisted: boolean; +} + +/** Answers an outstanding {@link RemoteHostAsk}; `rhId` is the ask's, not a new one. */ +export interface AnswerParams { + rhId: string; + results: unknown[]; +} + +// --- Command results --- + +export interface EnrollResult { + hostId: string; + serverUrl: string; +} + +/** + * What `window.dormouseRemoteHost.status()` prints. SELF_HOST.md documents these + * field names, so they are part of the user-facing surface. + */ +export interface RemoteHostConsoleStatus { + enrolled: boolean; + serverUrl: string | null; + hostId: string | null; + /** + * The relay socket's state. `displaced` is the one that needs acting on: + * another Dormouse instance enrolled with the same `hostId` took the relay + * slot, so this one stood down and no timer will bring it back — `reconnect()` + * takes the slot back (and displaces the other one in turn). + */ + connection: RemoteHostStatus; + pairedClients: number; +} + +/** + * The devices a push would reach, or `null` when no Host is running — which is + * "nowhere to push", not "the server could not be asked" (`push-devices.ts`). + */ +export type PushDevicesResult = { devices: Array<{ devicePublicKey: string; label: string }> } | null; diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts new file mode 100644 index 00000000..b901cdf1 --- /dev/null +++ b/lib/src/host/remote/service.test.ts @@ -0,0 +1,761 @@ +/** + * The Node-resident Host, driven the way both of its neighbours drive it: the + * webview through `handleCommand`, and the relay through a fake `/ws/host` + * socket. The point of most cases here is that nothing a webview says can widen + * access — recipients, the ACL, and the allowlist are all read on this side. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { HostAclRecord, PairingRequest } from 'server-lib-common'; +import type { HostEnrollment } from '../../remote/host/enrollment'; +import type { HostSurfaceProvider } from '../../remote/host/host-surface-provider'; +import { FakeSocket } from '../../remote/test-fake-socket'; +import { createEphemeralHostStateStore, type HostStateStore } from './host-state-store'; +import { RemoteHostService } from './service'; +import type { + HostStatusEvent, + PairingQueueEvent, + RemoteHostConsoleStatus, +} from './service-protocol'; + +const CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +const ENROLLMENT: HostEnrollment = { + serverUrl: 'https://relay.dormouse.sh', + hostId: 'host-1', + hostToken: 'tok', + origin: 'https://relay.dormouse.sh', + rpId: 'relay.dormouse.sh', +}; + +const PAIRING: PairingRequest = { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', +}; + +function aclRecord(devicePublicKey: string, label = 'iPhone Safari'): HostAclRecord { + return { + hostId: 'host-1', + accountId: 'owner', + passkeyCredentialId: 'cred', + passkeyPublicKeyHash: 'hash', + devicePublicKey, + approvedAt: 1, + approvedBy: 'host-user', + label, + revokedAt: null, + }; +} + +interface MemoryStore extends HostStateStore { + enrollment: HostEnrollment | null; + acl: Record; +} + +/** + * A durable store whose contents a test can seed and read back — not + * `createEphemeralHostStateStore`, whose whole point is `persistent: false`, + * which is what the adopt cases turn on. + */ +function memoryStore(seed: Partial> = {}): MemoryStore { + const store: MemoryStore = { + persistent: true, + enrollment: seed.enrollment ?? null, + acl: seed.acl ?? {}, + loadEnrollment: async () => store.enrollment, + saveEnrollment: async (enrollment) => { + store.enrollment = enrollment; + }, + clearEnrollment: async () => { + store.enrollment = null; + }, + loadAcl: async (hostId) => store.acl[hostId] ?? [], + saveAcl: async (hostId, records) => { + store.acl[hostId] = [...records]; + }, + }; + return store; +} + +function fakeProvider(): HostSurfaceProvider { + return { + collectDirectory: async () => [], + watchDirectory: () => () => {}, + resolveSurface: async () => null, + writePty: () => {}, + resizePty: () => {}, + streamPty: () => () => {}, + }; +} + +let sockets: FakeSocket[]; +let sent: Array<{ event: string; data: Record }>; +let requests: Array<{ url: string; init?: RequestInit }>; +let store: MemoryStore; +let service: RemoteHostService; +let commandSeq = 0; + +/** A server that answers enroll, push/send, and push/devices. */ +function fakeFetch(): typeof globalThis.fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url.endsWith('/api/host/enroll')) { + return { + ok: true, + json: async () => ({ + hostId: 'host-1', + hostToken: 'tok', + origin: new URL(url).origin, + rpId: new URL(url).hostname, + }), + } as Response; + } + if (url.endsWith('/api/push/devices')) { + return { + ok: true, + json: async () => ({ + devices: [ + { devicePublicKey: 'device-1', subscribedAt: 1 }, + { devicePublicKey: 'device-revoked', subscribedAt: 1 }, + ], + }), + } as Response; + } + return { + ok: true, + json: async () => ({ delivered: 1, expired: 0, unknown: 0, failed: 0 }), + } as Response; + }) as unknown as typeof globalThis.fetch; +} + +function createService(seed?: Partial>): RemoteHostService { + store = memoryStore(seed); + service = new RemoteHostService({ + store, + provider: fakeProvider(), + sendToUi: (event, data) => sent.push({ event, data: data as Record }), + connectSrc: CONNECT_SRC, + createWebSocket: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + fetch: fakeFetch(), + }); + return service; +} + +/** Run a command and return the `remoteHost:result` it produced. */ +async function command(cmd: string, params?: unknown): Promise> { + const rhId = `c-${++commandSeq}`; + await service.handleCommand({ rhId, cmd, params }); + const result = sent + .filter((message) => message.event === 'remoteHost:result') + .find((message) => message.data.rhId === rhId); + if (!result) throw new Error(`no result for ${cmd}`); + return result.data; +} + +function queueEvents(): PairingQueueEvent[] { + return uiEvents().filter((event): event is PairingQueueEvent => event.name === 'pairing-queue'); +} + +function uiEvents(): Array { + return sent + .filter((message) => message.event === 'remoteHost:event') + .map((message) => message.data as unknown as PairingQueueEvent | HostStatusEvent); +} + +/** What the webviews were told about whether there is a Host, in order. */ +function statusEvents(): boolean[] { + return uiEvents() + .filter((event): event is HostStatusEvent => event.name === 'status') + .map((event) => event.enrolled); +} + +beforeEach(() => { + sockets = []; + sent = []; + requests = []; + vi.stubGlobal('fetch', fakeFetch()); +}); + +afterEach(() => { + service?.dispose(); + vi.unstubAllGlobals(); +}); + +describe('status', () => { + it('reports a Host that has not been enrolled', async () => { + createService(); + await service.start(); + expect((await command('status')).result).toEqual({ + enrolled: false, + serverUrl: null, + hostId: null, + connection: 'stopped', + pairedClients: 0, + } satisfies RemoteHostConsoleStatus); + }); + + it('reports the relay socket and the paired count once running', async () => { + createService({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + await service.start(); + sockets[0]!.open(); + + expect((await command('status')).result).toEqual({ + enrolled: true, + serverUrl: ENROLLMENT.serverUrl, + hostId: 'host-1', + connection: 'connected', + pairedClients: 1, + } satisfies RemoteHostConsoleStatus); + }); + + it('rejects a command it does not know', async () => { + createService(); + expect((await command('nope')).error).toContain('nope'); + }); +}); + +describe('enroll', () => { + it('refuses an origin outside the build’s allowed sources', async () => { + createService(); + const result = await command('enroll', { + serverUrl: 'https://relay.example.com', + password: 'setup', + label: 'Laptop', + }); + + expect(result.error).toContain(CONNECT_SRC); + // Refused before the setup password leaves the machine. + expect(requests).toEqual([]); + expect(store.enrollment).toBeNull(); + }); + + it('enrolls, persists, and starts against an allowed origin', async () => { + createService(); + const result = await command('enroll', { + serverUrl: 'https://relay.dormouse.sh/', + password: 'setup', + label: 'Laptop', + }); + + expect(result.result).toEqual({ hostId: 'host-1', serverUrl: 'https://relay.dormouse.sh' }); + expect(store.enrollment?.hostToken).toBe('tok'); + expect(sockets).toHaveLength(1); + }); + + it('replaces a running Host rather than adding one', async () => { + createService({ enrollment: ENROLLMENT }); + await service.start(); + sockets[0]!.open(); + + await command('enroll', { + serverUrl: 'https://other.dormouse.sh', + password: 'setup', + label: 'Laptop', + }); + expect(sockets).toHaveLength(2); + expect(sockets[0]!.readyState).toBe(3); + }); + + it('keeps the old Host when the new enrollment cannot be persisted', async () => { + // The `hostToken` this exchange just minted exists nowhere else and cannot + // be minted again, so stopping the old Host before the save is what turns + // one failed write into a machine with no Host and a status that lies. + createService({ enrollment: ENROLLMENT }); + await service.start(); + sockets[0]!.open(); + store.saveEnrollment = async () => { + throw new Error('keychain is locked'); + }; + + const result = await command('enroll', { + serverUrl: 'https://other.dormouse.sh', + password: 'setup', + label: 'Laptop', + }); + + expect(result.error).toContain('keychain is locked'); + expect(sockets).toHaveLength(1); + expect(sockets[0]!.readyState).toBe(1); + expect((await command('status')).result).toMatchObject({ + enrolled: true, + serverUrl: ENROLLMENT.serverUrl, + connection: 'connected', + }); + }); + + it('cycles the enrolled gate when it swaps one running Host for another', async () => { + // The webviews' gate is edge-triggered (`enrolled-gate.ts`), and what it + // holds — the mirrored pairing queue, the push device list — belongs to the + // server being left. With no `false` between the two Hosts the gate never + // cycles and the Settings dialog keeps naming the old server's devices. + createService({ enrollment: ENROLLMENT }); + await service.start(); + sockets[0]!.open(); + expect(statusEvents()).toEqual([true]); + + await command('enroll', { + serverUrl: 'https://other.dormouse.sh', + password: 'setup', + label: 'Laptop', + }); + + expect(statusEvents()).toEqual([true, false, true]); + }); +}); + +describe('start', () => { + it('stays idle, loudly, when the persisted server is no longer allowed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createService({ enrollment: { ...ENROLLMENT, serverUrl: 'https://relay.example.com' } }); + await service.start(); + + expect(sockets).toEqual([]); + expect(warn).toHaveBeenCalled(); + expect((await command('status')).result).toMatchObject({ connection: 'stopped' }); + warn.mockRestore(); + }); + + it('reconnect is the way back, and start()s a Host that never ran', async () => { + createService({ enrollment: ENROLLMENT }); + const status = (await command('reconnect')).result as RemoteHostConsoleStatus; + expect(sockets).toHaveLength(1); + expect(status).toMatchObject({ enrolled: true, connection: 'connecting' }); + }); + + it('builds one Host when a start and an adopt race', async () => { + // Both read `#host`, both await the store, and both then act on what they + // read. Unserialized they each see no Host and each build one — and the + // second holds a relay socket nothing has a reference to, so it can never + // be stopped and the two displace each other on the server forever. + createService({ enrollment: ENROLLMENT }); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const seeded = store.loadEnrollment; + store.loadEnrollment = async () => { + await gate; + return seeded(); + }; + + const started = service.start(); + const adopted = service.handleCommand({ rhId: 'race', cmd: 'adopt', params: { enrollment: ENROLLMENT, aclRecords: [] } }); + release(); + await Promise.all([started, adopted]); + + expect(sockets).toHaveLength(1); + // And the one that exists is the one `dispose()` can reach. + service.dispose(); + expect(sockets[0]!.readyState).toBe(3); + }); + + it('does not resurrect a Host when disposal lands during startup', async () => { + createService({ enrollment: ENROLLMENT }); + let releaseAcl: () => void = () => {}; + let enteredAcl: () => void = () => {}; + const entered = new Promise((resolve) => { + enteredAcl = resolve; + }); + const gate = new Promise((resolve) => { + releaseAcl = resolve; + }); + const seeded = store.loadAcl; + store.loadAcl = async (hostId) => { + enteredAcl(); + await gate; + return seeded(hostId); + }; + + const starting = service.start(); + await entered; + service.dispose(); + releaseAcl(); + await starting; + + expect(sockets).toEqual([]); + expect(sent).toEqual([]); + }); + + it('clearEnrollment stops the Host and forgets it, keeping the records', async () => { + createService({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + await service.start(); + + await command('clearEnrollment'); + expect(store.enrollment).toBeNull(); + // The records stay filed under their hostId: re-enrolling onto the same + // host must not silently de-pair every device. + expect(store.acl['host-1']).toHaveLength(1); + expect((await command('status')).result).toMatchObject({ enrolled: false, connection: 'stopped' }); + }); + + it('stays enrolled — and running — when the enrollment cannot be deleted', async () => { + // Reporting un-enrolled over a delete that failed is the worst outcome + // available: the credential is still on disk, so the next launch reads it + // back and every paired device is let in again by a Host the user believes + // they removed. + createService({ enrollment: ENROLLMENT }); + await service.start(); + sockets[0]!.open(); + store.clearEnrollment = async () => { + throw new Error('keychain is locked'); + }; + + expect((await command('clearEnrollment')).error).toContain('keychain is locked'); + expect(store.enrollment).toEqual(ENROLLMENT); + expect(sockets[0]!.readyState).toBe(1); + expect((await command('status')).result).toMatchObject({ + enrolled: true, + serverUrl: ENROLLMENT.serverUrl, + connection: 'connected', + }); + expect(statusEvents()).toEqual([true]); + }); +}); + +describe('adopt', () => { + it('takes a webview-persisted Host when there is none, and starts it', async () => { + createService(); + const result = await command('adopt', { + enrollment: ENROLLMENT, + aclRecords: [aclRecord('device-1')], + }); + + // `persisted` is what tells the webview it may drop its own copy. + expect(result.result).toEqual({ persisted: true }); + expect(store.enrollment).toEqual(ENROLLMENT); + expect(store.acl['host-1']).toHaveLength(1); + expect(sockets).toHaveLength(1); + }); + + it('keeps the Host it already has', async () => { + createService({ enrollment: ENROLLMENT }); + await service.start(); + + const other = { ...ENROLLMENT, hostId: 'host-2', hostToken: 'other' }; + const result = await command('adopt', { enrollment: other, aclRecords: [] }); + + expect(store.enrollment).toEqual(ENROLLMENT); + expect(sockets).toHaveLength(1); + // The webview's copy is obsolete regardless: a second copy of one hostId is + // a second ACL, and this store is holding a Host that survives a restart. + expect(result.result).toEqual({ persisted: true }); + }); + + it('refuses an origin outside the build’s allowed sources', async () => { + // A Host handed over from an older build's localStorage may name a relay + // this build may not reach; adopting it would connect there anyway. + createService(); + const result = await command('adopt', { + enrollment: { ...ENROLLMENT, serverUrl: 'https://relay.example.com' }, + aclRecords: [], + }); + + expect(result.error).toContain(CONNECT_SRC); + expect(store.enrollment).toBeNull(); + expect(sockets).toEqual([]); + }); + + it('persists no enrollment when the ACL write fails', async () => { + // Order matters: the records go first, so a failure here leaves the store + // with no enrollment and the next launch re-adopts from the webview's copy + // rather than running a Host whose devices were silently dropped. + createService(); + store.saveAcl = async () => { + throw new Error('globalState is full'); + }; + + const result = await command('adopt', { + enrollment: ENROLLMENT, + aclRecords: [aclRecord('device-1')], + }); + + expect(result.error).toContain('globalState is full'); + expect(store.enrollment).toBeNull(); + expect(sockets).toEqual([]); + }); + + it('runs a session Host from an in-memory store, and says it did not persist', async () => { + // The dev harness with no state directory: the Host has to work for the + // session, but the webview's copy is the only one that outlives it. + const warnings: string[] = []; + const ephemeral = createEphemeralHostStateStore((message) => warnings.push(message)); + service = new RemoteHostService({ + store: ephemeral, + provider: fakeProvider(), + sendToUi: (event, data) => sent.push({ event, data: data as Record }), + connectSrc: CONNECT_SRC, + createWebSocket: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + fetch: fakeFetch(), + }); + + const result = await command('adopt', { + enrollment: ENROLLMENT, + aclRecords: [aclRecord('device-1')], + }); + + expect(result.result).toEqual({ persisted: false }); + expect(sockets).toHaveLength(1); + expect(await ephemeral.loadAcl('host-1')).toHaveLength(1); + expect(warnings).toHaveLength(1); + }); + + it('drops records that name another host', async () => { + createService(); + await command('adopt', { + enrollment: ENROLLMENT, + aclRecords: [{ ...aclRecord('device-1'), hostId: 'somebody-else' }], + }); + expect(store.acl['host-1']).toBeUndefined(); + }); + + it('ignores an enrollment that does not have the shape', async () => { + createService(); + await command('adopt', { enrollment: { hostId: 'x' }, aclRecords: [] }); + expect(store.enrollment).toBeNull(); + expect(sockets).toEqual([]); + }); +}); + +describe('status events', () => { + it('announces a Host that started, and one that was cleared', async () => { + // What every webview arms its outbound work on: an installation that never + // enrolls is told nothing and does nothing (`enrolled-gate.ts`). + createService({ enrollment: ENROLLMENT }); + await service.start(); + expect(statusEvents()).toEqual([true]); + + await command('clearEnrollment'); + expect(statusEvents()).toEqual([true, false]); + }); + + it('says nothing at all when there is no Host to run', async () => { + createService(); + await service.start(); + await command('status'); + expect(statusEvents()).toEqual([]); + }); + + it('announces the Host an enroll and an adopt each started', async () => { + createService(); + await command('enroll', { + serverUrl: 'https://relay.dormouse.sh', + password: 'setup', + label: 'Laptop', + }); + expect(statusEvents()).toEqual([true]); + + createService(); + sent.length = 0; + await command('adopt', { enrollment: ENROLLMENT, aclRecords: [] }); + expect(statusEvents()).toEqual([true]); + }); +}); + +describe('pairing queue', () => { + async function running(): Promise { + createService({ enrollment: ENROLLMENT }); + await service.start(); + const socket = sockets[0]!; + socket.open(); + return socket; + } + + it('pushes a snapshot when a pairing arrives, and answers a seed request', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + + const event = queueEvents().at(-1)!; + expect(event.name).toBe('pairing-queue'); + expect(event.queue).toHaveLength(1); + expect(event.queue[0]).toMatchObject({ clientId: 'c1', request: PAIRING }); + expect(typeof event.queue[0]!.pairingId).toBe('string'); + expect(typeof event.queue[0]!.requestedAt).toBe('number'); + + // A webview that reloaded mid-pairing seeds from the same snapshot. + expect((await command('pairingQueue')).result).toEqual(event.queue); + }); + + it('approve runs the real ceremony, persists, and empties the queue', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + const pairingId = queueEvents().at(-1)!.queue[0]!.pairingId; + + await command('approve', { clientId: 'c1', pairingId, label: 'Ned iPhone' }); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ clientId: 'c1', approved: true }); + expect((result.record as HostAclRecord).label).toBe('Ned iPhone'); + expect(store.acl['host-1']).toHaveLength(1); + expect(queueEvents().at(-1)!.queue).toEqual([]); + }); + + it('deny answers the client and writes no ACL', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + const pairingId = queueEvents().at(-1)!.queue[0]!.pairingId; + + await command('deny', { clientId: 'c1', pairingId }); + + expect(socket.frames('pair-result')[0]).toMatchObject({ approved: false }); + expect(store.acl['host-1']).toBeUndefined(); + expect(queueEvents().at(-1)!.queue).toEqual([]); + }); + + it('drops a client that went away, and a queue the socket took with it', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + socket.receive({ t: 'client-gone', clientId: 'c1' }); + expect(queueEvents().at(-1)!.queue).toEqual([]); + + socket.receive({ t: 'pair', clientId: 'c2', request: PAIRING }); + expect(queueEvents().at(-1)!.queue).toHaveLength(1); + socket.close(); + expect(queueEvents().at(-1)!.queue).toEqual([]); + }); + + it('rejects approval for something already resolved', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + const pairingId = queueEvents().at(-1)!.queue[0]!.pairingId; + await command('approve', { clientId: 'c1', pairingId }); + expect((await command('approve', { clientId: 'c1', pairingId })).error).toContain( + 'no longer pending', + ); + expect(socket.frames('pair-result')).toHaveLength(1); + }); + + it('rejects stale modal actions after the client replaces its pairing request', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + const firstId = queueEvents().at(-1)!.queue[0]!.pairingId; + + const replacement = { + ...PAIRING, + devicePublicKey: 'device-2', + requestedLabel: 'Android Chrome', + }; + socket.receive({ t: 'pair', clientId: 'c1', request: replacement }); + const replacementItem = queueEvents().at(-1)!.queue[0]!; + expect(replacementItem.pairingId).not.toBe(firstId); + + // Both buttons from the still-rendered first modal are now stale. Neither + // may resolve or authorize the replacement request before it is shown. + expect((await command('approve', { clientId: 'c1', pairingId: firstId })).error).toContain( + 'no longer pending', + ); + expect((await command('deny', { clientId: 'c1', pairingId: firstId })).error).toContain( + 'no longer pending', + ); + expect(socket.frames('pair-result')).toEqual([]); + expect(store.acl['host-1']).toBeUndefined(); + expect(queueEvents().at(-1)!.queue).toEqual([replacementItem]); + + await command('approve', { clientId: 'c1', pairingId: replacementItem.pairingId }); + expect(socket.frames('pair-result')[0]).toMatchObject({ + clientId: 'c1', + approved: true, + record: { devicePublicKey: 'device-2' }, + }); + }); +}); + +describe('push', () => { + const sendBody = (): Record | null => { + const request = requests.filter((r) => r.url.endsWith('/api/push/send')).at(-1); + return request ? (JSON.parse(String(request.init?.body)) as Record) : null; + }; + + it('addresses the Host’s own ACL, not anything the webview sent', async () => { + createService({ + enrollment: ENROLLMENT, + acl: { 'host-1': [aclRecord('device-1'), aclRecord('device-2', 'iPad')] }, + }); + await service.start(); + + await command('push', { sessionId: 'pty-1', title: 'pnpm dev' }); + + expect(sendBody()).toMatchObject({ + devicePublicKeys: ['device-1', 'device-2'], + title: 'pnpm dev', + tag: 'pty-1', + }); + }); + + it('bounds the title the webview supplied', async () => { + createService({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + await service.start(); + + await command('push', { sessionId: 'pty-1', title: 'buildfinished' }); + expect(sendBody()).toMatchObject({ title: 'build finished' }); + }); + + it('is a silent no-op with no Host running', async () => { + createService(); + const result = await command('push', { sessionId: 'pty-1', title: 'x' }); + expect(result.error).toBeUndefined(); + expect(requests).toEqual([]); + }); + + it('warns rather than failing the command when the server rejects it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + store = memoryStore({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + service = new RemoteHostService({ + store, + provider: fakeProvider(), + sendToUi: (event, data) => sent.push({ event, data: data as Record }), + connectSrc: CONNECT_SRC, + createWebSocket: () => new FakeSocket(), + fetch: (async () => ({ ok: false, status: 401 })) as unknown as typeof globalThis.fetch, + }); + await service.start(); + + expect((await command('push', { sessionId: 'pty-1', title: 'x' })).error).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('pushDevices', () => { + it('joins the server’s subscriptions to the ACL’s labels', async () => { + createService({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + await service.start(); + + // `device-revoked` is subscribed on the server but no longer in the ACL. + expect((await command('pushDevices')).result).toEqual({ + devices: [{ devicePublicKey: 'device-1', label: 'iPhone Safari' }], + }); + }); + + it('answers null when no Host is running', async () => { + createService(); + // "Nowhere to push" — not an empty list, and not a failed request. + expect((await command('pushDevices')).result).toBeNull(); + }); + + it('errors when the server cannot be asked', async () => { + store = memoryStore({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); + service = new RemoteHostService({ + store, + provider: fakeProvider(), + sendToUi: (event, data) => sent.push({ event, data: data as Record }), + connectSrc: CONNECT_SRC, + createWebSocket: () => new FakeSocket(), + fetch: (async () => ({ ok: false, status: 500 })) as unknown as typeof globalThis.fetch, + }); + await service.start(); + + expect((await command('pushDevices')).error).toBeTruthy(); + }); +}); diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts new file mode 100644 index 00000000..c61853d2 --- /dev/null +++ b/lib/src/host/remote/service.ts @@ -0,0 +1,447 @@ +/** + * The remote Host as a service in the process that owns the PTYs. + * + * It holds everything an access decision depends on — the relay socket, the + * enrollment, the ACL, the pairing ceremony — and serves remote-api v1 through + * an injected {@link HostSurfaceProvider}. The webview keeps only what a webview + * is for: the approval modal, the console hook, and answering what its own panes + * are called and how big they are. Nothing a webview says can widen access. + * + * Every dependency is injected, so this module is environment-free: it runs in + * the Tauri sidecar (`sidecar-entry.ts`) and in the VS Code extension host + * (`vscode-ext/src/remote-host.ts`), and its tests drive it with a fake socket + * and an in-memory store. + * + * Commands arrive from the webview over the bridge in `service-protocol.ts` and + * are dispatched in {@link RemoteHostService.handleCommand}. The two that carry + * no reply — `answer` and `notify` — belong to whoever built the provider and + * are settled there (`sidecar-entry.ts`), so they never reach this dispatch. + */ + +import { filterAclRecords } from '../../remote/host/acl'; +import { isEnrollment, performEnrollment, type HostEnrollment } from '../../remote/host/enrollment'; +import type { HostSurfaceProvider } from '../../remote/host/host-surface-provider'; +import type { PendingPairing } from '../../remote/host/pairing-approval'; +import { loadPushDevices, sendPush, type AlertPushDeps } from '../../remote/host/push-delivery'; +import { RemoteApiSession } from '../../remote/host/remote-api'; +import { RemoteHost, type WebSocketLike } from '../../remote/host/remote-host'; +import { originAllowedByConnectSrc } from './connect-src'; +import type { HostStateStore } from './host-state-store'; +import { createSerialQueue } from './serial-queue'; +import { + REMOTE_HOST_EVENT_EVENT, + REMOTE_HOST_RESULT_EVENT, + isRemoteHostCommand, + type AdoptParams, + type AdoptResult, + type ApproveParams, + type DenyParams, + type EnrollParams, + type EnrollResult, + type HostStatusEvent, + type PairingQueueEvent, + type PairingQueueItem, + type PushDevicesResult, + type PushParams, + type RemoteHostConsoleStatus, +} from './service-protocol'; + +export interface RemoteHostServiceOptions { + store: HostStateStore; + provider: HostSurfaceProvider; + /** Emit one of the `remoteHost:*` events to the webview. */ + sendToUi: (event: string, data: unknown) => void; + /** The CSP-shaped allowlist this build was compiled with (`connect-src.ts`). */ + connectSrc: string; + createWebSocket?: (url: string) => WebSocketLike; + fetch?: typeof globalThis.fetch; + now?: () => number; +} + +export class RemoteHostService { + readonly #store: HostStateStore; + readonly #provider: HostSurfaceProvider; + readonly #sendToUi: (event: string, data: unknown) => void; + readonly #connectSrc: string; + readonly #createWebSocket?: (url: string) => WebSocketLike; + readonly #fetch?: typeof globalThis.fetch; + readonly #now: () => number; + + #host: RemoteHost | null = null; + #enrollment: HostEnrollment | null = null; + /** + * Everything that starts or stops the Host runs one at a time on this chain. + * + * Each of those reads `#host`, awaits a store round trip, and then acts on + * what it read — so overlapping them (an activation `start` and a webview's + * `adopt`, a reconnect during an enroll) lets two of them both see no Host and + * both build one. The second `RemoteHost` would hold a relay socket nothing + * has a reference to and could not be stopped, and the two would displace each + * other on the server forever. + */ + readonly #serialize = createSerialQueue(); + /** Disposal is terminal: no in-flight store read may resurrect the Host. */ + #disposed = false; + /** + * Pairings awaiting local approval, service-side. The webview mirrors a + * serializable projection of this and answers with its immutable pairing id; + * the approve/deny closures the `RemoteHost` handed us never leave this + * process. + */ + readonly #pairings = new Map(); + + constructor(options: RemoteHostServiceOptions) { + this.#store = options.store; + this.#provider = options.provider; + this.#sendToUi = options.sendToUi; + this.#connectSrc = options.connectSrc; + this.#createWebSocket = options.createWebSocket; + this.#fetch = options.fetch; + this.#now = options.now ?? (() => Date.now()); + } + + /** Start from a persisted enrollment, if there is one this build may reach. */ + start(): Promise { + if (this.#disposed) return Promise.resolve(); + return this.#serialize(() => this.#start()); + } + + async #start(): Promise { + const enrollment = await this.#store.loadEnrollment(); + if (!enrollment) return; + if (!this.#allowed(enrollment.serverUrl)) { + // Enrolled against an origin this build cannot connect to — a binary + // downgraded from a custom build, or a moved server. Idle rather than + // connect: the allowlist is the whole boundary (docs/specs/server.md). + console.warn( + `[remote-host] enrolled server ${enrollment.serverUrl} is outside this build's allowed sources (${this.#connectSrc}); staying idle`, + ); + return; + } + await this.#startHost(enrollment); + } + + /** Stop the Host and forget the connection-scoped state. */ + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#stopHost(); + } + + async handleCommand(raw: unknown): Promise { + if (this.#disposed || !isRemoteHostCommand(raw)) return; + const command = raw; + try { + const result = await this.#run(command.cmd, command.params); + if (this.#disposed) return; + this.#sendToUi(REMOTE_HOST_RESULT_EVENT, { rhId: command.rhId, result }); + } catch (error) { + if (this.#disposed) return; + this.#sendToUi(REMOTE_HOST_RESULT_EVENT, { + rhId: command.rhId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async #run(cmd: string, params: unknown): Promise { + switch (cmd) { + // The four that start or stop the Host share the lifecycle chain with + // `start()`; everything below only reads what they left. + case 'enroll': + return this.#serialize(() => this.#enroll(params as EnrollParams)); + case 'status': + return this.#status(); + case 'reconnect': + return this.#serialize(() => this.#reconnect()); + case 'clearEnrollment': + return this.#serialize(() => this.#clearEnrollment()); + case 'approve': + return this.#approve(params as ApproveParams); + case 'deny': + return this.#deny(params as DenyParams); + case 'push': + return this.#push(params as PushParams); + case 'pushDevices': + return this.#pushDevices(); + case 'pairingQueue': + return this.#queueSnapshot(); + case 'adopt': + return this.#serialize(() => this.#adopt(params as AdoptParams)); + default: + throw new Error(`unknown remote-host command: ${cmd}`); + } + } + + // --- Commands --- + + async #enroll(params: EnrollParams): Promise { + if (!this.#allowed(params.serverUrl)) { + // Refused before the password leaves the machine. Self-hosters widen the + // list in their own build (docs/specs/server.md → "Host webview CSP"). + throw new Error( + `${params.serverUrl} is outside this build's allowed remote sources (${this.#connectSrc}). ` + + 'A self-host build bakes its own via DORMOUSE_REMOTE_CONNECT_SRC.', + ); + } + const enrollment = await performEnrollment(params.serverUrl, params.password, params.label); + // Persist before touching the running Host. The credential we just minted + // exists nowhere else and cannot be minted again from the same password + // exchange, so a save that fails after the old Host had been stopped would + // strand the machine with no Host, a status that says otherwise, and a + // brand-new `hostToken` lost to the failure. Failing here instead leaves + // the old Host running and everything it reports still true. + await this.#store.saveEnrollment(enrollment); + if (this.#host) { + // Swapping one running Host for another. The gate the webviews arm their + // outbound work on is edge-triggered (`enrolled-gate.ts`), and everything + // it holds — the mirrored pairing queue, the push device list — belongs + // to the server we are leaving. Without a `false` between the two Hosts + // the gate never cycles: the Settings dialog keeps naming the old + // server's devices, and a device fetch already on the wire can land after + // the swap and put them back. + this.#stopHost(); + this.#enrollment = null; + this.#emitStatus(); + } + await this.#startHost(enrollment); + return { hostId: enrollment.hostId, serverUrl: enrollment.serverUrl }; + } + + #status(): RemoteHostConsoleStatus { + return { + enrolled: !!this.#enrollment, + serverUrl: this.#enrollment?.serverUrl ?? null, + hostId: this.#enrollment?.hostId ?? null, + connection: this.#host?.status ?? 'stopped', + pairedClients: this.#host?.activeRecords.length ?? 0, + }; + } + + /** + * Re-open the relay socket now. The only way back from `displaced`: an evicted + * Host stands down for good rather than fighting the Host that replaced it, so + * returning has to be asked for. + */ + async #reconnect(): Promise { + if (this.#host) this.#host.start(); + else await this.#start(); + return this.#status(); + } + + async #clearEnrollment(): Promise> { + // The delete first, and nothing else unless it succeeded. Stopping and + // forgetting the Host ahead of it would report un-enrolled while the + // credential was still on disk, and the next launch would read it back and + // let every paired device in again — an un-enrollment the user believes + // happened is the one thing this command must not get wrong. + // + // ACL records stay keyed by their hostId. They are unreachable without an + // enrollment naming that host, and keeping them means a re-enrollment onto + // the same hostId does not silently de-pair every device. + await this.#store.clearEnrollment(); + this.#stopHost(); + this.#enrollment = null; + this.#emitStatus(); + return {}; + } + + #approve(params: ApproveParams): Record { + this.#pendingPairing(params.clientId, params.pairingId).approve(params.label); + return {}; + } + + #deny(params: DenyParams): Record { + this.#pendingPairing(params.clientId, params.pairingId).deny(); + return {}; + } + + /** Resolve an action only against the exact request its modal displayed. */ + #pendingPairing(clientId: string, pairingId: string): PendingPairing { + const pending = this.#pairings.get(clientId); + if (!pending || pending.pairingId !== pairingId) { + throw new Error('pairing request is no longer pending'); + } + return pending; + } + + async #push(params: PushParams): Promise> { + const deps = this.#pushDeps(); + // No Host means no ACL and no server to post to; the ring is simply not + // pushed. Nothing to report to the webview, which cannot act on it either. + if (deps) { + // A push that fails must never break the alert path. + await sendPush(deps, params.sessionId, params.title).catch((error: unknown) => { + console.warn('[remote-host] push notification failed', error); + }); + } + return {}; + } + + async #pushDevices(): Promise { + const deps = this.#pushDeps(); + if (!deps) return null; + return { devices: await loadPushDevices(deps) }; + } + + async #adopt(params: AdoptParams): Promise { + const existing = await this.#store.loadEnrollment(); + // A store that keeps nothing across restarts (the dev harness) can run the + // Host for this session but must not be treated as having taken custody of + // it: the webview's copy is then the only one that survives. + const durable = this.#store.persistent; + let persisted = existing ? durable : false; + + if (!existing && isEnrollment(params.enrollment)) { + const enrollment = params.enrollment; + // The same gate as `#enroll`, for the same reason: a Host handed over from + // an older build's localStorage may name a relay this build is not allowed + // to reach, and adopting it would connect there anyway. + if (!this.#allowed(enrollment.serverUrl)) { + throw new Error( + `${enrollment.serverUrl} is outside this build's allowed remote sources (${this.#connectSrc}). ` + + 'A self-host build bakes its own via DORMOUSE_REMOTE_CONNECT_SRC.', + ); + } + // Records first, enrollment last. A failed ACL write then fails the whole + // adopt while the store still holds no enrollment, so the next launch + // re-adopts from the webview's copy and retries cleanly — the other order + // leaves a Host running with every paired device silently dropped. + const records = filterAclRecords(enrollment.hostId, params.aclRecords ?? []); + if (records.length > 0) await this.#store.saveAcl(enrollment.hostId, records); + await this.#store.saveEnrollment(enrollment); + persisted = durable; + } + // Either way there may now be a Host to run: an adoption just supplied one, + // and a rejected adoption means the store already had one this service may + // not have started yet (a webview that reloads before `start()` lands). + if (!this.#host) await this.#start(); + return { persisted }; + } + + // --- Host lifecycle --- + + #allowed(serverUrl: string): boolean { + try { + return originAllowedByConnectSrc(new URL(serverUrl).origin, this.#connectSrc); + } catch { + return false; + } + } + + async #startHost(enrollment: HostEnrollment): Promise { + if (this.#disposed) return; + // Never two. Callers are serialized (see `#lifecycle`), but a Host left in + // `#host` here would be dropped without its socket being closed, so the + // replacement is explicit rather than implied by the assignment below. + this.#stopHost(); + // The controller wants the ACL synchronously; the store is async because + // the places it lives are. Read it before constructing, and let saves run + // in the background — a failed write must not fail the pairing that is + // already approved and already on the wire. + const records = await this.#store.loadAcl(enrollment.hostId); + // Deactivation can land during that store round trip. Disposal is terminal: + // constructing here would leave a relay socket alive after its owner had + // dropped the service and could no longer stop it. + if (this.#disposed) return; + this.#enrollment = enrollment; + this.#host = new RemoteHost({ + enrollment, + createWebSocket: this.#createWebSocket, + createSession: (opts) => + new RemoteApiSession({ + hostId: opts.hostId, + send: opts.send, + provider: this.#provider, + }), + loadAcl: () => records, + saveAcl: (hostId, next) => { + void this.#store.saveAcl(hostId, next).catch((error: unknown) => { + console.warn('[remote-host] could not persist the ACL', error); + }); + }, + requestApproval: (pending) => this.#enqueuePairing(pending), + dismissApproval: (clientId) => this.#resolvePairing(clientId), + now: this.#now, + }); + this.#host.start(); + this.#emitStatus(); + } + + /** + * Tell the webviews whether there is a Host at all. Everything they do *for* + * one — announcing that the directory may have changed on every pane-state, + * activity, and focus change, watching for unattended rings — costs a + * crossing per event on a machine that may never enroll, so they arm on this + * and idle without it (`lib/src/remote/host/enrolled-gate.ts`). + * + * `enrolled` means the same thing as the `status` command's field of that + * name, which is how a webview seeds before any event arrives. + */ + #emitStatus(): void { + if (this.#disposed) return; + this.#sendToUi(REMOTE_HOST_EVENT_EVENT, this.statusEvent()); + } + + /** + * The status event as it stands, for a UI that arrived after the last change + * and so has no event coming (`vscode-ext/src/remote-host.ts` greets a window + * that joins the broker with it). + */ + statusEvent(): HostStatusEvent { + return { name: 'status', enrolled: !!this.#enrollment }; + } + + #stopHost(): void { + this.#host?.stop(); + this.#host = null; + // `stop()` dismisses every in-flight pairing, which empties the queue and + // pushes the empty snapshot; clear defensively in case there was no Host. + if (this.#pairings.size > 0) { + this.#pairings.clear(); + this.#emitQueue(); + } + } + + // --- Pairing queue --- + + #enqueuePairing(pending: PendingPairing): void { + // Coalesce by clientId: a re-sent pair for the same client replaces the old. + this.#pairings.set(pending.clientId, pending); + this.#emitQueue(); + } + + #resolvePairing(clientId: string): void { + if (!this.#pairings.delete(clientId)) return; + this.#emitQueue(); + } + + #queueSnapshot(): PairingQueueItem[] { + return [...this.#pairings.values()].map(({ clientId, pairingId, request, requestedAt }) => ({ + clientId, + pairingId, + request, + requestedAt, + })); + } + + #emitQueue(): void { + if (this.#disposed) return; + this.#sendToUi(REMOTE_HOST_EVENT_EVENT, { + name: 'pairing-queue', + queue: this.#queueSnapshot(), + } satisfies PairingQueueEvent); + } + + /** Push delivery needs a live Host: the ACL it reads is the running one's. */ + #pushDeps(): AlertPushDeps | null { + const host = this.#host; + const enrollment = this.#enrollment; + if (!host || !enrollment) return null; + return { + enrollment, + activeRecords: () => host.activeRecords, + fetch: this.#fetch, + }; + } +} diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts new file mode 100644 index 00000000..ce41bebe --- /dev/null +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -0,0 +1,296 @@ +/** + * The provider the sidecar hands the service: PTYs answered locally, everything + * about the webview's *view* of them asked over the bridge. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PtySink } from '../../remote/host/host-surface-provider'; +import { createSidecarSurfaceBridge, type SidecarSurfaceBridge } from './sidecar-entry'; +import { ASK_BUDGET_MS, type RemoteHostAsk } from './service-protocol'; + +let sent: Array<{ event: string; data: RemoteHostAsk }>; +let written: Array<{ id: string; data: string }>; +let resized: Array<{ id: string; cols: number; rows: number }>; +let livePtys: Set; +let bridge: SidecarSurfaceBridge; + +/** The ask the bridge is waiting on, most recent last. */ +function asks(): RemoteHostAsk[] { + return sent.filter((message) => message.event === 'remoteHost:ask').map((m) => m.data); +} + +function answer(ask: RemoteHostAsk, results: unknown[]): void { + bridge.onAnswer({ rhId: ask.rhId, results }); +} + +function sink(): PtySink & { data: string[]; exits: number[] } { + const record = { + data: [] as string[], + exits: [] as number[], + onData: (chunk: string) => void record.data.push(chunk), + onExit: (code: number) => void record.exits.push(code), + }; + return record; +} + +beforeEach(() => { + sent = []; + written = []; + resized = []; + livePtys = new Set(['pty-1', 'pty-2']); + bridge = createSidecarSurfaceBridge({ + send: (event, data) => sent.push({ event, data: data as RemoteHostAsk }), + mgr: { + write: (id, data) => void written.push({ id, data }), + resize: (id, cols, rows) => void resized.push({ id, cols, rows }), + hasPty: (id) => livePtys.has(id), + }, + }); +}); + +afterEach(() => { + bridge.dispose(); + vi.useRealTimers(); +}); + +describe('asking the webview', () => { + it('carries the op and its params, and settles on the answer', async () => { + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + expect(ask.op).toBe('directory'); + expect(typeof ask.rhId).toBe('string'); + + answer(ask, [{ surfaceId: 's1' }]); + expect(await pending).toEqual([{ surfaceId: 's1' }]); + }); + + it('settles on the first answer and ignores a later one', async () => { + // Standalone ships one window, so one answerer; a second is a stale reply. + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + answer(ask, [{ surfaceId: 'first' }]); + answer(ask, [{ surfaceId: 'second' }]); + expect(await pending).toEqual([{ surfaceId: 'first' }]); + }); + + it('gives up at the budget rather than hanging', async () => { + vi.useFakeTimers(); + const pending = bridge.provider.collectDirectory(); + await vi.advanceTimersByTimeAsync(ASK_BUDGET_MS); + expect(await pending).toEqual([]); + }); + + it('ignores an answer for an ask that is not outstanding', async () => { + expect(() => bridge.onAnswer({ rhId: 'nope', results: [] })).not.toThrow(); + expect(() => bridge.onAnswer(undefined)).not.toThrow(); + }); + + it('marks the directory stale when an answer lands after the budget', async () => { + // The snapshot the Host already rendered is missing whatever this answer + // names — an empty picker on a machine that does have terminals. Nothing + // re-opens a settled ask, so the next collect is the only repair, and an + // idle machine has no other reason to run one. + vi.useFakeTimers(); + const changes = vi.fn(); + bridge.provider.watchDirectory(changes); + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + await vi.advanceTimersByTimeAsync(ASK_BUDGET_MS); + expect(await pending).toEqual([]); + + answer(ask, [{ surfaceId: 's1' }]); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it('resolves everything outstanding when disposed', async () => { + const pending = bridge.provider.collectDirectory(); + bridge.dispose(); + expect(await pending).toEqual([]); + }); +}); + +describe('directory invalidation', () => { + it('fires watchers on a notify, and stops after unsubscribe', () => { + const changes = vi.fn(); + const unsubscribe = bridge.provider.watchDirectory(changes); + + bridge.onNotify(); + expect(changes).toHaveBeenCalledTimes(1); + + unsubscribe(); + bridge.onNotify(); + expect(changes).toHaveBeenCalledTimes(1); + }); +}); + +describe('resolveSurface', () => { + it('attaches at the requested size and reports what the owner settled at', async () => { + const pending = bridge.provider.resolveSurface('s1', { cols: 80, rows: 24 }); + const ask = asks()[0]!; + expect(ask.op).toBe('surfaceOp'); + expect(ask.params).toEqual({ surfaceId: 's1', op: 'attach', cols: 80, rows: 24 }); + + answer(ask, [{ ptyId: 'pty-1', cols: 80, rows: 24 }]); + const handle = (await pending)!; + expect(handle.ptyId).toBe('pty-1'); + expect([handle.cols, handle.rows]).toEqual([80, 24]); + }); + + it('is null when nobody owns the surface', async () => { + const pending = bridge.provider.resolveSurface('gone', {}); + answer(asks()[0]!, []); + expect(await pending).toBeNull(); + }); + + it('resizes through the owner and remembers what it reported', async () => { + const attach = bridge.provider.resolveSurface('s1', { cols: 80, rows: 24 }); + answer(asks()[0]!, [{ ptyId: 'pty-1', cols: 80, rows: 24 }]); + const handle = (await attach)!; + + const pending = handle.resize(100, 30); + const ask = asks()[1]!; + expect(ask.params).toEqual({ surfaceId: 's1', op: 'resize', cols: 100, rows: 30 }); + // The owner clamped it. + answer(ask, [{ ptyId: 'pty-1', cols: 100, rows: 28 }]); + + expect(await pending).toEqual({ cols: 100, rows: 28 }); + expect([handle.cols, handle.rows]).toEqual([100, 28]); + }); + + it('leaves the last known size standing when nobody answers a resize', async () => { + const attach = bridge.provider.resolveSurface('s1', {}); + answer(asks()[0]!, [{ ptyId: 'pty-1', cols: 80, rows: 24 }]); + const handle = (await attach)!; + + const pending = handle.resize(100, 30); + answer(asks()[1]!, []); + expect(await pending).toEqual({ cols: 80, rows: 24 }); + }); + + it('releases without asking anyone — the stream owns itself', async () => { + const attach = bridge.provider.resolveSurface('s1', {}); + answer(asks()[0]!, [{ ptyId: 'pty-1', cols: 80, rows: 24 }]); + const handle = (await attach)!; + + handle.release(); + expect(asks()).toHaveLength(1); + }); +}); + +describe('PTYs', () => { + it('writes and resizes straight through to the manager', () => { + bridge.provider.writePty('pty-1', 'ls\r'); + bridge.provider.resizePty('pty-1', 80, 24); + expect(written).toEqual([{ id: 'pty-1', data: 'ls\r' }]); + expect(resized).toEqual([{ id: 'pty-1', cols: 80, rows: 24 }]); + }); + + it('routes output by id, stripped', () => { + const one = sink(); + const two = sink(); + bridge.provider.streamPty('pty-1', one); + bridge.provider.streamPty('pty-2', two); + + bridge.onPtyEvent('data', { id: 'pty-1', data: `\x1b]133;A\x07$ ` }); + expect(one.data).toEqual(['$ ']); + expect(two.data).toEqual([]); + }); + + it('drops a chunk that was nothing but protocol', () => { + const one = sink(); + bridge.provider.streamPty('pty-1', one); + bridge.onPtyEvent('data', { id: 'pty-1', data: '\x1b]7;file:///tmp\x07' }); + expect(one.data).toEqual([]); + }); + + it('parses each PTY once, so a late joiner inherits the byte boundaries', () => { + const one = sink(); + const two = sink(); + bridge.provider.streamPty('pty-1', one); + // A second attachment starts mid-stream, after the OSC introducer. It + // inherits the parser rather than starting a fresh one mid-sequence, so it + // sees the same stripped output as the attachment that was there first. + bridge.onPtyEvent('data', { id: 'pty-1', data: '\x1b]133;' }); + bridge.provider.streamPty('pty-1', two); + bridge.onPtyEvent('data', { id: 'pty-1', data: 'A\x07hi' }); + + expect(one.data).toEqual(['hi']); + expect(two.data).toEqual(['hi']); + }); + + it('keeps one PTY’s half-read sequence out of another’s', () => { + const one = sink(); + const two = sink(); + bridge.provider.streamPty('pty-1', one); + bridge.provider.streamPty('pty-2', two); + + bridge.onPtyEvent('data', { id: 'pty-1', data: '\x1b]133;' }); + bridge.onPtyEvent('data', { id: 'pty-2', data: 'plain' }); + bridge.onPtyEvent('data', { id: 'pty-1', data: 'A\x07hi' }); + + expect(one.data).toEqual(['hi']); + expect(two.data).toEqual(['plain']); + }); + + it('reports an exit, defaulting a missing code to 0', () => { + const one = sink(); + bridge.provider.streamPty('pty-1', one); + bridge.onPtyEvent('exit', { id: 'pty-1', exitCode: 3 }); + bridge.onPtyEvent('exit', { id: 'pty-1', signal: 'SIGTERM' }); + expect(one.exits).toEqual([3, 0]); + }); + + it('replays an exit that landed before the stream was installed', () => { + // pty-core emits before removing the generation from its live map. + bridge.onPtyEvent('exit', { id: 'pty-1', exitCode: 23 }); + livePtys.delete('pty-1'); + + const late = sink(); + const subscription = bridge.provider.streamPty('pty-1', late); + + expect(late.exits).toEqual([23]); + expect(() => subscription.stop()).not.toThrow(); + }); + + it('does not replay an old exit after the PTY id is reused', () => { + bridge.onPtyEvent('exit', { id: 'pty-1', exitCode: 23 }); + // The manager has already installed a fresh generation under the id. + expect(livePtys.has('pty-1')).toBe(true); + + const replacement = sink(); + bridge.provider.streamPty('pty-1', replacement); + bridge.onPtyEvent('data', { id: 'pty-1', data: 'new generation' }); + + expect(replacement.exits).toEqual([]); + expect(replacement.data).toEqual(['new generation']); + }); + + it('stops delivering after unsubscribe', () => { + const one = sink(); + const subscription = bridge.provider.streamPty('pty-1', one); + subscription.stop(); + bridge.onPtyEvent('data', { id: 'pty-1', data: 'x' }); + expect(one.data).toEqual([]); + }); + + it('does not let a spent unsubscribe silence the attachment that replaced it', () => { + const first = sink(); + const subscription = bridge.provider.streamPty('pty-1', first); + subscription.stop(); + + // A new attachment to the same id gets a fresh stream, which the previous + // subscription's unsubscribe has no claim on. + const second = sink(); + bridge.provider.streamPty('pty-1', second); + subscription.stop(); + + bridge.onPtyEvent('data', { id: 'pty-1', data: 'still flowing' }); + expect(second.data).toEqual(['still flowing']); + expect(first.data).toEqual([]); + }); + + it('ignores events with no id', () => { + expect(() => bridge.onPtyEvent('data', { data: 'x' })).not.toThrow(); + expect(() => bridge.onPtyEvent('data', null)).not.toThrow(); + }); +}); diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts new file mode 100644 index 00000000..523bf25a --- /dev/null +++ b/lib/src/host/remote/sidecar-entry.ts @@ -0,0 +1,265 @@ +/** + * The Tauri sidecar's binding of {@link RemoteHostService}: bundled to + * `standalone/sidecar/remote-host.cjs` by + * `standalone/scripts/build-sidecar-proxy.mjs` and required from + * `standalone/sidecar/main.js`. + * + * The sidecar owns the PTYs, so writes, resizes, and output go straight to + * `pty-core`'s manager. What it does *not* own is the webview's view of itself + * — what a pane is called, whether it is focused, how big its xterm is — so + * those are asked over the bridge in `service-protocol.ts` and answered by the + * surface responder in `lib/src/remote/host/peer-surfaces.ts`. + * + * All logging goes to stderr: stdout is the JSON-lines protocol channel. + */ + +import type { HostSurfaceProvider, PtySink } from '../../remote/host/host-surface-provider'; +import { createAskSurfaceProvider } from './ask-surface-provider'; +import { bakedConnectSrc } from './connect-src'; +import { createEphemeralHostStateStore, FileHostStateStore } from './host-state-store'; +import { createPtyStrip } from './pty-strip'; +import { RemoteHostService } from './service'; +import { + ASK_BUDGET_MS, + REMOTE_HOST_ASK_EVENT, + isRemoteHostCommand, + type AnswerParams, +} from './service-protocol'; + +/** The slice of `pty-core`'s manager the Host drives. */ +export interface SidecarPtyManager { + write(id: string, data: string): void; + resize(id: string, cols: number, rows: number): void; + /** Whether the current PTY generation still has a live process. */ + hasPty(id: string): boolean; +} + +export interface SidecarSurfaceBridgeOptions { + /** Writes one JSON line to the Rust bridge, which emits it to the webview. */ + send: (event: string, data: unknown) => void; + mgr: SidecarPtyManager; +} + +export interface SidecarSurfaceBridge { + provider: HostSurfaceProvider; + /** An `answer` command: settles the ask it names. */ + onAnswer(params: AnswerParams | undefined): void; + /** A `notify` command: something the directory depends on changed. */ + onNotify(): void; + /** A `pty-core` event, tapped before it goes to the webview. */ + onPtyEvent(event: string, data: unknown): void; + dispose(): void; +} + +/** + * The provider half: PTYs answered locally, everything about the *view* of them + * asked of the webview. Separate from {@link createSidecarRemoteHost} so it can + * be driven directly by tests, and so the next Host to move into its own process + * can reuse the ask machinery without the sidecar's file store. + */ +export function createSidecarSurfaceBridge( + options: SidecarSurfaceBridgeOptions, +): SidecarSurfaceBridge { + interface PendingAsk { + settle(results: unknown[]): void; + } + const asks = new Map(); + let askSeq = 0; + + function ask(op: string, params: unknown): Promise { + const rhId = `ask-${++askSeq}`; + return new Promise((resolve) => { + const timer = setTimeout(() => { + // Budget spent. An attach must not hang on a webview that is reloading, + // and a directory that missed a pane re-collects on the next change. + asks.delete(rhId); + resolve([]); + }, ASK_BUDGET_MS); + // An outstanding ask must never hold the sidecar's event loop open. + (timer as unknown as { unref?: () => void }).unref?.(); + asks.set(rhId, { + settle: (results) => { + clearTimeout(timer); + asks.delete(rhId); + resolve(results); + }, + }); + options.send(REMOTE_HOST_ASK_EVENT, { rhId, op, params }); + }); + } + + interface Stream { + /** + * One parser per PTY, not per subscription: what an incomplete escape + * sequence leaves behind belongs to *this* PTY's byte boundaries and must + * never be mixed with another's. A late joiner inherits the state from + * before it joined, which beats a fresh parser starting mid-sequence. + */ + strip: (data: string) => string; + sinks: Set; + } + const streams = new Map(); + /** Natural exits outlive their process so a late subscription can replay one. */ + const exits = new Map(); + + const { provider, notifyDirectoryChanged } = createAskSurfaceProvider(ask, { + writePty: (ptyId, data) => options.mgr.write(ptyId, data), + resizePty: (ptyId, cols, rows) => options.mgr.resize(ptyId, cols, rows), + + streamPty(ptyId, sink) { + let stream = streams.get(ptyId); + if (!stream) { + stream = { strip: createPtyStrip(), sinks: new Set() }; + streams.set(ptyId, stream); + } + const subscribed = stream; + subscribed.sinks.add(sink); + const unsubscribe = () => { + // Only while the map still holds the very stream this subscription + // joined. Once the last sink leaves, the entry goes and a later + // attachment to the same id gets a fresh one — so an unsubscribe run + // twice would delete *that* one and silence a stream still flowing. + // Same guard, same reason, as `vscode-ext/src/processed-pty-streams.ts`. + if (streams.get(ptyId) !== subscribed) return; + subscribed.sinks.delete(sink); + if (subscribed.sinks.size > 0) return; + // The parser goes with the last attachment: keeping it would carry a + // half-read sequence into a stream that starts over. + streams.delete(ptyId); + }; + + // Subscribe first, then inspect the manager on the same event-loop turn. + // An earlier exit is in `exits`; a later one reaches the sink above. A + // live result also identifies a new PTY generation that reused this id, + // so its predecessor's recorded exit can be forgotten safely. + let alive: boolean; + try { + alive = options.mgr.hasPty(ptyId); + } catch (error) { + unsubscribe(); + throw error; + } + if (alive) { + exits.delete(ptyId); + } else { + const exitCode = exits.get(ptyId) ?? 0; + unsubscribe(); + sink.onExit(exitCode); + } + + return { stop: unsubscribe, ready: Promise.resolve() }; + }, + }); + + return { + provider, + + /** + * The first answer settles the ask. Standalone ships one window, so there is + * exactly one answerer today; the multi-window seam + * (docs/specs/standalone.md) is where this becomes "collect until the + * budget". + */ + onAnswer(params) { + if (!params || typeof params.rhId !== 'string') return; + const pending = asks.get(params.rhId); + if (!pending) { + // The budget expired before this answer arrived, so the snapshot the + // Host already rendered is missing whatever it names — an empty + // directory on a machine that does have terminals. Nothing re-opens a + // settled ask, so mark the directory stale and let the next collect + // repair it; otherwise an idle machine has no other reason to + // re-collect and the phone's picker stays wrong indefinitely. + notifyDirectoryChanged(); + return; + } + pending.settle(Array.isArray(params.results) ? params.results : []); + }, + + onNotify() { + notifyDirectoryChanged(); + }, + + onPtyEvent(event, data) { + const detail = data as { id?: unknown } | null; + if (!detail || typeof detail.id !== 'string') return; + if (event === 'exit') { + const exitCode = (detail as { exitCode?: unknown }).exitCode; + exits.set(detail.id, typeof exitCode === 'number' ? exitCode : 0); + } + // Nothing is attached: data can stay cheap, but exits above are durable + // because a surface resolution may already be in flight without a sink. + if (streams.size === 0) return; + const stream = streams.get(detail.id); + if (!stream) return; + if (event === 'data') { + const chunk = (detail as { data?: unknown }).data; + if (typeof chunk !== 'string') return; + const visible = stream.strip(chunk); + if (visible === '') return; + // Iterated live rather than copied: a sink can only unsubscribe itself + // from here, which a Set tolerates mid-iteration. + for (const sink of stream.sinks) sink.onData(visible); + return; + } + if (event === 'exit') { + const code = exits.get(detail.id) ?? 0; + for (const sink of stream.sinks) sink.onExit(code); + } + }, + + dispose() { + for (const pending of [...asks.values()]) pending.settle([]); + asks.clear(); + streams.clear(); + exits.clear(); + }, + }; +} + +export interface SidecarRemoteHostOptions extends SidecarSurfaceBridgeOptions { + /** Where the enrollment + ACL file lives; absent in the browser dev harness. */ + stateDir?: string; +} + +export interface SidecarRemoteHost { + /** One `remoteHost:command` line from the webview. */ + handleCommand(data: unknown): void; + onPtyEvent(event: string, data: unknown): void; + dispose(): void; +} + +export function createSidecarRemoteHost(options: SidecarRemoteHostOptions): SidecarRemoteHost { + const store = options.stateDir + ? new FileHostStateStore(options.stateDir) + : createEphemeralHostStateStore((message) => console.error(message)); + + const bridge = createSidecarSurfaceBridge(options); + + const service = new RemoteHostService({ + store, + provider: bridge.provider, + sendToUi: options.send, + connectSrc: bakedConnectSrc(), + }); + void service.start().catch((error: unknown) => { + console.error(`[remote-host] failed to start: ${String(error)}`); + }); + + return { + handleCommand(data) { + if (!isRemoteHostCommand(data)) return; + const command = data; + // Both of these feed something already waiting on this side, so they + // answer nothing and never reach the service's dispatch. + if (command.cmd === 'answer') return bridge.onAnswer(command.params as AnswerParams); + if (command.cmd === 'notify') return bridge.onNotify(); + void service.handleCommand(command); + }, + onPtyEvent: bridge.onPtyEvent, + dispose() { + service.dispose(); + bridge.dispose(); + }, + }; +} diff --git a/lib/src/lib/local-json-store.test.ts b/lib/src/lib/local-json-store.test.ts index de4f2964..080684ba 100644 --- a/lib/src/lib/local-json-store.test.ts +++ b/lib/src/lib/local-json-store.test.ts @@ -1,14 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { loadJson, saveJson } from './local-json-store'; +import { loadJson, removeJson, saveJson } from './local-json-store'; + +/** A Map-backed `Storage` surface. */ +function memoryStore() { + const map = new Map(); + return { + map, + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + }; +} 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; + const backend = memoryStore(); + vi.stubGlobal('localStorage', backend); + return backend.map; } interface Widget { @@ -84,4 +91,18 @@ describe('local-json-store', () => { expect(() => saveJson('k', { id: 'w1' })).not.toThrow(); }); }); + describe('removeJson', () => { + it('deletes the stored value', () => { + const store = stubLocalStorage(); + saveJson('k', { id: 'w1' }); + removeJson('k'); + expect(store.has('k')).toBe(false); + expect(loadJson('k', null, isWidget)).toBeNull(); + }); + + it('does not throw when localStorage is absent', () => { + vi.stubGlobal('localStorage', undefined); + expect(() => removeJson('k')).not.toThrow(); + }); + }); }); diff --git a/lib/src/lib/local-json-store.ts b/lib/src/lib/local-json-store.ts index 9318baf9..9ea6c830 100644 --- a/lib/src/lib/local-json-store.ts +++ b/lib/src/lib/local-json-store.ts @@ -70,3 +70,12 @@ export function saveJson(key: string, value: unknown): void { // No localStorage / quota exceeded: the in-memory value still works. } } + +/** Delete the value at `key`, swallowing any failure. */ +export function removeJson(key: string): void { + try { + globalThis.localStorage?.removeItem(key); + } catch { + // No storage: nothing to clear. + } +} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 60378abe..36153f2b 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -113,11 +113,56 @@ export interface AgentBrowserPopResult { error?: string; } +/** + * The webview end of a Node-resident remote Host + * (`lib/src/host/remote/service-protocol.ts`). + * + * The Host runs in the process that owns the PTYs — the Tauri sidecar, the VS + * Code extension host — so the webview is its UI plus its surface responder: it + * forwards console commands, answers what its own panes are called and how big + * they are, and mirrors the pairing queue. Nothing a webview answers can widen + * access (docs/specs/remote-security-model.md). + * + * `cmd` and `op` are deliberately opaque here. *What* the service can be asked + * belongs to the remote Host, not to the platform, so the operation map and its + * real types live in `lib/src/remote/host/peer-surfaces.ts`; this layer and the + * transports under it only carry the bytes. + */ +export interface RemoteHostLink { + /** Run a service command and resolve its result, or reject with its error. */ + command(cmd: string, params?: unknown): Promise; + + /** Answer `op` on behalf of this webview's own surfaces; no results = not mine. */ + respond(op: string, handler: (params: unknown) => unknown[]): void; + + /** + * Announce that future answers may differ. Carries no subject: the directory + * is the only thing a peer can be asked to answer, so naming it would be a + * field every layer copies and nobody reads. + */ + notify(): void; + + /** + * Subscribe to one of the service's pushed events by name (`pairing-queue`), + * receiving the event object the service sent — its `name` included. Returns + * the unsubscribe. + */ + on(name: string, listener: (data: unknown) => void): () => void; +} + export interface PlatformAdapter { // Lifecycle init(): Promise; shutdown(): void; + /** + * Reach the remote Host service behind this host. Present exactly when a + * process behind the webview owns the PTYs and can run the Host (standalone's + * sidecar, VS Code's extension host). Adapters that omit it have no Host + * anywhere — the website — so the remote modules stay inert. + */ + remoteHost?: RemoteHostLink; + // Shell detection getAvailableShells(): Promise; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index f457e333..4ddd6aab 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -44,36 +44,39 @@ function hostMessage(data: Record, token: unknown = HOST_TOKEN) }); } -describe('VSCodeAdapter PTY exit handling', () => { - let windowTarget: EventTarget; - let postMessage: ReturnType; - - beforeEach(() => { - windowTarget = new EventTarget(); - postMessage = vi.fn(); - terminalThemeMocks.listeners.clear(); - terminalThemeMocks.getTerminalTheme.mockReturnValue({ foreground: '#eeeeee', background: '#111111', cursor: '#abcabc' }); - class TestCustomEvent extends Event { - readonly detail: T; - - constructor(type: string, eventInitDict?: CustomEventInit) { - super(type, eventInitDict); - this.detail = eventInitDict?.detail as T; - } - - initCustomEvent(): void {} +let windowTarget: EventTarget; +let postMessage: ReturnType; + +/** The globals the adapter captures at construction. Shared by the suites below. */ +function stubWebviewEnv(): void { + windowTarget = new EventTarget(); + postMessage = vi.fn(); + terminalThemeMocks.listeners.clear(); + terminalThemeMocks.getTerminalTheme.mockReturnValue({ foreground: '#eeeeee', background: '#111111', cursor: '#abcabc' }); + class TestCustomEvent extends Event { + readonly detail: T; + + constructor(type: string, eventInitDict?: CustomEventInit) { + super(type, eventInitDict); + this.detail = eventInitDict?.detail as T; } - vi.stubGlobal('window', windowTarget); - vi.stubGlobal('CustomEvent', TestCustomEvent); - // The adapter captures this at construction, so it must be stubbed before - // any `new VSCodeAdapter()` below. - vi.stubGlobal(HOST_MESSAGE_TOKEN_GLOBAL, HOST_TOKEN); - vi.stubGlobal('acquireVsCodeApi', () => ({ - postMessage, - getState: vi.fn(), - setState: vi.fn(), - })); - }); + + initCustomEvent(): void {} + } + vi.stubGlobal('window', windowTarget); + vi.stubGlobal('CustomEvent', TestCustomEvent); + // The adapter captures this at construction, so it must be stubbed before + // any `new VSCodeAdapter()`. + vi.stubGlobal(HOST_MESSAGE_TOKEN_GLOBAL, HOST_TOKEN); + vi.stubGlobal('acquireVsCodeApi', () => ({ + postMessage, + getState: vi.fn(), + setState: vi.fn(), + })); +} + +describe('VSCodeAdapter PTY exit handling', () => { + beforeEach(stubWebviewEnv); afterEach(() => { vi.unstubAllGlobals(); @@ -377,3 +380,101 @@ describe('VSCodeAdapter PTY exit handling', () => { }); }); }); + + +// The remote Host lives in the extension host, in whichever VS Code window won +// the bind (vscode-ext/src/remote-host.ts). This is the webview's end of that +// bridge; the contract is lib/src/host/remote/service-protocol.ts. +// +// Only what this transport adds is covered here: which message carries what, +// and the host-token guard in front of all of it. The correlation, timeout, +// always-answer, and dispose rules are the shared client's +// (lib/src/host/remote/link-client.test.ts). +describe('VSCodeAdapter remote host link', () => { + beforeEach(stubWebviewEnv); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + /** Every `remoteHost:command` this adapter has posted, in order. */ + function sent(): Array<{ rhId: string; cmd: string; params?: unknown }> { + return postMessage.mock.calls + .map((call) => call[0]) + .filter((message) => message.type === 'remoteHost:command') + .map((message) => message.payload); + } + + function deliver(data: Record): void { + windowTarget.dispatchEvent(hostMessage(data)); + } + + it('posts a command and settles it from the result message', async () => { + const adapter = new VSCodeAdapter(); + const pending = adapter.remoteHost.command('status'); + + const payload = sent()[0]!; + expect(payload.cmd).toBe('status'); + deliver({ type: 'remoteHost:result', payload: { rhId: payload.rhId, result: { enrolled: true } } }); + + expect(await pending).toEqual({ enrolled: true }); + }); + + it('answers an ask from the registered responder', () => { + const adapter = new VSCodeAdapter(); + adapter.remoteHost.respond('surfaceOp', (params) => [ + { ptyId: 'pty-1', ...(params as Record) }, + ]); + + deliver({ type: 'peer:ask', requestId: 'ask-1', op: 'surfaceOp', params: { surfaceId: 's1' } }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'peer:answer', + requestId: 'ask-1', + results: [{ ptyId: 'pty-1', surfaceId: 's1' }], + }); + }); + + it('fans an extension-host event out by name', () => { + const adapter = new VSCodeAdapter(); + const seen: unknown[] = []; + adapter.remoteHost.on('pairing-queue', (data) => void seen.push(data)); + + deliver({ type: 'remoteHost:event', payload: { name: 'pairing-queue', queue: [{ clientId: 'c1' }] } }); + expect(seen).toEqual([{ name: 'pairing-queue', queue: [{ clientId: 'c1' }] }]); + }); + + it('notifies without waiting for anything', () => { + const adapter = new VSCodeAdapter(); + adapter.remoteHost.notify(); + expect(postMessage).toHaveBeenCalledWith({ type: 'peer:notify' }); + }); + + it('rejects what is still in flight when the webview shuts down', async () => { + // The extension host cleans up the PTYs, but nothing there will ever answer + // a command this webview is still holding. + const adapter = new VSCodeAdapter(); + const pending = adapter.remoteHost.command('status'); + adapter.shutdown(); + await expect(pending).rejects.toThrow('remote host bridge closed'); + }); + + it('ignores an unauthenticated result, so framed content cannot settle a command', async () => { + const adapter = new VSCodeAdapter(); + vi.useFakeTimers(); + try { + const pending = adapter.remoteHost.command('status'); + const rejected = expect(pending).rejects.toThrow(/timed out/); + windowTarget.dispatchEvent( + new MessageEvent('message', { + data: { type: 'remoteHost:result', payload: { rhId: sent()[0]!.rhId, result: 'forged' } }, + }), + ); + await vi.advanceTimersByTimeAsync(20_000); + await rejected; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 8829b161..17490a3c 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,5 +1,6 @@ -import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo } from './types'; +import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo, RemoteHostLink } from './types'; import { OPEN_PORT_TIMEOUT_MS } from './types'; +import { createRemoteHostLinkClient } from '../../host/remote/link-client'; import type { AlertSettings } from '../alert-settings'; import { readInjectedRecoveryCommands } from '../vscode-recovery-global'; import { setDefaultShellOpts } from '../shell-defaults'; @@ -36,6 +37,23 @@ export class VSCodeAdapter implements PlatformAdapter { private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); private watchedCommandHandlers = new Set<(names: string[]) => void>(); private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>(); + // --- Remote host bridge (docs/specs/remote-api.md) --- + // + // The Host lives in the extension host, next to the PTYs, in whichever VS + // Code window won the bind-as-lease. This webview forwards its console + // commands, answers what only it knows (pane names, xterm sizes), and mirrors + // the pairing queue. Everything but the three postMessage shapes below is the + // shared client's (lib/src/host/remote/link-client.ts). + private readonly remoteHostClient = createRemoteHostLinkClient({ + sendCommand: (payload) => this.vscode.postMessage({ type: 'remoteHost:command', payload }), + // An ask arrives as `peer:ask` and is answered on the same pair, which the + // extension host's fan-out settles by `requestId`. + answerAsk: (requestId, results) => + this.vscode.postMessage({ type: 'peer:answer', requestId, results }), + notify: () => this.vscode.postMessage({ type: 'peer:notify' }), + }); + + readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; constructor() { this.vscode = acquireVsCodeApi(); @@ -157,6 +175,12 @@ export class VSCodeAdapter implements PlatformAdapter { respond, }, })); + } else if (msg.type === 'peer:ask') { + this.remoteHostClient.onAsk(msg.requestId, msg.op, msg.params); + } else if (msg.type === 'remoteHost:result') { + this.remoteHostClient.onResult(msg.payload); + } else if (msg.type === 'remoteHost:event') { + this.remoteHostClient.onEvent(msg.payload); } }); } @@ -197,7 +221,9 @@ export class VSCodeAdapter implements PlatformAdapter { } shutdown(): void { - // No-op — the extension host handles cleanup + // The extension host handles PTY cleanup, but nothing there will answer a + // command this webview is still holding once it goes away. + this.remoteHostClient.dispose(); } async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> { diff --git a/lib/src/lib/push-devices.ts b/lib/src/lib/push-devices.ts index 240deebf..8d42fdfd 100644 --- a/lib/src/lib/push-devices.ts +++ b/lib/src/lib/push-devices.ts @@ -38,7 +38,6 @@ const EMPTY: PushDevicesState = { status: 'no-host', devices: [] }; let state: PushDevicesState = EMPTY; let refresh: (() => void) | null = null; -let generation = 0; const listeners = new Set<() => void>(); /** Stable-identity snapshot for `useSyncExternalStore`. */ @@ -78,18 +77,20 @@ export function refreshPushDevicesNow(): void { } /** - * Identity of the current Host attachment. A refresh captures it before its - * request and compares before writing, so a fetch still in flight when - * {@link resetPushDevices} ran — the Host stopped, or re-enrollment replaced it - * — discards its result instead of overwriting `no-host` with a stale list. + * Back to `no-host`, keeping the refresher: the enrolled gate's disarm when the + * Host goes away, where the dialog must stop naming devices nothing can reach + * but may still be opened and told `no-host` (`lib/src/remote/host/activation.ts`). */ -export function getPushDevicesGeneration(): number { - return generation; +export function clearPushDevices(): void { + setPushDevices(EMPTY); } -/** Back to `no-host`, for a Host that stopped or a test that finished. */ +/** + * Full teardown: back to `no-host` *and* no refresher — a story or a test that + * finished, where the closure that would answer is going away too. Anything that + * only means "the Host is gone" wants {@link clearPushDevices}. + */ export function resetPushDevices(): void { - generation += 1; refresh = null; setPushDevices(EMPTY); } diff --git a/lib/src/main.tsx b/lib/src/main.tsx index ea7b91df..fba10b64 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -4,13 +4,22 @@ import { initPlatform } from "./lib/platform"; import { resumeOrRestore } from "./lib/reconnect"; import { initAlertStateReceiver } from "./lib/terminal-registry"; import { installVscodeThemeVarResolver } from "./lib/themes/vscode-color-observer"; +import { installPeerSurfaceResponder } from "./remote/host/peer-surfaces"; import App from "./App"; import "./index.css"; const platform = initPlatform(); -if (typeof acquireVsCodeApi === "function") { +// This entry serves the VS Code webview and the lib dev server. Only the +// former has a remote Host behind it: the Host runs in the extension host, +// next to the PTYs, and the dev server has neither. +const isVscode = typeof acquireVsCodeApi === "function"; + +if (isVscode) { installVscodeThemeVarResolver(); + // Every webview answers for its own terminals — that is what lets the phone + // see a whole window rather than one webview's panes. + installPeerSurfaceResponder(); } // Wire up alert state before reconnect so state messages are handled @@ -21,7 +30,7 @@ initAlertStateReceiver(); resumeOrRestore(platform).then((result) => { createRoot(document.getElementById("root")!).render( - + , ); }); diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index bac0aaa1..4111f7f8 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -25,10 +25,10 @@ import { PASSKEY_UNAVAILABLE_MESSAGE, PocketClient, SessionExpiredError, - type PocketSocket, type PocketStorage, type PocketClientDeps, } from './pocket-client'; +import { FakeSocket } from '../test-fake-socket'; import { getOrCreateDeviceKey, type DeviceKeyStore } from './device-key'; import type { PasskeyRegistration, WebAuthnClient } from './webauthn'; @@ -133,58 +133,6 @@ function recordingWebAuthn(): { }; } -class FakeSocket implements PocketSocket { - readyState = 0; - closeEmits = true; - 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; - if (this.closeEmits) this.emitClose(1000); - } - - /** Simulate the server/network dropping the connection (no client `close()`). */ - drop(): void { - this.readyState = 3; - this.emitClose(1006); - } - - fireOpen(): void { - this.readyState = 1; - this.#emit('open', {}); - } - - /** A rejected upgrade: the browser fires `error` with no status, never `open`. */ - fireError(): void { - this.readyState = 3; - this.#emit('error', {}); - } - - /** Simulate the server sending a frame to this client. */ - server(frame: unknown): void { - this.#emit('message', { data: JSON.stringify(frame) }); - } - - emitClose(code = 1000): void { - this.#emit('close', { code }); - } - - #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, @@ -250,7 +198,7 @@ async function signedIn(overrides: Partial = {}): Promise = {}): Promise { const pairing = client.pair('h1', 'iPhone'); await nextSent(socket, (f) => f.t === 'pair'); - socket.server({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); + socket.receive({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); await pairing; } @@ -297,12 +245,12 @@ describe('setup + signin', () => { await harness.client.signin(); const open = harness.client.openSocket(); - harness.socket.fireOpen(); + harness.socket.open(); await open; const pairing = harness.client.pair('h1', 'iPhone (Home Screen)'); const frame = await nextSent(harness.socket, (f) => f.t === 'pair'); - harness.socket.server({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); + harness.socket.receive({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); await pairing; // The request carries the hash of the key sign-in handed back. @@ -426,7 +374,7 @@ describe('pair', () => { expect(typeof request.devicePublicKey).toBe('string'); const record = { hostId: 'h1', label: 'iPhone Safari' }; - socket.server({ t: 'pair-result', approved: true, record }); + socket.receive({ t: 'pair-result', approved: true, record }); const result = await pairing; expect(result.approved).toBe(true); expect(result.record).toEqual(record); @@ -437,7 +385,7 @@ describe('pair', () => { 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' }); + socket.receive({ 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'); @@ -454,12 +402,12 @@ describe('pair', () => { await client.setup('pw', 'My Phone'); await client.signin(); const open = client.openSocket(); - socket.fireOpen(); + socket.open(); await open; const pairing = client.pair('h1', 'iPhone'); await nextSent(socket, (f) => f.t === 'pair'); - socket.server({ t: 'pair-result', approved: false, error: PAIRING_STALE_PRESENCE_ERROR }); + socket.receive({ t: 'pair-result', approved: false, error: PAIRING_STALE_PRESENCE_ERROR }); // The client re-auths (bearer-authorized begin + finish) and re-sends the // SAME pairing request; approve the retry. @@ -473,7 +421,7 @@ describe('pair', () => { })(); const first = socket.sent.find((f) => f.t === 'pair')!; expect(retry.request).toEqual(first.request); - socket.server({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); + socket.receive({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); const result = await pairing; expect(result.approved).toBe(true); @@ -499,7 +447,7 @@ describe('connect', () => { const connecting = client.connect('h1'); await nextSent(socket, (f) => f.t === 'connect'); - socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + socket.receive({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); const connect2 = await nextSent(socket, (f) => f.t === 'connect2'); const request = connect2.request as Record; @@ -512,7 +460,7 @@ describe('connect', () => { CREDENTIAL_ID, ); - socket.server({ t: 'decision', allowed: true }); + socket.receive({ t: 'decision', allowed: true }); const decision = await connecting; expect(decision.allowed).toBe(true); expect(client.connectedHostId).toBe('h1'); @@ -524,9 +472,9 @@ describe('connect', () => { const connecting = client.connect('h1'); await nextSent(socket, (f) => f.t === 'connect'); - socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + socket.receive({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); const connect2 = await nextSent(socket, (f) => f.t === 'connect2'); - socket.server({ t: 'decision', allowed: true }); + socket.receive({ t: 'decision', allowed: true }); const decision = await connecting; expect(decision.allowed).toBe(true); @@ -546,9 +494,9 @@ describe('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 }); + socket.receive({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); await nextSent(socket, (f) => f.t === 'connect2'); - socket.server({ t: 'decision', allowed: true }); + socket.receive({ t: 'decision', allowed: true }); expect((await first).allowed).toBe(true); }); @@ -559,9 +507,9 @@ describe('connect', () => { const connecting = client.connect('h1'); await nextSent(socket, (f) => f.t === 'connect'); - socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(3), expiresAt: 9e15 }); + socket.receive({ 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'] }); + socket.receive({ t: 'decision', allowed: false, failures: ['device-not-paired'] }); const decision = await connecting; expect(decision.allowed).toBe(false); expect(decision.failures).toEqual(['device-not-paired']); @@ -576,9 +524,9 @@ describe('connect', () => { const connecting = client.connect('h1'); await nextSent(socket, (f) => f.t === 'connect'); - socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(4), expiresAt: 9e15 }); + socket.receive({ 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'] }); + socket.receive({ t: 'decision', allowed: false, failures: ['challenge-invalid'] }); const decision = await connecting; expect(decision.allowed).toBe(false); @@ -592,9 +540,9 @@ 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 }); + socket.receive({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); await nextSent(socket, (f) => f.t === 'connect2'); - socket.server({ t: 'decision', allowed: true }); + socket.receive({ t: 'decision', allowed: true }); await connecting; } @@ -642,7 +590,7 @@ describe('session expiry', () => { live = false; const opening = harness.client.openSocket(); - harness.socket.fireError(); + harness.socket.emitError(); await expect(opening).rejects.toBeInstanceOf(SessionExpiredError); expect(harness.client.sessionToken).toBeNull(); }); @@ -651,7 +599,7 @@ describe('session expiry', () => { const harness = await withHostsRoute(() => ({ json: { hosts: [] } })); const opening = harness.client.openSocket(); - harness.socket.fireError(); + harness.socket.emitError(); await expect(opening).rejects.toThrow('relay socket error'); expect(harness.client.sessionToken).toBe('tok-abc'); }); @@ -687,7 +635,7 @@ describe('socket lifecycle', () => { let hostGone = 0; harness.client.setOnHostGone(() => hostGone++); - harness.socket.server({ t: 'host-gone' }); + harness.socket.receive({ t: 'host-gone' }); expect(hostGone).toBe(1); expect(harness.client.connectedHostId).toBeNull(); harness.socket.drop(); @@ -717,7 +665,7 @@ describe('socket lifecycle', () => { await harness.client.signin(); const firstOpen = harness.client.openSocket(); - first.fireOpen(); + first.open(); await firstOpen; await connectEstablished({ ...harness, socket: first }); @@ -725,19 +673,19 @@ describe('socket lifecycle', () => { harness.client.close(); const secondOpen = harness.client.openSocket(); - second.fireOpen(); + second.open(); await secondOpen; await connectEstablished({ ...harness, socket: second }); let hostGone = 0; harness.client.setOnHostGone(() => hostGone++); - first.server({ t: 'host-gone' }); + first.receive({ t: 'host-gone' }); expect(hostGone).toBe(0); expect(harness.client.connectedHostId).toBe('h1'); expect(harness.client.socketOpen).toBe(true); - first.emitClose(); + first.closeWith(1000); expect(hostGone).toBe(0); expect(harness.client.connectedHostId).toBe('h1'); expect(harness.client.socketOpen).toBe(true); @@ -751,7 +699,7 @@ describe('remote-api correlation', () => { 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({ + socket.receive({ t: 'msg', data: { requestId: data.requestId, ok: true, result: { protocolVersion: 1, hostId: 'h1' } }, }); @@ -764,7 +712,7 @@ describe('remote-api correlation', () => { 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' } }); + socket.receive({ t: 'msg', data: { requestId: data.requestId, ok: false, error: 'nope' } }); await expect(req).rejects.toThrow('nope'); }); @@ -777,17 +725,17 @@ describe('remote-api correlation', () => { 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 } } }); + socket.receive({ 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({ + socket.receive({ t: 'msg', data: { subId, event: 'directory.snapshot', data: { entries: [{ title: 'zsh' }] } }, }); // ...one for an unrelated subId is not. - socket.server({ + socket.receive({ t: 'msg', data: { subId: 'other', event: 'directory.snapshot', data: { entries: [{ title: 'nope' }] } }, }); diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx index c5bccd2e..e746b750 100644 --- a/lib/src/remote/host/RemotePairingModalHost.tsx +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -7,10 +7,10 @@ import { 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. + * Renders the head of the pairing-approval queue and, on mount, wires this + * webview to the Host service 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, @@ -20,9 +20,8 @@ export function RemotePairingModalHost({ const pending = useSyncExternalStore(subscribePairingApproval, getPairingApprovalSnapshot); const head = pending[0] ?? null; - useEffect(() => { - installRemoteHostConsoleHook(); - }, []); + // Idempotent, because StrictMode mounts this twice. + useEffect(() => installRemoteHostConsoleHook(), []); useEffect(() => { onKeyboardActiveChange?.(head !== null); @@ -33,7 +32,11 @@ export function RemotePairingModalHost({ return ( head.approve()} onDeny={() => head.deny()} diff --git a/lib/src/remote/host/acl.test.ts b/lib/src/remote/host/acl.test.ts index 0aa81f48..91b8a227 100644 --- a/lib/src/remote/host/acl.test.ts +++ b/lib/src/remote/host/acl.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HostAcl } from 'server-lib-common'; -import { ACL_KEY_PREFIX, loadAclRecords, loadHostAcl, saveAclRecords } from './acl'; +import { ACL_KEY_PREFIX, clearAclRecords, loadAclRecords, loadHostAcl } from './acl'; function stubLocalStorage(): Map { const store = new Map(); @@ -25,18 +25,23 @@ function makeRecord(hostId: string) { return acl.records(); } +/** What a webview-resident Host left behind, which is all this module reads now. */ +function seed(store: Map, hostId: string): ReturnType { + const records = makeRecord(hostId); + store.set(`${ACL_KEY_PREFIX}${hostId}`, JSON.stringify(records)); + return records; +} + describe('remote-host acl persistence', () => { afterEach(() => vi.unstubAllGlobals()); - it('round-trips records through localStorage', () => { + it('reads back what a webview-resident Host persisted', () => { const store = stubLocalStorage(); - const records = makeRecord('host-1'); - saveAclRecords('host-1', records); + const records = seed(store, 'host-1'); - expect(store.get(`${ACL_KEY_PREFIX}host-1`)).toBe(JSON.stringify(records)); expect(loadAclRecords('host-1')).toEqual(records); - const acl = loadHostAcl('host-1'); + const acl = loadHostAcl('host-1', loadAclRecords); const active = acl.activeRecords(); expect(active).toHaveLength(1); expect(active[0]?.label).toBe('iPhone Safari'); @@ -44,23 +49,31 @@ describe('remote-host acl persistence', () => { }); it('drops records belonging to a different host', () => { - stubLocalStorage(); - saveAclRecords('host-1', makeRecord('host-1')); + const store = stubLocalStorage(); + seed(store, 'host-1'); // A different host must not inherit host-1's ACL. expect(loadAclRecords('host-2')).toEqual([]); - expect(loadHostAcl('host-2').activeRecords()).toEqual([]); + expect(loadHostAcl('host-2', loadAclRecords).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([]); + expect(loadHostAcl('host-1', loadAclRecords).activeRecords()).toEqual([]); + }); + + it('clears the copy once the service has taken custody of it', () => { + // Left behind it would be a second, diverging ACL for the same hostId. + const store = stubLocalStorage(); + seed(store, 'host-1'); + clearAclRecords('host-1'); + expect(loadAclRecords('host-1')).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(); + expect(() => clearAclRecords('host-1')).not.toThrow(); }); }); diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts index 06e8d7f9..3a713477 100644 --- a/lib/src/remote/host/acl.ts +++ b/lib/src/remote/host/acl.ts @@ -1,15 +1,19 @@ /** * 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`. + * on the Host, never the Server. * - * Keyed per host so a browser profile that re-enrolls under a new hostId does - * not inherit a stale ACL. + * The Host runs in the process that owns the PTYs now, and writes its records + * through its own store (`lib/src/host/remote/host-state-store.ts`). What is + * left here is `localStorage` as the *read* side: a webview that paired devices + * before the service existed still holds the record array `HostAcl.records()` + * produced, and hands it over once (`activation.ts` → adoption) before clearing + * it. Keyed per host, so a profile that re-enrolls under a new hostId does not + * inherit a stale ACL. */ import { HostAcl, type HostAclRecord } from 'server-lib-common'; -import { loadJson, saveJson } from '../../lib/local-json-store'; +import { loadJson, removeJson } from '../../lib/local-json-store'; export const ACL_KEY_PREFIX = 'dormouse.remote-host.acl.'; @@ -17,29 +21,50 @@ 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[] { - // 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( +/** + * Keep only the records that belong to `hostId`, dropping anything that is not + * a record at all. + * + * Exported because every store that reads an ACL back — this one, the sidecar's + * file, VS Code's `globalState`, an `adopt` a webview sent — reads it as + * `unknown[]`, and `HostAcl.fromRecords` rejects a mismatched hostId outright. + * Dropping foreign rows beats failing the whole load over one of them, and + * doing it in one place keeps a store from quietly being the lenient one. + */ +export function filterAclRecords(hostId: string, records: readonly unknown[]): HostAclRecord[] { + return records.filter( (record): record is HostAclRecord => !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, ); } -export function saveAclRecords(hostId: string, records: readonly HostAclRecord[]): void { - saveJson(aclKey(hostId), records); +/** Load the persisted records for a host, dropping anything malformed. */ +export function loadAclRecords(hostId: string): HostAclRecord[] { + // Missing key / malformed JSON / non-array all collapse to `[]`. + return filterAclRecords(hostId, loadJson(aclKey(hostId), [], Array.isArray)); +} + +/** + * Drop this browser's copy of a host's records. Used once, when a webview hands + * its persisted Host to a Node-resident service (`activation.ts` → adoption): + * the copy left behind would be a second, diverging ACL for the same hostId. + */ +export function clearAclRecords(hostId: string): void { + removeJson(aclKey(hostId)); } /** * 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. + * ACL if the stored records cannot be reconciled with `hostId`. + * + * `loadRecords` is required rather than defaulted to {@link loadAclRecords}: the + * Host runs in the sidecar and the extension host as well as in a webview, and + * a `localStorage` default would be the wrong ACL in both — silently empty + * rather than loudly missing. */ export function loadHostAcl( hostId: string, - loadRecords: (hostId: string) => HostAclRecord[] = loadAclRecords, + loadRecords: (hostId: string) => HostAclRecord[], ): HostAcl { try { return HostAcl.fromRecords(hostId, loadRecords(hostId)); diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts new file mode 100644 index 00000000..af1003e4 --- /dev/null +++ b/lib/src/remote/host/activation.test.ts @@ -0,0 +1,510 @@ +/** + * The webview's end of the Host service (`lib/src/host/remote/service.ts`): it + * forwards console commands, mirrors the pairing queue, reports rings, and + * hands over a Host it persisted before the service existed. It starts no + * `RemoteHost` of its own — there is no webview-resident mode left to fall back + * to, so a host with no service behind it gets nothing at all. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PairingRequest } from 'server-lib-common'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +const enrollmentState = vi.hoisted(() => ({ + current: { + serverUrl: 'https://relay.example.ts.net', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.example.ts.net', + rpId: 'relay.example.ts.net', + } as { + serverUrl: string; + hostId: string; + hostToken: string; + origin: string; + rpId: string; + } | null, +})); + +const pushWatch = vi.hoisted(() => ({ + fire: undefined as ((sessionId: string, title: string) => void) | undefined, + stopped: 0, + invalidated: 0, + loads: [] as Array<() => Promise>, +})); +vi.mock('./alert-push', () => ({ + watchPushRings: (fire: (sessionId: string, title: string) => void) => { + pushWatch.fire = fire; + return () => { + pushWatch.fire = undefined; + pushWatch.stopped += 1; + }; + }, + commitPushDevices: async (load: () => Promise) => { + pushWatch.loads.push(load); + await load(); + }, + invalidatePushDeviceRefreshes: () => { + pushWatch.invalidated += 1; + }, +})); +const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void>, cleared: 0 })); +vi.mock('../../lib/push-devices', () => ({ + setPushDevicesRefresher: (refresh: () => void) => void pushRefreshers.current.push(refresh), + clearPushDevices: () => { + pushRefreshers.cleared += 1; + }, +})); +const aclState = vi.hoisted(() => ({ + records: [] as unknown[], + cleared: [] as string[], +})); +vi.mock('./acl', () => ({ + loadAclRecords: () => aclState.records, + clearAclRecords: (hostId: string) => void aclState.cleared.push(hostId), +})); +vi.mock('./enrollment', () => ({ + getEnrollment: () => enrollmentState.current, + clearEnrollment: () => { + enrollmentState.current = null; + }, +})); + +let remoteHostLink: RemoteHostLink | undefined; +// A host with `remoteHost` has a Host service behind it; without one (the +// website) there is no Host anywhere. +vi.mock('../../lib/platform', () => ({ + getPlatform: () => ({ remoteHost: remoteHostLink }), +})); + +beforeEach(() => { + remoteHostLink = undefined; + pushWatch.fire = undefined; + pushWatch.stopped = 0; + pushWatch.invalidated = 0; + pushWatch.loads.length = 0; + pushRefreshers.current.length = 0; + pushRefreshers.cleared = 0; + aclState.records = []; + aclState.cleared.length = 0; + enrollmentState.current = { + serverUrl: 'https://relay.example.ts.net', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.example.ts.net', + rpId: 'relay.example.ts.net', + }; + // The console hook lives on globalThis and outlives `vi.resetModules()`; + // leaving it set would make the next test call the previous module's closure. + delete (globalThis as { dormouseRemoteHost?: unknown }).dormouseRemoteHost; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// --- Bridge mode --- + +const PAIRING_REQUEST = { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', +} satisfies PairingRequest; + +interface FakeLink extends RemoteHostLink { + commands: Array<{ cmd: string; params?: unknown }>; + emit(name: string, data: unknown): void; + results: Record; +} + +function fakeLink(): FakeLink { + const listeners = new Map void>>(); + const link: FakeLink = { + commands: [], + results: {}, + command: async (cmd, params) => { + link.commands.push({ cmd, params }); + return link.results[cmd]; + }, + respond: () => {}, + notify: () => {}, + on: (name, listener) => { + const set = listeners.get(name) ?? new Set(); + set.add(listener); + listeners.set(name, set); + return () => void set.delete(listener); + }, + emit: (name, data) => { + for (const listener of listeners.get(name) ?? []) listener(data); + }, + }; + return link; +} + +/** + * Install in bridge mode and hand back the module's fresh pairing store. + * Enrolled unless a test says otherwise: that is the state everything but the + * gate's own cases is about. + */ +async function installBridge(link: FakeLink) { + link.results.status ??= { enrolled: true }; + // A store that survives a restart, which is what lets the webview drop its + // own copy of an adopted Host. + link.results.adopt ??= { persisted: true }; + remoteHostLink = link; + vi.resetModules(); + const mod = await import('./activation'); + const pairing = await import('./pairing-approval'); + mod.installRemoteHostConsoleHook(); + // The adoption round trip gates the queue seed, and the `status` seed gates + // the ring watch. + await settle(); + return { mod, pairing }; +} + +/** Let the boot round trips land. */ +async function settle(): Promise { + for (let i = 0; i < 4; i++) await Promise.resolve(); +} + +function consoleHook() { + return (globalThis as { + dormouseRemoteHost?: { + enroll: (a: string, b: string, c: string) => Promise; + status: () => unknown; + reconnect: () => unknown; + clearEnrollment: () => unknown; + }; + }).dormouseRemoteHost!; +} + +describe('remote host bridge mode', () => { + it('does nothing at all with no service behind the host', async () => { + // The website: no `remoteHost`, so no console hook and no commands. There + // is no webview-resident Host to fall back to. + remoteHostLink = undefined; + vi.resetModules(); + const mod = await import('./activation'); + mod.installRemoteHostConsoleHook(); + expect((globalThis as { dormouseRemoteHost?: unknown }).dormouseRemoteHost).toBeUndefined(); + }); + + it('forwards every console method to the service', async () => { + const link = fakeLink(); + link.results.status = { enrolled: true }; + await installBridge(link); + + await consoleHook().enroll('https://relay.dormouse.sh', 'password', 'Laptop'); + expect(await consoleHook().status()).toEqual({ enrolled: true }); + await consoleHook().reconnect(); + await consoleHook().clearEnrollment(); + + expect(link.commands.map((c) => c.cmd)).toEqual( + expect.arrayContaining(['enroll', 'status', 'reconnect', 'clearEnrollment']), + ); + expect(link.commands.find((c) => c.cmd === 'enroll')?.params).toEqual({ + serverUrl: 'https://relay.dormouse.sh', + password: 'password', + label: 'Laptop', + }); + }); + + it('hands a webview-persisted Host over once, then clears its keys', async () => { + aclState.records = [{ hostId: 'host-1' }]; + const link = fakeLink(); + await installBridge(link); + + const adopt = link.commands.find((c) => c.cmd === 'adopt'); + expect(adopt?.params).toMatchObject({ + enrollment: { hostId: 'host-1' }, + aclRecords: [{ hostId: 'host-1' }], + }); + // The service is holding it somewhere durable now, so this copy is obsolete + // — leaving it would be a second ACL for the same hostId. + expect(enrollmentState.current).toBeNull(); + expect(aclState.cleared).toEqual(['host-1']); + }); + + it('keeps the local copy when the service could not persist it', async () => { + // The dev harness with no state directory holds the Host in memory only: + // this copy is the one that survives the process, and clearing it would + // lose the Host at the next launch. + aclState.records = [{ hostId: 'host-1' }]; + const link = fakeLink(); + link.results.adopt = { persisted: false }; + await installBridge(link); + + expect(link.commands.some((c) => c.cmd === 'adopt')).toBe(true); + expect(enrollmentState.current).not.toBeNull(); + expect(aclState.cleared).toEqual([]); + }); + + it('adopts nothing when the webview never was a Host', async () => { + enrollmentState.current = null; + const link = fakeLink(); + await installBridge(link); + expect(link.commands.some((c) => c.cmd === 'adopt')).toBe(false); + expect(aclState.cleared).toEqual([]); + }); + + it('keeps the local copy when the hand-off fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const link = fakeLink(); + link.command = async (cmd, params) => { + link.commands.push({ cmd, params }); + if (cmd === 'adopt') throw new Error('sidecar is down'); + return link.results[cmd]; + }; + await installBridge(link); + + expect(enrollmentState.current).not.toBeNull(); + expect(aclState.cleared).toEqual([]); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('mirrors the service queue and answers by clientId', async () => { + const link = fakeLink(); + const { pairing } = await installBridge(link); + + link.emit('pairing-queue', { + name: 'pairing-queue', + queue: [{ clientId: 'c1', pairingId: 'p1', request: PAIRING_REQUEST, requestedAt: 5 }], + }); + + const head = pairing.getPairingApprovalSnapshot()[0]!; + expect(head).toMatchObject({ + clientId: 'c1', + pairingId: 'p1', + request: PAIRING_REQUEST, + requestedAt: 5, + }); + + head.approve('Ned iPhone'); + expect(link.commands.at(-1)).toEqual({ + cmd: 'approve', + params: { clientId: 'c1', pairingId: 'p1', label: 'Ned iPhone' }, + }); + head.deny(); + expect(link.commands.at(-1)).toEqual({ + cmd: 'deny', + params: { clientId: 'c1', pairingId: 'p1' }, + }); + }); + + it('replaces the mirror wholesale — the service is authoritative', async () => { + const link = fakeLink(); + const { pairing } = await installBridge(link); + const queue = (ids: string[]) => ({ + name: 'pairing-queue', + queue: ids.map((clientId) => ({ + clientId, + pairingId: `pairing-${clientId}`, + request: PAIRING_REQUEST, + requestedAt: 5, + })), + }); + + link.emit('pairing-queue', queue(['c1', 'c2'])); + expect(pairing.getPairingApprovalSnapshot().map((p) => p.clientId)).toEqual(['c1', 'c2']); + + // c1 resolved on the service side; the snapshot that no longer names it is + // the only signal, and the order of what remains must not churn. + link.emit('pairing-queue', queue(['c2'])); + expect(pairing.getPairingApprovalSnapshot().map((p) => p.clientId)).toEqual(['c2']); + + link.emit('pairing-queue', queue([])); + expect(pairing.getPairingApprovalSnapshot()).toEqual([]); + }); + + it('re-mirrors a request the service replaced under the same clientId', async () => { + // The service coalesces a re-sent pair by clientId, so the same id can come + // to name a different device. Approving authorizes what the *service* + // holds, so a mirror that skipped the update would show device #1 while + // Approve wrote device #2 (docs/specs/remote-security-model.md). + const link = fakeLink(); + const { pairing } = await installBridge(link); + const second = { + ...PAIRING_REQUEST, + devicePublicKey: 'device-2', + requestedLabel: 'Android Chrome', + }; + + link.emit('pairing-queue', { + name: 'pairing-queue', + queue: [{ clientId: 'c1', pairingId: 'p1', request: PAIRING_REQUEST, requestedAt: 5 }], + }); + const stale = pairing.getPairingApprovalSnapshot()[0]!; + link.emit('pairing-queue', { + name: 'pairing-queue', + queue: [{ clientId: 'c1', pairingId: 'p2', request: second, requestedAt: 9 }], + }); + + const head = pairing.getPairingApprovalSnapshot(); + expect(head).toHaveLength(1); + expect(head[0]).toMatchObject({ + clientId: 'c1', + pairingId: 'p2', + request: second, + requestedAt: 9, + }); + + // A click already queued from the old modal stays bound to the old ticket; + // the service can reject it instead of applying it to the replacement. + stale.approve(); + expect(link.commands.at(-1)).toEqual({ + cmd: 'approve', + params: { clientId: 'c1', pairingId: 'p1', label: undefined }, + }); + }); + + it('leaves an unchanged request alone, so the modal does not churn', async () => { + // Every snapshot arrives as fresh JSON, so "unchanged" has to be decided by + // value — comparing identity would re-render the modal on every event. + const link = fakeLink(); + const { pairing } = await installBridge(link); + const snapshot = () => ({ + name: 'pairing-queue', + queue: [ + { + clientId: 'c1', + pairingId: 'p1', + request: { ...PAIRING_REQUEST }, + requestedAt: 5, + }, + ], + }); + + link.emit('pairing-queue', snapshot()); + const first = pairing.getPairingApprovalSnapshot()[0]; + link.emit('pairing-queue', snapshot()); + expect(pairing.getPairingApprovalSnapshot()[0]).toBe(first); + + // A distinct ceremony can look identical and land in the same millisecond; + // its ticket still has to replace the closures that answer the old one. + link.emit('pairing-queue', { + name: 'pairing-queue', + queue: [ + { + clientId: 'c1', + pairingId: 'p2', + request: { ...PAIRING_REQUEST }, + requestedAt: 5, + }, + ], + }); + expect(pairing.getPairingApprovalSnapshot()[0]).not.toBe(first); + expect(pairing.getPairingApprovalSnapshot()[0]!.pairingId).toBe('p2'); + }); + + it('seeds the mirror, for a webview that reloaded mid-pairing', async () => { + const link = fakeLink(); + link.results.pairingQueue = [ + { clientId: 'c1', pairingId: 'p1', request: PAIRING_REQUEST, requestedAt: 5 }, + ]; + const { pairing } = await installBridge(link); + + expect(link.commands.some((c) => c.cmd === 'pairingQueue')).toBe(true); + expect(pairing.getPairingApprovalSnapshot()).toHaveLength(1); + }); + + it('re-seeds the mirror every time a Host appears, not only at install', async () => { + // Joining a Host that is already mid-pairing is the case a one-shot seed + // misses: the service pushes the queue only when it changes, so the modal + // would stay hidden until the pairing was answered somewhere else. + const link = fakeLink(); + link.results.status = { enrolled: false }; + link.results.pairingQueue = [ + { clientId: 'c1', pairingId: 'p1', request: PAIRING_REQUEST, requestedAt: 5 }, + ]; + const { pairing } = await installBridge(link); + expect(link.commands.some((c) => c.cmd === 'pairingQueue')).toBe(false); + + link.emit('status', { name: 'status', enrolled: true }); + await settle(); + expect(pairing.getPairingApprovalSnapshot()).toHaveLength(1); + + // And again after the Host goes and comes back. + link.emit('status', { name: 'status', enrolled: false }); + link.commands.length = 0; + link.emit('status', { name: 'status', enrolled: true }); + await settle(); + expect(link.commands.filter((c) => c.cmd === 'pairingQueue')).toHaveLength(1); + }); + + it('reports rings with the label the webview derived', async () => { + const link = fakeLink(); + await installBridge(link); + + pushWatch.fire!('pty-1', 'pnpm dev'); + expect(link.commands.at(-1)).toEqual({ + cmd: 'push', + params: { sessionId: 'pty-1', title: 'pnpm dev' }, + }); + }); + + it('asks the service for the device list the dialog names', async () => { + const link = fakeLink(); + await installBridge(link); + + expect(link.commands.some((c) => c.cmd === 'pushDevices')).toBe(true); + // And the dialog can ask again later. + link.commands.length = 0; + pushRefreshers.current.at(-1)!(); + await Promise.resolve(); + expect(link.commands.map((c) => c.cmd)).toEqual(['pushDevices']); + }); + + it('arms nothing at all on a host that never enrolled', async () => { + // The common case: no ring watch, no device fetch, and no crossing per + // activity change — only the one `status` that says so. + const link = fakeLink(); + link.results.status = { enrolled: false }; + await installBridge(link); + + expect(pushWatch.fire).toBeUndefined(); + expect(link.commands.some((c) => c.cmd === 'pushDevices')).toBe(false); + expect(link.commands.some((c) => c.cmd === 'status')).toBe(true); + }); + + it('arms when the service announces a Host, and disarms when it goes', async () => { + const link = fakeLink(); + link.results.status = { enrolled: false }; + await installBridge(link); + + // An enroll from any webview reaches every webview as this event. + link.emit('status', { name: 'status', enrolled: true }); + await settle(); + expect(pushWatch.fire).toBeDefined(); + expect(link.commands.some((c) => c.cmd === 'pushDevices')).toBe(true); + + // `clearEnrollment` announces the same way. + link.emit('status', { name: 'status', enrolled: false }); + expect(pushWatch.fire).toBeUndefined(); + expect(pushWatch.stopped).toBe(1); + // The dialog must stop naming devices nothing can push to — including any + // list still on the wire, which would otherwise put them back on arrival. + expect(pushWatch.invalidated).toBe(1); + expect(pushRefreshers.cleared).toBe(1); + // The refresher stays installed rather than being dropped and put back: the + // dialog may still open on an un-enrolled machine, where asking is one + // command that answers `no-host`. + expect(pushRefreshers.current).toHaveLength(1); + link.commands.length = 0; + pushRefreshers.current.at(-1)!(); + await settle(); + expect(link.commands.map((c) => c.cmd)).toEqual(['pushDevices']); + }); + + it('is idempotent under a StrictMode double mount', async () => { + const link = fakeLink(); + const { mod } = await installBridge(link); + const before = link.commands.length; + + mod.installRemoteHostConsoleHook(); + await Promise.resolve(); + expect(link.commands).toHaveLength(before); + }); +}); diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 7a4d7d05..3b50ee0d 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -1,11 +1,14 @@ /** - * 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). + * Activation glue: wires this webview to the remote Host service behind the + * platform adapter, 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. + * The Host itself is a service in the process that owns the PTYs + * (`lib/src/host/remote/service.ts`) — the Tauri sidecar, the VS Code extension + * host. This module is its client: it forwards console commands, mirrors the + * pairing queue, and reports rings. It starts no Host, holds no relay socket, + * and reads no ACL. A host with no service behind it (the website) gets nothing + * at all, which is why every entry point here tolerates a missing link. * * Enroll from the devtools console: * @@ -15,113 +18,210 @@ * window.dormouseRemoteHost.clearEnrollment() */ -import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; -import { refreshPushDevices, startAlertPush, type AlertPushDeps } from './alert-push'; -import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; -import { RemoteApiSession } from './remote-api'; -import { RemoteHost, type RemoteHostStatus } from './remote-host'; +import type { PairingRequest } from 'server-lib-common'; +import type { + AdoptResult, + PairingQueueEvent, + PairingQueueItem, + PushDevicesResult, + RemoteHostConsoleStatus, +} from '../../host/remote/service-protocol'; +import { getPlatform } from '../../lib/platform'; +import type { RemoteHostLink } from '../../lib/platform/types'; +import { clearPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; +import { clearAclRecords, loadAclRecords } from './acl'; +import { commitPushDevices, invalidatePushDeviceRefreshes, watchPushRings } from './alert-push'; +import { clearEnrollment, getEnrollment } from './enrollment'; +import { armWhileEnrolled } from './enrolled-gate'; +import { + enqueuePairingApproval, + getPairingApprovalSnapshot, + resolvePairingApproval, +} from './pairing-approval'; -let current: RemoteHost | null = null; -let stopPush: (() => void) | null = null; +export type { RemoteHostConsoleStatus }; -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: opts.send, - }), +/** Install the `window.dormouseRemoteHost` console hook and connect. Idempotent. */ +export function installRemoteHostConsoleHook(): void { + const link = getPlatform().remoteHost; + // No service behind this host (the website): there is no Host to reach, and + // nothing here degrades to a webview-resident one. + if (link) installBridgeMode(link); +} + +// --- Bridge mode: the Host lives in another process --- + +let bridgeInstalled = false; + +/** + * Wire this webview to the Host service behind the adapter. No `RemoteHost`, no + * `RemoteApiSession`, no relay socket: those are the service's, and everything + * here is either UI or something only a webview knows. + * + * Idempotent — `RemotePairingModalHost` mounts twice under StrictMode. + */ +function installBridgeMode(link: RemoteHostLink): void { + if (bridgeInstalled) return; + bridgeInstalled = true; + + // The service is authoritative about the queue, so a pushed snapshot replaces + // the mirror wholesale rather than merging into it. Subscribed before the + // adoption round trip so a pairing that arrives during it is not missed. + link.on('pairing-queue', (data) => { + mirrorPairingQueue(link, (data as PairingQueueEvent).queue); }); - host.start(); - // Alarm push is armed here rather than in `Wall` so it exists exactly when a - // Host does: a build with no enrollment has nothing to push to, and this - // module is already the lazily-loaded standalone-only boundary - // (`docs/specs/alert.md` -> Push notifications). - const deps: AlertPushDeps = { - enrollment, - activeRecords: () => host.activeRecords, + void adoptWebviewHost(link); + + const refresh = (): void => { + void commitPushDevices(async () => { + const result = (await link.command('pushDevices')) as PushDevicesResult; + return result ? result.devices : null; + }); }; - stopPush = startAlertPush(deps); - // Populate the Alarm settings dialog's device line up front, and let the - // dialog ask for a fresh one when it opens — a phone can subscribe long after - // the Host booted, and a list only read at startup would name it never. - setPushDevicesRefresher(() => void refreshPushDevices(deps)); - void refreshPushDevices(deps); + // Installed unconditionally: the dialog may open on an un-enrolled machine, + // and asking then is one command that answers `no-host`. + setPushDevicesRefresher(refresh); + + armWhileEnrolled(link, () => { + // Rings are detected here — the activity store and the pane labels are + // webview state — and delivered there, where the ACL is. + const stopRings = watchPushRings((sessionId, title) => { + void link.command('push', { sessionId, title }).catch(() => {}); + }); + refresh(); + // Seeded on every transition to enrolled, not once at install: the service + // pushes the queue only when it changes, so a webview that joins — or a + // machine that enrolls — mid-pairing would otherwise show no modal at all + // until the next change. + void link + .command('pairingQueue') + .then((queue) => mirrorPairingQueue(link, (queue ?? []) as PairingQueueItem[])) + .catch(() => {}); + return () => { + stopRings(); + // The Host is gone, so the dialog must stop naming devices nothing can + // reach — including any list still on the wire, which would otherwise put + // them back the moment it lands. The refresher stays installed: the dialog + // may still open on an un-enrolled machine, where asking is one command + // that answers `no-host`. + invalidatePushDeviceRefreshes(); + clearPushDevices(); + }; + }); - return host; + const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; + if (target.dormouseRemoteHost) return; + // Same method names and result shapes as the legacy hook (SELF_HOST.md), one + // round trip further away — so `status()` and `reconnect()` are promises here. + target.dormouseRemoteHost = { + enroll: (serverUrl: string, password: string, label: string) => + link.command('enroll', { serverUrl, password, label }), + status: () => link.command('status'), + reconnect: () => link.command('reconnect'), + clearEnrollment: () => link.command('clearEnrollment'), + }; } -/** Start the Host if an enrollment exists and none is running. Idempotent. */ -export function activateRemoteHost(): void { - if (current) return; +/** + * Hand a Host this webview persisted before the service existed over to it, + * once. The service keeps whichever enrollment it already has, so this can only + * add. + * + * The copy is cleared only once the service reports it is holding the Host + * somewhere that survives a restart — leaving it otherwise would be a second + * ACL for the same hostId, diverging from the moment the next device pairs, but + * clearing it against an in-memory store (a dev harness with no state + * directory) would throw the only surviving copy away. + */ +async function adoptWebviewHost(link: RemoteHostLink): Promise { const enrollment = getEnrollment(); if (!enrollment) return; - current = startFromEnrollment(enrollment); + let result: AdoptResult | null; + try { + result = (await link.command('adopt', { + enrollment, + aclRecords: loadAclRecords(enrollment.hostId), + })) as AdoptResult | null; + } catch (error) { + // Keep the local copy for the next launch rather than dropping a Host on + // the floor because one command failed. + console.warn('remote-host: could not hand the persisted Host to the service', error); + return; + } + if (!result?.persisted) return; + clearEnrollment(); + clearAclRecords(enrollment.hostId); } -export function stopRemoteHost(): void { - current?.stop(); - current = null; - stopPush?.(); - stopPush = null; - // Back to `no-host`: the dialog must stop naming devices nothing can reach. - resetPushDevices(); +/** Project the service's queue onto the modal's store. */ +function mirrorPairingQueue(link: RemoteHostLink, queue: readonly PairingQueueItem[]): void { + const present = new Set(queue.map((item) => item.clientId)); + for (const pending of getPairingApprovalSnapshot()) { + if (!present.has(pending.clientId)) resolvePairingApproval(pending.clientId); + } + const mirrored = new Map(getPairingApprovalSnapshot().map((pending) => [pending.clientId, pending])); + for (const item of queue) { + const showing = mirrored.get(item.clientId); + // Re-enqueuing an unchanged request would reorder the queue and re-render + // the modal for nothing. The ticket id is part of "unchanged": timestamps + // can collide, and each approve/deny must echo the exact ticket displayed. + if ( + showing && + showing.pairingId === item.pairingId && + showing.requestedAt === item.requestedAt && + sameRequest(showing.request, item.request) + ) { + continue; + } + // Changed under the same id. The service coalesces a re-sent pair by + // replacing what it holds for that clientId, so approving authorizes the + // *new* device — and the modal must therefore be showing the new device. + // Anything else approves something the user was never shown + // (docs/specs/remote-security-model.md). + if (showing) resolvePairingApproval(item.clientId); + enqueuePairingApproval({ + clientId: item.clientId, + pairingId: item.pairingId, + request: item.request, + requestedAt: item.requestedAt, + approve: (label) => + void link + .command('approve', { clientId: item.clientId, pairingId: item.pairingId, label }) + .catch(() => {}), + deny: () => + void link + .command('deny', { clientId: item.clientId, pairingId: item.pairingId }) + .catch(() => {}), + }); + } } -export interface RemoteHostConsoleStatus { - enrolled: boolean; - serverUrl: string | null; - hostId: string | null; - /** - * The relay socket's state. `displaced` is the one that needs acting on: - * another Dormouse instance enrolled with the same `hostId` took the relay - * slot, so this one stood down and no timer will bring it back — `reconnect()` - * takes the slot back (and displaces the other one in turn). - */ - connection: RemoteHostStatus; - 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, - }; -} +/** + * Every field of a {@link PairingRequest}, as a compile-time checklist. + * + * `satisfies` is the whole point: {@link sameRequest} decides whether the modal + * is already showing this exact device, and approving one authorizes the *pair* + * — so a field added to the wire type and forgotten in a hand-written compare + * would silently leave the user approving a device they were never shown + * (docs/specs/remote-security-model.md). Naming the keys here makes that a + * compile error rather than a silent security regression. + */ +const PAIRING_REQUEST_FIELDS = { + accountId: true, + passkeyCredentialId: true, + passkeyPublicKeyHash: true, + devicePublicKey: true, + requestedLabel: true, +} satisfies Record; -/** 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, - /** - * Re-open the relay socket now. The only way back from `displaced`: an - * evicted Host stands down for good rather than fighting the Host that - * replaced it, so returning has to be asked for. - */ - reconnect(): RemoteHostConsoleStatus { - activateRemoteHost(); - current?.start(); - return remoteHostStatus(); - }, - clearEnrollment() { - stopRemoteHost(); - clearEnrollment(); - }, - }; +/** + * Whether the mirror already shows exactly this request. Field by field rather + * than by identity: every snapshot arrives as fresh JSON off the bridge, so + * identity always differs and would re-render the modal on every event. + */ +function sameRequest(a: PairingRequest, b: PairingRequest): boolean { + return (Object.keys(PAIRING_REQUEST_FIELDS) as Array).every( + (field) => a[field] === b[field], + ); } diff --git a/lib/src/remote/host/alert-push.test.ts b/lib/src/remote/host/alert-push.test.ts index 0aa080a7..9dad7f7d 100644 --- a/lib/src/remote/host/alert-push.test.ts +++ b/lib/src/remote/host/alert-push.test.ts @@ -5,7 +5,10 @@ vi.mock('../../lib/platform', () => ({ })); import type { HostAclRecord } from 'server-lib-common'; -import { refreshPushDevices, startAlertPush, toPushText } from './alert-push'; +import { commitPushDevices, invalidatePushDeviceRefreshes, watchPushRings } from './alert-push'; +// Delivery — the Server calls, the recipient rule, the title bounds — runs in +// the Host's process, so it lives beside neither webview nor sidecar. +import { loadPushDevices, sendPush, toPushText, type AlertPushDeps } from './push-delivery'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from '../../lib/alert-settings'; import { getPushDevices, resetPushDevices } from '../../lib/push-devices'; import { clearPrimedActivity, primeActivity } from '../../lib/session-activity-store'; @@ -57,6 +60,26 @@ function deps() { return { enrollment: ENROLLMENT, activeRecords: () => records, fetch: fakeFetch() }; } +/** + * The two shipped halves joined: the webview watches for rings and names the + * Session (`watchPushRings`, in `activation.ts`), and the Host delivers with + * its own ACL and swallows failures so a dead push never breaks the alert path + * (`RemoteHostService.#push`). Wired here because they only meet across a + * process boundary. + */ +function startPush(pushDeps: AlertPushDeps): () => void { + return watchPushRings((id, title) => { + void sendPush(pushDeps, id, title).catch((error: unknown) => { + console.warn('remote-host: push notification failed', error); + }); + }); +} + +/** As the settings dialog asks for it, over the bridge (`activation.ts`). */ +function refreshPushDevices(pushDeps: AlertPushDeps): Promise { + return commitPushDevices(() => loadPushDevices(pushDeps)); +} + function ring(id: string): void { primeActivity(id, { status: 'NOTHING_TO_SHOW' }); primeActivity(id, { status: 'ALERT_RINGING' }); @@ -141,7 +164,7 @@ describe('toPushText', () => { describe('alarm push', () => { it('sends the pane label after the delay, tagged per Session', async () => { - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS - 1); @@ -157,7 +180,7 @@ describe('alarm push', () => { it('sends nothing while pushEnabled is off', async () => { applyAlertSettingsFromHost({ ...DEFAULT_ALERT_SETTINGS, pushEnabled: false }); - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); await vi.advanceTimersByTimeAsync(60_000); @@ -170,7 +193,7 @@ describe('alarm push', () => { pushEnabled: true, pushDelayMs: 5_000, }); - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); await vi.advanceTimersByTimeAsync(5_000); @@ -183,7 +206,7 @@ describe('alarm push', () => { // of the request because the ACL, not the server's list, chooses targets. subscribed = ['device-phone', 'device-revoked']; records = [aclRecord('device-phone', 'iPhone Safari')]; - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS); @@ -194,7 +217,7 @@ describe('alarm push', () => { // The ACL is local, and the Server intersects the names it is given with // its own subscriptions anyway — so asking it first would only add a round // trip to the one path whose whole value is timeliness. - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS); @@ -202,11 +225,20 @@ describe('alarm push', () => { expect(requests[0]!.url).toContain('/api/push/send'); }); + it('refuses redirects instead of sending Host data outside the allowlist', async () => { + await loadPushDevices(deps()); + expect(requests[0]!.init?.redirect).toBe('error'); + + requests.length = 0; + await sendPush(deps(), 'pty-1', 'build'); + expect(requests[0]!.init?.redirect).toBe('error'); + }); + it('warns when the server accepted the send but no phone got it', async () => { // The send route answers 200 with counts even when every delivery failed — // a rotated VAPID key or a wedged push service must not be silent. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - stop = startAlertPush({ + stop = startPush({ enrollment: ENROLLMENT, activeRecords: () => records, fetch: (async () => ({ @@ -225,7 +257,7 @@ describe('alarm push', () => { // A 401 from a revoked host token would otherwise resolve normally and // leave push permanently broken with nothing in the console. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - stop = startAlertPush({ + stop = startPush({ enrollment: ENROLLMENT, activeRecords: () => records, fetch: (async () => ({ ok: false, status: 401 })) as unknown as typeof globalThis.fetch, @@ -239,7 +271,7 @@ describe('alarm push', () => { it('sends nothing when no subscribed device is still authorized', async () => { records = []; - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS); @@ -247,7 +279,7 @@ describe('alarm push', () => { }); it('re-reads the target list at send time, not at schedule time', async () => { - stop = startAlertPush(deps()); + stop = startPush(deps()); ring('pty-1'); // Revoked during the delay. records = []; @@ -265,7 +297,7 @@ describe('alarm push', () => { }) as unknown as typeof globalThis.fetch, }; const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - stop = startAlertPush(failing); + stop = startPush(failing); ring('pty-1'); await expect(vi.advanceTimersByTimeAsync(60_000)).resolves.not.toThrow(); @@ -301,26 +333,28 @@ describe('push device list', () => { expect(getPushDevices()).toEqual({ status: 'error', devices: [] }); }); - it('discards a refresh that resolves after the Host stopped', async () => { - // Without the generation fence, the resolving fetch would overwrite the - // reset's `no-host` with a `ready` list naming devices nothing can reach — - // and since the reset cleared the refresher, it would stick all session. - let resolveFetch: (response: Response) => void = () => {}; - const pending = refreshPushDevices({ + it('discards a refresh that lands after the Host went away', async () => { + // The enrolled gate disarms on `clearEnrollment` and resets the store to + // `no-host`. A request already on the wire resolves afterwards and would + // otherwise repopulate the dialog with phones there is nothing to push to. + let land: (response: Response) => void = () => {}; + const inFlight = refreshPushDevices({ enrollment: ENROLLMENT, activeRecords: () => records, fetch: (() => new Promise((resolve) => { - resolveFetch = resolve; + land = resolve; })) as unknown as typeof globalThis.fetch, }); + invalidatePushDeviceRefreshes(); resetPushDevices(); - resolveFetch({ + + land({ ok: true, json: async () => ({ devices: [{ devicePublicKey: 'device-phone', subscribedAt: 1 }] }), } as Response); - await pending; + await inFlight; expect(getPushDevices()).toEqual({ status: 'no-host', devices: [] }); }); diff --git a/lib/src/remote/host/alert-push.ts b/lib/src/remote/host/alert-push.ts index ad2d498c..7fa94e85 100644 --- a/lib/src/remote/host/alert-push.ts +++ b/lib/src/remote/host/alert-push.ts @@ -4,185 +4,80 @@ * send that Pane's name to the paired phones. * * The ring detection, delay, and cancellation rules are shared with spoken - * alarms (`lib/src/lib/alert-ring-watch.ts`); this module is the push sink. - * It lives under `remote/host/` rather than `lib/` because it needs the Host's - * enrollment and ACL, and because that keeps it inside the lazily-imported - * `RemotePairingModalHost` chunk — so the website and vscode webviews, which - * never set `enableRemoteHost`, never fetch it. + * alarms (`lib/src/lib/alert-ring-watch.ts`); this module is the webview half of + * the push sink — the watch, the pane label, and the settings dialog's device + * list. The Server calls themselves are in `push-delivery.ts`, which a + * Node-resident Host runs without any of this. * - * Delivery is an HTTP POST to the Server rather than a relay frame: the relay - * routes between two live sockets, and the whole point of a push is reaching a - * phone whose app is closed. + * It lives under `remote/host/` rather than `lib/` because it is only meaningful + * with a Host behind it, and because that keeps it inside the lazily-imported + * `RemotePairingModalHost` chunk — so a host that never sets `enableRemoteHost` + * never fetches it. */ -import { - API_ROUTES, - boundedPushText, - type HostAclRecord, - type PushDevicesResponse, - type PushSendResponse, -} from 'server-lib-common'; import { getAlertSettings } from '../../lib/alert-settings'; import { watchUnattendedRings } from '../../lib/alert-ring-watch'; import { deriveSessionLabel } from '../../lib/session-label'; -import { - getPushDevicesGeneration, - setPushDevices, - type PushDevice, - type PushDevicesState, -} from '../../lib/push-devices'; -import type { HostEnrollment } from './enrollment'; - -/** - * Longest label we put in a notification title. Every OS truncates well before - * this on a lock screen; the cap exists so a pathological title cannot bloat - * the encrypted payload toward the ~4KB Web Push limit. - */ -const PUSH_TITLE_LIMIT = 100; - -/** Shown as the notification body; the Pane name carries the information. */ -const PUSH_BODY = 'Needs attention'; - -/** - * Apply this sink's bounds to a Pane label. The rule itself is - * `boundedPushText` in `server-lib-common`, shared with the Server so the - * sanitization has one implementation rather than a strong copy here and a - * weaker one there; this wrapper only names the sink's limit and fallback. - */ -export function toPushText(label: string): string { - return boundedPushText(label, { limit: PUSH_TITLE_LIMIT, fallback: 'terminal' }); -} - -export interface AlertPushDeps { - readonly enrollment: Pick; - /** The Host's active ACL records — the authority on who may be reached. */ - readonly activeRecords: () => readonly HostAclRecord[]; - /** Injectable for tests. */ - readonly fetch?: typeof globalThis.fetch; -} - -/** The Host's one authenticated call to the Server. */ -async function hostFetch( - deps: AlertPushDeps, - route: string, - body?: unknown, -): Promise { - const doFetch = deps.fetch ?? globalThis.fetch; - const response = await doFetch(`${deps.enrollment.serverUrl}${route}`, { - ...(body === undefined - ? {} - : { method: 'POST', body: JSON.stringify(body) }), - headers: { - authorization: `Bearer ${deps.enrollment.hostToken}`, - ...(body === undefined ? {} : { 'content-type': 'application/json' }), - }, - }); - // Checked here so both call sites fail loudly. A send that swallowed a 401 - // from a revoked host token would leave push permanently broken and silent — - // the failure mode this whole feature is most prone to. - if (!response.ok) throw new Error(`${route} failed (${response.status})`); - return response; -} - -/** - * The devices the settings dialog names: subscribed on the Server **and** still - * active in the Host's ACL, joined to the ACL's human labels. - * - * Only the Host can do this join — it holds the ACL, and the Server never - * learns a label (`docs/specs/remote-security-model.md`). The send path does - * not need it, and deliberately does not pay for it; see {@link sendPush}. - */ -async function loadPushDevices(deps: AlertPushDeps): Promise { - const response = await hostFetch(deps, API_ROUTES.pushDevices); - const body = (await response.json()) as PushDevicesResponse; - - const labels = new Map(deps.activeRecords().map((r) => [r.devicePublicKey, r.label])); - return body.devices - .filter((device) => labels.has(device.devicePublicKey)) - .map((device) => ({ - devicePublicKey: device.devicePublicKey, - label: labels.get(device.devicePublicKey) || 'Unnamed device', - })); -} +import { setPushDevices, type PushDevice, type PushDevicesState } from '../../lib/push-devices'; let pushDevicesRefreshSequence = 0; /** - * Refresh the push-device list the Alarm settings dialog reads. Failure is - * reported as `error` rather than an empty list: "we could not ask" and "no - * devices are subscribed" are different things to show a user. + * Run `load` and publish its result to the dialog's store, fenced as below. + * `load` goes over the service bridge (`activation.ts`), because the ACL the + * list is joined against is the Host's — and it answers `null` when no Host is + * running, which is "nowhere to push", not an empty list. Failure is reported + * as `error` rather than an empty list: "we could not ask" and "no devices are + * subscribed" are different things to show a user. */ -export async function refreshPushDevices(deps: AlertPushDeps): Promise { - // Writes are fenced on both Host generation and request order. Generation - // discards a request that outlives stop/re-enrollment; sequence makes - // overlapping requests for the same Host latest-request-wins, so a slow - // startup refresh cannot overwrite a newer dialog refresh. - const generation = getPushDevicesGeneration(); +export async function commitPushDevices( + load: () => Promise, +): Promise { + // Writes are fenced on request order: overlapping requests are + // latest-request-wins, so a slow startup refresh cannot overwrite a newer + // dialog refresh. Which Host answered needs no fence of its own — the service + // reads its own ACL at request time, and a Host that stopped answers + // `no-host` like any other state. const sequence = ++pushDevicesRefreshSequence; const commit = (next: PushDevicesState) => { - if ( - getPushDevicesGeneration() === generation && - pushDevicesRefreshSequence === sequence - ) { - setPushDevices(next); - } + if (pushDevicesRefreshSequence === sequence) setPushDevices(next); }; + // The same fence covers {@link invalidatePushDeviceRefreshes}: a Host that + // went away is not a newer request, but it has the same claim on the result. commit({ status: 'loading', devices: [] }); try { - commit({ status: 'ready', devices: await loadPushDevices(deps) }); + const devices = await load(); + commit(devices ? { status: 'ready', devices } : { status: 'no-host', devices: [] }); } catch { commit({ status: 'error', devices: [] }); } } -async function sendPush(deps: AlertPushDeps, sessionId: string): Promise { - // Read straight from the ACL, which is local and in-memory, rather than - // asking the Server which devices are subscribed: the Server intersects the - // names it is given with its own subscriptions anyway, so the target set is - // identical and this costs one round trip instead of two on the one path - // whose whole value is timeliness. - // - // Naming targets at all is the security-relevant part. Nothing propagates a - // revocation today (`docs/specs/remote-security-model.md` -> Future), so a - // revoked Client keeps its subscription row; letting the Server choose - // recipients would keep pushing Pane labels to a de-authorized phone. Read at - // send time, so a revocation during the delay takes effect. - const devicePublicKeys = deps.activeRecords().map((record) => record.devicePublicKey); - if (devicePublicKeys.length === 0) return; - - const response = await hostFetch(deps, API_ROUTES.pushSend, { - devicePublicKeys, - title: toPushText(deriveSessionLabel(sessionId)), - body: PUSH_BODY, - // Per-Session collapse key: a Pane that rings, is cleared, and rings again - // replaces its own notification rather than stacking copies. Internal ids - // only — a tag is never displayed. - tag: sessionId, - }); - // `hostFetch` threw on a non-2xx; this is the quieter failure class — the - // Server accepted the send but a push service refused delivery, which it - // reports in counts on an HTTP 200. Without this check an all-failed fan-out - // is indistinguishable from success. - const result = (await response.json()) as PushSendResponse; - if (result.failed > 0 || result.delivered === 0) { - console.warn('remote-host: push was not delivered to every device', result); - } +/** + * Discard every refresh currently in flight. + * + * Called when the Host goes away (`activation.ts`, the enrolled gate's disarm). + * A request that was already on the wire resolves afterwards and would otherwise + * repopulate the dialog with devices there is no longer anything to push to — + * the list would name phones and the Host behind them would be gone. + */ +export function invalidatePushDeviceRefreshes(): void { + pushDevicesRefreshSequence += 1; } /** - * Watch the activity store for fresh rings and push the unattended ones. - * Returns a disposer that cancels everything pending. + * Watch the activity store for fresh rings and hand the unattended ones to + * `fire`, with the Session's display label already derived. Returns a disposer + * that cancels everything pending. + * + * The label is derived here, in the webview, because that is where the pane + * stores are — a Host in another process is told what the Session is called + * rather than guessing (`push-delivery.ts`). */ -export function startAlertPush(deps: AlertPushDeps): () => void { +export function watchPushRings(fire: (sessionId: string, title: string) => void): () => void { return watchUnattendedRings({ enabled: () => getAlertSettings().pushEnabled, delayMs: () => getAlertSettings().pushDelayMs, - fire: (id) => { - // A push that fails must never break the alert path, and there is nothing - // useful to retry against — the alarm is already stale by the next ring. - void sendPush(deps, id).catch((error: unknown) => { - console.warn('remote-host: push notification failed', error); - }); - }, + fire: (id) => fire(id, deriveSessionLabel(id)), }); } diff --git a/lib/src/remote/host/enrolled-gate.ts b/lib/src/remote/host/enrolled-gate.ts new file mode 100644 index 00000000..7f1813d1 --- /dev/null +++ b/lib/src/remote/host/enrolled-gate.ts @@ -0,0 +1,56 @@ +/** + * Arm something only while there is a Host to serve. + * + * Answering the Host is free — a webview replies to an ask and goes back to + * sleep — but *volunteering* is not: announcing that the directory may have + * changed costs a crossing into the Host's process on every pane-state change, + * every activity change, and every focus move, and watching for unattended + * rings costs a subscription to the activity store, forever, on a machine whose + * owner may never enroll a Host at all. + * + * So the outbound half is gated on the service's own answer: it announces + * `{ name: 'status', enrolled }` whenever its lifecycle changes that + * (`lib/src/host/remote/service.ts`), and the seed is one `status` command at + * install time, because a webview that opens after the enrollment would + * otherwise wait for a change that already happened. + */ + +import type { RemoteHostConsoleStatus, HostStatusEvent } from '../../host/remote/service-protocol'; +import type { RemoteHostLink } from '../../lib/platform/types'; + +/** + * Run `arm` while the Host service is enrolled and its disarm when it is not, + * starting from whatever `status` reports. Returns the disposer, which disarms + * too. + * + * The seed cannot lose a race with the event: both travel the same ordered + * channel, so a status that changed after the command was sent arrives as an + * event behind the seed's own result. + */ +export function armWhileEnrolled(link: RemoteHostLink, arm: () => () => void): () => void { + let disarm: (() => void) | null = null; + + const apply = (enrolled: boolean): void => { + if (enrolled === !!disarm) return; + if (enrolled) { + disarm = arm(); + return; + } + disarm?.(); + disarm = null; + }; + + const unsubscribe = link.on('status', (data) => { + apply(!!(data as HostStatusEvent | null)?.enrolled); + }); + void link + .command('status') + .then((status) => apply(!!(status as RemoteHostConsoleStatus | null)?.enrolled)) + // No Host to report one: nothing to arm, which is already the state. + .catch(() => {}); + + return () => { + unsubscribe(); + apply(false); + }; +} diff --git a/lib/src/remote/host/enrollment.test.ts b/lib/src/remote/host/enrollment.test.ts index fba9b442..885b2260 100644 --- a/lib/src/remote/host/enrollment.test.ts +++ b/lib/src/remote/host/enrollment.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - ENROLLMENT_KEY, - clearEnrollment, - enrollHost, - getEnrollment, -} from './enrollment'; +import { clearEnrollment, getEnrollment, performEnrollment } from './enrollment'; +import { ENROLLMENT_KEY } from './store'; function stubLocalStorage(): Map { const store = new Map(); @@ -19,7 +15,7 @@ function stubLocalStorage(): Map { describe('remote-host enrollment', () => { afterEach(() => vi.unstubAllGlobals()); - it('posts to /api/host/enroll, normalizes the url, and persists', async () => { + it('posts to /api/host/enroll, normalizes the url, and persists nothing', async () => { const store = stubLocalStorage(); const fetchMock = vi.fn(async () => new Response( @@ -35,11 +31,11 @@ describe('remote-host enrollment', () => { vi.stubGlobal('fetch', fetchMock); // Trailing slash should be stripped before appending the route. - const enrollment = await enrollHost('https://dormouse.example/', 'hunter2', 'My Laptop'); + const enrollment = await performEnrollment('https://dormouse.example/', 'hunter2', 'My Laptop'); expect(fetchMock).toHaveBeenCalledWith( 'https://dormouse.example/api/host/enroll', - expect.objectContaining({ method: 'POST' }), + expect.objectContaining({ method: 'POST', redirect: 'error' }), ); const body = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); expect(body).toEqual({ password: 'hunter2', label: 'My Laptop' }); @@ -51,17 +47,49 @@ describe('remote-host enrollment', () => { origin: 'https://dormouse.example', rpId: 'dormouse.example', }); - expect(JSON.parse(store.get(ENROLLMENT_KEY)!)).toEqual(enrollment); - expect(getEnrollment()).toEqual(enrollment); + // The service that asked decides where the credentials live; the exchange + // itself writes nowhere. + expect(store.size).toBe(0); + }); + + it('gives up on a relay that accepts the connection and never answers', async () => { + // This exchange runs on the Host service's lifecycle chain, where every + // start/stop command queues behind it, so a black-holed relay must not be + // allowed to wedge them for the platform's default socket timeout. + stubLocalStorage(); + const controller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal); + let seen: AbortSignal | undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + seen = init.signal ?? undefined; + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }), + ); + + const pending = performEnrollment('https://dormouse.example', 'hunter2', 'x'); + // Below the webview's own 15 s command budget, so the console that asked + // sees the real error rather than a bare timeout. + expect(timeout).toHaveBeenCalledWith(10_000); + expect(seen).toBe(controller.signal); + + controller.abort(); + await expect(pending).rejects.toThrow(/abort/i); + timeout.mockRestore(); }); 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/); + await expect(performEnrollment('https://dormouse.example', 'wrong', 'x')).rejects.toThrow(/401/); }); it('clears and rejects malformed persisted enrollment', () => { + // What a webview that enrolled before the service existed still holds, and + // hands over once (`activation.ts` → adoption). const store = stubLocalStorage(); expect(getEnrollment()).toBeNull(); diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index 59891f2a..f18ecd9c 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -6,12 +6,17 @@ * 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. + * The Host that holds the socket is a service in the process that owns the + * PTYs, and it persists this through its own store (a 0600 file in the sidecar, + * `SecretStorage` in VS Code — `lib/src/host/remote/host-state-store.ts`). + * What is left here of the browser's `localStorage` copy is the read path: a + * webview that enrolled before the service existed still has one, and hands it + * over once (`activation.ts` → adoption). */ import { API_ROUTES, type HostEnrollResponse } from 'server-lib-common'; -import { loadJson, saveJson } from '../../lib/local-json-store'; +import { loadJson, removeJson } from '../../lib/local-json-store'; +import { ENROLLMENT_KEY } from './store'; export interface HostEnrollment { /** Origin the Server is reachable at, e.g. `https://dormouse.tailnet.ts.net`. */ @@ -25,10 +30,13 @@ export interface HostEnrollment { 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 { +/** + * The shape guard, exported because everywhere an enrollment is *read* — a + * keychain entry, a JSON file, an `adopt` a webview sent — it arrives as + * `unknown` and has to be checked. One copy, so a field added here cannot be + * silently accepted by a store that never learned about it. + */ +export function isEnrollment(value: unknown): value is HostEnrollment { if (!value || typeof value !== 'object') return false; const v = value as Record; return ( @@ -46,23 +54,21 @@ export function getEnrollment(): HostEnrollment | null { } export function clearEnrollment(): void { - try { - globalThis.localStorage?.removeItem(ENROLLMENT_KEY); - } catch { - // No localStorage (some host/test contexts): nothing to clear. - } + removeJson(ENROLLMENT_KEY); } -function saveEnrollment(enrollment: HostEnrollment): void { - saveJson(ENROLLMENT_KEY, enrollment); -} +const ENROLL_TIMEOUT_MS = 10_000; /** - * `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. + * `POST /api/host/enroll` with the setup password and map the response to an + * enrollment. Throws with the server's status text on failure so the caller + * (console hook / settings UI) can surface it. + * + * Persists nothing: the service that ran it decides where the credentials live + * (`lib/src/host/remote/host-state-store.ts`), while the exchange itself is one + * exchange, and a second copy of it could drift from the Server's contract. */ -export async function enrollHost( +export async function performEnrollment( serverUrl: string, password: string, label: string, @@ -70,6 +76,17 @@ export async function enrollHost( const base = serverUrl.replace(/\/+$/, ''); const response = await fetch(`${base}${API_ROUTES.hostEnroll}`, { method: 'POST', + // This runs on the Host service's lifecycle chain, where everything that + // starts or stops the Host queues behind it — so a relay that accepts the + // connection and then answers nothing would wedge every later command for + // as long as the platform's default socket timeout, which is minutes. Below + // the webview's own 15 s command budget (`link-client.ts`) on purpose: the + // console then sees "the server did not answer" rather than a bare timeout. + signal: AbortSignal.timeout(ENROLL_TIMEOUT_MS), + // The Node-resident Host has no browser CSP to check each redirect hop. + // Failing here keeps an allowed origin's open redirect from forwarding the + // setup password to a server outside the build-time allowlist. + redirect: 'error', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password, label }), }); @@ -78,13 +95,11 @@ export async function enrollHost( throw new Error(`host enroll failed (${response.status})${detail ? `: ${detail}` : ''}`); } const body = (await response.json()) as HostEnrollResponse; - const enrollment: HostEnrollment = { + return { 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/host-surface-provider.ts b/lib/src/remote/host/host-surface-provider.ts new file mode 100644 index 00000000..da04bfab --- /dev/null +++ b/lib/src/remote/host/host-surface-provider.ts @@ -0,0 +1,119 @@ +/** + * The seam between protocol-v1 and wherever the Host's surfaces actually live. + * + * `RemoteApiSession` speaks the wire and nothing else: surface ids, PTY ids, + * sizes, and bytes (docs/specs/remote-api.md). *Where* a named surface lives — + * this webview's xterm registry, a sibling webview's, another window's, or a + * webview the Node host service fans out to — is a deployment fact, not a + * protocol concept, so every environment-specific answer is behind this + * interface and the session never imports the platform adapter, the stores, or + * `document`. + * + * Both implementations are Node-side: the Tauri sidecar's + * (`lib/src/host/remote/sidecar-entry.ts`) and the VS Code extension host's + * (`vscode-ext/src/remote-host.ts`), each answering from the process that owns + * the PTYs with the webviews demoted to surface responders. + * + * Types only — this module must stay environment-free so the session and its + * tests can be imported anywhere. + */ + +import type { DirectoryEntry } from 'server-lib-common'; + +// Re-exported so an implementor can name the entry type without depending on +// `server-lib-common` itself; vscode-ext's project does not resolve it. +export type { DirectoryEntry }; + +export interface SurfaceHandle { + /** + * Provider-local routing key; a peer-backed handle need not expose its + * owner's raw PTY id. + */ + readonly ptyId: string; + /** The size the surface stands at now — live for a local pane, last-reported for a peer's. */ + readonly cols: number; + readonly rows: number; + /** Resize through the owner's live xterm, and report what it settled at. */ + resize(cols: number, rows: number): Promise<{ cols: number; rows: number }>; + /** Let go: stops a peer's stream, nothing to undo for a local pane. */ + release(): void; +} + +/** + * One attachment's view of a PTY. Exit carries no id: a sink is subscribed to + * exactly one PTY, so there is nothing to filter and no way to mistake another + * PTY's death for this one's. + */ +export interface PtySink { + onData(data: string): void; + onExit(exitCode: number): void; +} + +export interface PtyStream { + /** Stop this sink's stream. Idempotent after exit. */ + stop(): void; + /** + * Settles only after the sink is installed at the PTY owner. For an in-process + * owner this is already resolved; a cross-window provider waits for the peer's + * subscription acknowledgement. + */ + readonly ready: Promise; +} + +export interface HostSurfaceProvider { + /** + * Every surface the Host can reach right now, from wherever they live — + * peers included, so the session emits one snapshot per collect rather than + * knowing that some entries arrive later than others. + */ + collectDirectory(): Promise; + + /** + * Fire `onChange` whenever a future {@link collectDirectory} could differ — + * pane state, activity, focus, peer membership. Returns the unsubscribe. The + * session coalesces, so firing too often is cheap and missing a change is not. + */ + watchDirectory(onChange: () => void): () => void; + + /** + * Take hold of `surfaceId` at the size the client asked for, or `null` if + * nobody owns it. + * + * The size is part of resolving because attach-is-the-resize + * (docs/specs/remote-api.md): an owner that is a round trip away has to apply + * it inside the attach, since there is no way to reach into its xterm + * afterwards without a second one. An owner the provider can touch directly + * is left alone here and resized by the caller, which subscribes to the PTY + * first so a synchronous repaint is not lost — the resolved handle reports + * the size as it stands, and the caller reconciles. + */ + resolveSurface( + surfaceId: string, + size: { cols?: number; rows?: number }, + ): Promise; + + /** Feed the PTY's input path; the local echo returns through {@link streamPty}. */ + writePty(ptyId: string, data: string): void; + + /** + * Resize the PTY *only*, leaving any owning xterm alone. This is the + * same-size repaint bounce's path, not the attach path — attach-is-the-resize + * goes through {@link SurfaceHandle.resize} so the owner's own view follows. + */ + resizePty(ptyId: string, cols: number, rows: number): void; + + /** + * Subscribe to one PTY's output and exit. Subscription and liveness observation + * are atomic at the owner: if this PTY already exited, call `sink.onExit` + * before `ready` settles. In-process providers replay synchronously; a + * cross-window provider waits for the owner's acknowledgement, ordered after + * any replay on the same socket. That closes the asynchronous + * `resolveSurface` -> subscription gap without making the protocol session + * know how either Host records PTY lifetime. + * + * Per-PTY rather than a global stream the caller filters, so an attachment + * cannot leak another attachment's bytes and unsubscribing cannot outlive its + * id. + */ + streamPty(ptyId: string, sink: PtySink): PtyStream; +} diff --git a/lib/src/remote/host/pairing-approval.ts b/lib/src/remote/host/pairing-approval.ts index 60f35375..bf7028b5 100644 --- a/lib/src/remote/host/pairing-approval.ts +++ b/lib/src/remote/host/pairing-approval.ts @@ -1,16 +1,23 @@ /** * 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). + * `external-link-confirmation.ts`) that backs the React approval modal. + * + * The ceremony itself runs in the Host service, which is where the ACL is + * (`lib/src/host/remote/service.ts`). This is the webview's mirror of its + * queue: the service pushes a snapshot, `activation.ts` projects it here, and + * `approve`/`deny` send a command back keyed by both `clientId` and the + * immutable `pairingId` the modal displayed — so the closures that can + * actually write the ACL never leave that process, and a stale modal cannot + * answer a replacement request under the same client id. */ import type { PairingRequest } from 'server-lib-common'; export interface PendingPairing { - /** Server-assigned client socket id; the approve/deny reply is keyed by it. */ + /** Server-assigned client socket id. */ clientId: string; + /** Immutable ceremony ticket id; approve/deny must name this exact request. */ + pairingId: string; request: PairingRequest; requestedAt: number; /** Approve locally on the Host — writes the ACL and replies `pair-result`. */ diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts new file mode 100644 index 00000000..3a88cf8d --- /dev/null +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -0,0 +1,209 @@ +/** + * The surface responder: what a webview answers when the Host — a service in + * the process that owns the PTYs — asks what this webview's panes are called + * and drives them (docs/specs/vscode.md → "Peer surfaces"). + * + * The asking side lives in the Host and is covered by `remote-api.test.ts` + * against a fake provider. What is only testable here is the registry side: + * presence-is-ownership, attach-is-the-resize going through the live xterm, and + * the invalidation that tells the Host to re-collect. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakePtyAdapter, setPlatform, type PlatformAdapter } from '../../lib/platform'; +import { primeActivity, clearPrimedActivity } from '../../lib/session-activity-store'; +import { registry, type TerminalEntry } from '../../lib/terminal-store'; +import { installPeerSurfaceResponder } from './peer-surfaces'; + +interface Responder { + (params: unknown): unknown[]; +} + +/** A platform whose `remoteHost` link stands in for the Host service. */ +class ServicePlatform { + readonly responders = new Map(); + /** How many crossings into the Host's process this webview has paid for. */ + notified = 0; + /** What `status` answers — the gate the notify sources arm on. */ + enrolled = true; + + readonly remoteHost = { + command: async (cmd: string) => (cmd === 'status' ? { enrolled: this.enrolled } : undefined), + respond: (op: string, handler: Responder) => { + this.responders.set(op, handler); + }, + notify: () => { + this.notified += 1; + }, + on: () => () => {}, + }; + + answer(op: string, params: unknown): unknown[] { + const handler = this.responders.get(op); + if (!handler) throw new Error(`nothing responds to ${op}`); + return handler(params); + } + + asAdapter(): PlatformAdapter { + return this as unknown as PlatformAdapter; + } +} + +/** A pane in this webview's registry, with a terminal that records resizes. */ +function registerSurface(surfaceId: string, ptyId: string, cols = 80, rows = 24) { + const terminal = { + cols, + rows, + resize: vi.fn((nextCols: number, nextRows: number) => { + terminal.cols = nextCols; + terminal.rows = nextRows; + }), + }; + registry.set(surfaceId, { ptyId, terminal } as unknown as TerminalEntry); + return terminal; +} + +let platform: ServicePlatform; + +/** The `status` seed is a round trip; the notify sources arm when it lands. */ +async function armed(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + platform = new ServicePlatform(); + setPlatform(platform.asAdapter()); + installPeerSurfaceResponder(); +}); + +afterEach(() => { + registry.clear(); + clearPrimedActivity(); + setPlatform(new FakePtyAdapter()); +}); + +describe('surface responder', () => { + it('answers with nothing for a surface this webview does not own', () => { + // Presence *is* ownership: every webview answers, and only the owner's + // answer is non-empty, so nobody has to say "not mine". + expect(platform.answer('surfaceOp', { surfaceId: 'elsewhere', op: 'attach' })).toEqual([]); + }); + + it('resolves ownership without resizing the live xterm', () => { + const terminal = registerSurface('surface-1', 'pty-1'); + + expect(platform.answer('surfaceOp', { + surfaceId: 'surface-1', op: 'resolve', cols: 100, rows: 30, + })).toEqual([{ ptyId: 'pty-1', cols: 80, rows: 24 }]); + expect(terminal.resize).not.toHaveBeenCalled(); + }); + + it('resizes the live xterm on attach and reports what it settled at', () => { + const terminal = registerSurface('surface-1', 'pty-1'); + + const results = platform.answer('surfaceOp', { + surfaceId: 'surface-1', op: 'attach', cols: 100, rows: 30, + }); + + // Through the xterm, not the PTY: otherwise the owning pane's own view + // drifts from the size the phone set. + expect(terminal.resize).toHaveBeenCalledWith(100, 30); + expect(results).toEqual([{ ptyId: 'pty-1', cols: 100, rows: 30 }]); + }); + + it('treats a later resize exactly like the attach', () => { + const terminal = registerSurface('surface-1', 'pty-1'); + platform.answer('surfaceOp', { surfaceId: 'surface-1', op: 'attach', cols: 100, rows: 30 }); + + const results = platform.answer('surfaceOp', { + surfaceId: 'surface-1', op: 'resize', cols: 120, rows: 40, + }); + + expect(terminal.resize).toHaveBeenLastCalledWith(120, 40); + expect(results).toEqual([{ ptyId: 'pty-1', cols: 120, rows: 40 }]); + }); + + it('clamps a size the client asked for, and keeps the current one when it asks for none', () => { + const terminal = registerSurface('surface-1', 'pty-1', 80, 24); + + expect(platform.answer('surfaceOp', { surfaceId: 'surface-1', op: 'attach' })).toEqual([ + { ptyId: 'pty-1', cols: 80, rows: 24 }, + ]); + expect(terminal.resize).not.toHaveBeenCalled(); + + const clamped = platform.answer('surfaceOp', { + surfaceId: 'surface-1', op: 'resize', cols: 0, rows: -5, + }) as Array<{ cols: number; rows: number }>; + expect(clamped[0]!.cols).toBeGreaterThan(0); + expect(clamped[0]!.rows).toBeGreaterThan(0); + }); + + it('answers the directory with this webview snapshot', () => { + registerSurface('surface-1', 'pty-1'); + const entries = platform.answer('directory', {}) as Array<{ surfaceId: string }>; + expect(entries.map((entry) => entry.surfaceId)).toEqual(['surface-1']); + }); + + it('tells the Host when a future directory answer could differ', async () => { + // The Host has no view of the activity store, so a ring that changes an + // entry is only visible to it if this webview says so. + await armed(); + primeActivity('pty-1', { status: 'ALERT_RINGING' }); + await Promise.resolve(); + expect(platform.notified).toBe(1); + }); + + it('coalesces a burst of changes into one crossing', async () => { + // A focus move alone is two events, and a pane-state change usually lands + // with an activity change. The Host re-collects the whole directory either + // way, so the burst is worth exactly one notify. + await armed(); + primeActivity('pty-1', { status: 'ALERT_RINGING' }); + primeActivity('pty-2', { status: 'ALERT_RINGING' }); + expect(platform.notified).toBe(0); + + await Promise.resolve(); + expect(platform.notified).toBe(1); + + // And the next burst is announced on its own. + primeActivity('pty-3', { status: 'ALERT_RINGING' }); + await Promise.resolve(); + expect(platform.notified).toBe(2); + }); + + it('installs its announcing half once, however often it is called', async () => { + // `RemotePairingModalHost` mounts twice under StrictMode. A second install + // adds a second set of pane-state, activity, and focus listeners with no + // handle left to remove them, so every change would cross into the Host's + // process twice for the rest of the session. + installPeerSurfaceResponder(); + installPeerSurfaceResponder(); + await armed(); + + primeActivity('pty-1', { status: 'ALERT_RINGING' }); + await Promise.resolve(); + expect(platform.notified).toBe(1); + // And answering still works after the extra calls. + registerSurface('surface-1', 'pty-1'); + expect(platform.answer('directory', {})).toHaveLength(1); + }); + + it('announces nothing until there is a Host to hear it', async () => { + // A machine that never enrolled pays no crossing per activity change, + // which is most machines most of the time. + platform.enrolled = false; + const quiet = new ServicePlatform(); + quiet.enrolled = false; + setPlatform(quiet.asAdapter()); + installPeerSurfaceResponder(); + await armed(); + + primeActivity('pty-2', { status: 'ALERT_RINGING' }); + await Promise.resolve(); + expect(quiet.notified).toBe(0); + // Answering still works: it costs nothing until the Host asks. + registerSurface('surface-2', 'pty-2'); + expect(quiet.answer('directory', {})).toHaveLength(1); + }); +}); diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts new file mode 100644 index 00000000..7952f62a --- /dev/null +++ b/lib/src/remote/host/peer-surfaces.ts @@ -0,0 +1,168 @@ +/** + * What the Host may ask a webview, and the answers it gives back + * (docs/specs/vscode.md → "Peer surfaces"). + * + * The Host runs in the process that owns the PTYs, but a window's terminals are + * spread across its webviews and each webview has its own xterm registry. So + * *every* webview installs the responder here: it answers what the panes this + * webview owns are called, and drives them when the Host asks. + * + * This is also the one place the operations have real types. The platform + * adapter, the extension-host broker, and the cross-window socket all treat + * `op` as opaque, because *what* a webview can be asked belongs to the remote + * Host and not to any of them — {@link PeerOps} is the whole vocabulary, and + * adding an operation means one entry here plus its caller, not a parallel + * ladder of message types at every layer. + * + * Deliberately light — the registry, the directory collector, and a resize. It + * carries none of the relay, enrollment, or pairing machinery, so a webview + * pays almost nothing to make its terminals reachable from the Host. + */ + +import { clampTerminalDimension, type DirectoryEntry } from 'server-lib-common'; +import { getPlatform } from '../../lib/platform'; +import type { RemoteHostLink } from '../../lib/platform/types'; +import { subscribeToActivity } from '../../lib/session-activity-store'; +import { registry } from '../../lib/terminal-store'; +import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; +import { collectDirectorySnapshot } from './directory-collect'; +import { armWhileEnrolled } from './enrolled-gate'; + +/** + * What the Host can ask the owner of a surface to do with it. There is no + * detach: the Host stops streaming on its side, and the pane keeps whatever + * size it was left at — which is what last-attach-wins means. + */ +export type PeerSurfaceOp = 'resolve' | 'attach' | 'resize'; + +export interface PeerSurfaceParams { + surfaceId: string; + op: PeerSurfaceOp; + cols?: number; + rows?: number; +} + +/** + * What the owner reports back. There is no `ok` flag: an owner answers with one + * of these and everyone else answers with nothing, so presence *is* ownership — + * which is also what lets every field be required. + * + * `ptyId` is read by the cross-window link as the routing hint that says which + * window this PTY lives in (`routedPtyId` in `vscode-ext/src/peer-link-protocol.ts`). + */ +export interface PeerSurfaceResult { + ptyId: string; + cols: number; + rows: number; +} + +/** + * Every peer operation, keyed by the name that goes on the wire. `result` is + * the type of *one* answer: a peer contributes zero or more of them, so the + * directory returns its entries and a surface op returns one result or none. + */ +export interface PeerOps { + directory: { params: Record; result: DirectoryEntry }; + surfaceOp: { params: PeerSurfaceParams; result: PeerSurfaceResult }; +} + +/** Answer `op` for this webview's own surfaces. No-op where nobody can ask. */ +function answerPeers( + op: K, + handler: (params: PeerOps[K]['params']) => PeerOps[K]['result'][], +): void { + getPlatform().remoteHost?.respond(op, (params) => handler(params as PeerOps[K]['params'])); +} + +/** + * Resolve or drive one of this webview's own surfaces on the Host's behalf. + * + * `resolve` is the read-only ownership probe that lets a multi-window Host pick + * one duplicate claimant before mutating it. `attach` and `resize` are the same + * operation — attach-is-the-resize + * (docs/specs/remote-api.md) — and both go through the live xterm rather than + * the PTY directly, so the owning pane's own view stays consistent with the + * size the phone asked for. + */ +function driveOwnSurface({ + surfaceId, + op, + cols, + rows, +}: PeerSurfaceParams): PeerSurfaceResult[] { + const entry = registry.get(surfaceId); + if (!entry) return []; + + const term = entry.terminal; + const nextCols = clampTerminalDimension(cols, term.cols); + const nextRows = clampTerminalDimension(rows, term.rows); + if (op !== 'resolve' && (term.cols !== nextCols || term.rows !== nextRows)) { + term.resize(nextCols, nextRows); + } + return [{ ptyId: entry.ptyId, cols: term.cols, rows: term.rows }]; +} + +/** + * The link the announcing half is already installed against. + * + * Answering is idempotent on its own — a responder replaces the one before it — + * but the announcing half is not: every call adds a `status` subscription, and + * every arming under it adds pane-state, activity, and focus listeners with no + * handle left to remove them. A second install would then cross into the Host's + * process twice per change, forever. Keyed by link rather than a bare flag + * because the platform is what owns one: a different adapter is a different + * Host to announce to. + */ +let announcingFor: RemoteHostLink | null = null; + +/** + * Make this webview's terminals reachable from the Host service in the process + * that owns the PTYs. Idempotent, and a no-op on a host with no service behind + * it (the website). + */ +export function installPeerSurfaceResponder(): void { + // Registered unconditionally: answering is stateless, costs nothing until + // asked, and must work the moment a Host starts. + answerPeers('directory', () => collectDirectorySnapshot()); + answerPeers('surfaceOp', driveOwnSurface); + + const link = getPlatform().remoteHost; + if (!link || link === announcingFor) return; + announcingFor = link; + // Announcing is not free — one crossing per pane-state change, activity + // change, and focus move — so it is armed only while a Host exists to hear it + // (`enrolled-gate.ts`). + armWhileEnrolled(link, () => { + let armed = true; + let queued = false; + // Trailing-edge coalesce: these sources fire in bursts — a focus move is a + // focusout and a focusin, and a pane-state change usually lands with an + // activity change — and the Host re-collects the whole directory either way, + // so one crossing per burst is the whole message. + const notifyDirectory = (): void => { + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + // A disarm can land inside the coalesce window, and a Host that is gone + // must not be told anything. + if (armed) link.notify(); + }); + }; + const unsubscribePaneState = subscribeToTerminalPaneState(notifyDirectory); + const unsubscribeActivity = subscribeToActivity(notifyDirectory); + const hasDocument = typeof document !== 'undefined'; + if (hasDocument) { + document.addEventListener('focusin', notifyDirectory); + document.addEventListener('focusout', notifyDirectory); + } + return () => { + armed = false; + unsubscribePaneState(); + unsubscribeActivity(); + if (!hasDocument) return; + document.removeEventListener('focusin', notifyDirectory); + document.removeEventListener('focusout', notifyDirectory); + }; + }); +} diff --git a/lib/src/remote/host/push-delivery.ts b/lib/src/remote/host/push-delivery.ts new file mode 100644 index 00000000..7fd29818 --- /dev/null +++ b/lib/src/remote/host/push-delivery.ts @@ -0,0 +1,146 @@ +/** + * Delivering a push (`docs/specs/alert.md` -> Push notifications): the Host's + * authenticated calls to the Server, and the rule that the Host's own ACL — read + * at send time — chooses who is reached. + * + * Split from `alert-push.ts` because the two halves run in different processes + * once the Host is Node-resident: ring *detection* is webview state (the + * activity store, the alarm settings, the pane's label), while *delivery* needs + * the enrollment and the ACL, which only the Host holds. Nothing here touches + * the DOM or a store, so it runs unchanged in a webview or in the sidecar. + * + * Delivery is an HTTP POST to the Server rather than a relay frame: the relay + * routes between two live sockets, and the whole point of a push is reaching a + * phone whose app is closed. + */ + +import { + API_ROUTES, + boundedPushText, + type HostAclRecord, + type PushDevicesResponse, + type PushSendResponse, +} from 'server-lib-common'; +import type { PushDevice } from '../../lib/push-devices'; +import type { HostEnrollment } from './enrollment'; + +/** + * Longest label we put in a notification title. Every OS truncates well before + * this on a lock screen; the cap exists so a pathological title cannot bloat + * the encrypted payload toward the ~4KB Web Push limit. + */ +const PUSH_TITLE_LIMIT = 100; + +/** Shown as the notification body; the Pane name carries the information. */ +const PUSH_BODY = 'Needs attention'; + +/** + * Apply this sink's bounds to a Pane label. The rule itself is + * `boundedPushText` in `server-lib-common`, shared with the Server so the + * sanitization has one implementation rather than a strong copy here and a + * weaker one there; this wrapper only names the sink's limit and fallback. + */ +export function toPushText(label: string): string { + return boundedPushText(label, { limit: PUSH_TITLE_LIMIT, fallback: 'terminal' }); +} + +export interface AlertPushDeps { + readonly enrollment: Pick; + /** The Host's active ACL records — the authority on who may be reached. */ + readonly activeRecords: () => readonly HostAclRecord[]; + /** Injectable for tests. */ + readonly fetch?: typeof globalThis.fetch; +} + +/** The Host's one authenticated call to the Server. */ +async function hostFetch( + deps: AlertPushDeps, + route: string, + body?: unknown, +): Promise { + const doFetch = deps.fetch ?? globalThis.fetch; + const response = await doFetch(`${deps.enrollment.serverUrl}${route}`, { + ...(body === undefined + ? {} + : { method: 'POST', body: JSON.stringify(body) }), + // The service replaced a webview whose CSP checked every redirect target. + // Do not let an allowed relay bounce the bearer token or notification + // metadata to a destination outside the baked allowlist. + redirect: 'error', + headers: { + authorization: `Bearer ${deps.enrollment.hostToken}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + }); + // Checked here so both call sites fail loudly. A send that swallowed a 401 + // from a revoked host token would leave push permanently broken and silent — + // the failure mode this whole feature is most prone to. + if (!response.ok) throw new Error(`${route} failed (${response.status})`); + return response; +} + +/** + * The devices the settings dialog names: subscribed on the Server **and** still + * active in the Host's ACL, joined to the ACL's human labels. + * + * Only the Host can do this join — it holds the ACL, and the Server never + * learns a label (`docs/specs/remote-security-model.md`). The send path does + * not need it, and deliberately does not pay for it; see {@link sendPush}. + */ +export async function loadPushDevices(deps: AlertPushDeps): Promise { + const response = await hostFetch(deps, API_ROUTES.pushDevices); + const body = (await response.json()) as PushDevicesResponse; + + const labels = new Map(deps.activeRecords().map((r) => [r.devicePublicKey, r.label])); + return body.devices + .filter((device) => labels.has(device.devicePublicKey)) + .map((device) => ({ + devicePublicKey: device.devicePublicKey, + label: labels.get(device.devicePublicKey) || 'Unnamed device', + })); +} + +/** + * Push `title` for one Session to every device the ACL still authorizes. + * + * The label is passed in rather than derived: it comes from the pane stores, + * which live in the webview, so a Host in another process is told what the + * Session is called and never guesses. + */ +export async function sendPush( + deps: AlertPushDeps, + sessionId: string, + title: string, +): Promise { + // Read straight from the ACL, which is local and in-memory, rather than + // asking the Server which devices are subscribed: the Server intersects the + // names it is given with its own subscriptions anyway, so the target set is + // identical and this costs one round trip instead of two on the one path + // whose whole value is timeliness. + // + // Naming targets at all is the security-relevant part. Nothing propagates a + // revocation today (`docs/specs/remote-security-model.md` -> Future), so a + // revoked Client keeps its subscription row; letting the Server choose + // recipients would keep pushing Pane labels to a de-authorized phone. Read at + // send time, so a revocation during the delay takes effect. + const devicePublicKeys = deps.activeRecords().map((record) => record.devicePublicKey); + if (devicePublicKeys.length === 0) return; + + const response = await hostFetch(deps, API_ROUTES.pushSend, { + devicePublicKeys, + title: toPushText(title), + body: PUSH_BODY, + // Per-Session collapse key: a Pane that rings, is cleared, and rings again + // replaces its own notification rather than stacking copies. Internal ids + // only — a tag is never displayed. + tag: sessionId, + }); + // `hostFetch` threw on a non-2xx; this is the quieter failure class — the + // Server accepted the send but a push service refused delivery, which it + // reports in counts on an HTTP 200. Without this check an all-failed fan-out + // is indistinguishable from success. + const result = (await response.json()) as PushSendResponse; + if (result.failed > 0 || result.delivered === 0) { + console.warn('remote-host: push was not delivered to every device', result); + } +} diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 3e15bfe1..4ed8ebb2 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -1,3 +1,11 @@ +/** + * The remote-api session against a fake {@link HostSurfaceProvider}. Everything + * below the protocol — registry, platform adapter, peer round trips — is the + * provider's problem, so these tests are about the protocol only: what the + * client is answered, in what order, and which provider calls a request turns + * into. The webview-backed provider itself is covered by `peer-surfaces.test.ts`. + */ + import { afterEach, describe, expect, it, vi } from 'vitest'; import { REMOTE_EVENTS, @@ -6,87 +14,215 @@ import { toBase64Url, utf8Decode, utf8Encode, + type DirectoryEntry, 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 type { HostSurfaceProvider, PtySink, SurfaceHandle } from './host-surface-provider'; 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}`); - }); - readonly writePty = vi.fn(); +/** A surface the fake owns, standing in for a live xterm at a known size. */ +interface FakeSurface { + ptyId: string; + cols: number; + rows: number; +} - onPtyData(handler: DataHandler): void { - this.dataHandlers.add(handler); - } +/** + * A provider whose PTYs repaint on every resize, the way a real one does: that + * repaint is the only thing that fills the client's screen (attach-is-the-resize), + * so its timing relative to the attach response is load-bearing. + */ +class FakeProvider implements HostSurfaceProvider { + readonly surfaces = new Map(); - offPtyData(handler: DataHandler): void { - this.dataHandlers.delete(handler); - } + /** `resizePty` — the PTY-only path used by the same-size repaint bounce. */ + readonly ptyResizes: Array<[string, number, number]> = []; + /** `handle.resize` — the through-the-owner path an attach/resize takes. */ + readonly handleResizes: Array<[string, number, number]> = []; + readonly writes: Array<[string, string]> = []; + readonly released: string[] = []; + readonly streamed: string[] = []; + readonly unstreamed: string[] = []; + readonly resolved: string[] = []; + + entries: DirectoryEntry[] = []; + collects = 0; + watchers = 0; + collectError: Error | null = null; + resolveError: Error | null = null; + resizeError: Error | null = null; + + /** Hold every resolve open, the way an owner a round trip away would. */ + resolveGate: Promise | null = null; + /** Hold every directory collect open. */ + collectGate: Promise | null = null; + /** Hold every `handle.resize` open — the deferred half of an attach. */ + resizeGate: Promise | null = null; + /** Hold stream readiness, as a cross-window subscribe acknowledgement does. */ + streamReadyGate: Promise | null = null; + + readonly #sinks = new Map>(); + readonly #exits = new Map(); + readonly #onChange = new Set<() => void>(); + + // --- HostSurfaceProvider --- + + collectDirectory = async (): Promise => { + this.collects += 1; + await this.collectGate; + if (this.collectError) throw this.collectError; + return this.entries; + }; + + watchDirectory = (onChange: () => void): (() => void) => { + this.watchers += 1; + this.#onChange.add(onChange); + return () => { + this.watchers -= 1; + this.#onChange.delete(onChange); + }; + }; + + resolveSurface = async (surfaceId: string): Promise => { + this.resolved.push(surfaceId); + const surface = this.surfaces.get(surfaceId); + await this.resolveGate; + if (this.resolveError) throw this.resolveError; + return surface ? this.#handleFor(surface) : null; + }; + + writePty = (ptyId: string, data: string): void => { + this.writes.push([ptyId, data]); + }; + + resizePty = (ptyId: string, cols: number, rows: number): void => { + this.ptyResizes.push([ptyId, cols, rows]); + this.emitData(ptyId, `pty-resize:${cols}x${rows}`); + }; + + streamPty = (ptyId: string, sink: PtySink) => { + this.streamed.push(ptyId); + let sinks = this.#sinks.get(ptyId); + if (!sinks) { + sinks = new Set(); + this.#sinks.set(ptyId, sinks); + } + sinks.add(sink); + const stop = () => { + this.unstreamed.push(ptyId); + sinks.delete(sink); + }; + // The production providers bridge the resolve -> subscribe gap this way: + // subscription replays an exit that landed before there was a sink. + if (this.#exits.has(ptyId)) sink.onExit(this.#exits.get(ptyId)!); + return { stop, ready: this.streamReadyGate ?? Promise.resolve() }; + }; + + // --- Test drivers --- - onPtyExit(handler: ExitHandler): void { - this.exitHandlers.add(handler); + addSurface(surfaceId: string, ptyId: string, cols = 80, rows = 24): FakeSurface { + const surface: FakeSurface = { ptyId, cols, rows }; + // A new PTY generation may reuse a pane id; its predecessor's exit does not + // belong to it. + this.#exits.delete(ptyId); + this.surfaces.set(surfaceId, surface); + return surface; } - offPtyExit(handler: ExitHandler): void { - this.exitHandlers.delete(handler); + /** Only a subscriber hears anything — the per-PTY subscription *is* the filter. */ + emitData(ptyId: string, data: string): void { + for (const sink of this.#sinks.get(ptyId) ?? []) sink.onData(data); } - emitData(id: string, data: string): void { - for (const handler of this.dataHandlers) { - handler({ id, data }); - } + emitExit(ptyId: string, exitCode: number): void { + this.#exits.set(ptyId, exitCode); + for (const sink of [...(this.#sinks.get(ptyId) ?? [])]) sink.onExit(exitCode); } - emitExit(id: string, exitCode: number): void { - for (const handler of this.exitHandlers) { - handler({ id, exitCode }); - } + /** Whatever the provider watches for changed; the session decides when to re-collect. */ + changeDirectory(): void { + for (const listener of [...this.#onChange]) listener(); } - asAdapter(): PlatformAdapter { - return this as unknown as PlatformAdapter; + #handleFor(surface: FakeSurface): SurfaceHandle { + return { + ptyId: surface.ptyId, + // Live, and pinned to this surface object rather than to the id it was + // found under, so a swap behind the id cannot move the attachment. + get cols() { + return surface.cols; + }, + get rows() { + return surface.rows; + }, + resize: async (cols, rows) => { + this.handleResizes.push([surface.ptyId, cols, rows]); + // Only when gated: an owner applies the size synchronously and answers + // a round trip later, which several cases below depend on. + if (this.resizeGate) await this.resizeGate; + if (this.resizeError) throw this.resizeError; + if (surface.cols !== cols || surface.rows !== rows) { + surface.cols = cols; + surface.rows = rows; + this.emitData(surface.ptyId, `terminal-resize:${cols}x${rows}`); + } + return { cols: surface.cols, rows: surface.rows }; + }, + release: () => void this.released.push(surface.ptyId), + }; } } -function registerSurface( - platform: RepaintOnResizePlatform, - cols: number, - rows: number, - surfaceId = 'surface-1', - ptyId = 'pty-1', -): void { - 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}`); - }), - }; +/** Hold every gated round trip open until the returned function is called. */ +function gate(): { promise: Promise; release: () => void } { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} - registry.set(surfaceId, { - ptyId, - terminal, - } as unknown as TerminalEntry); +function makeSession(provider: FakeProvider): { session: RemoteApiSession; sent: SentPayload[] } { + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ + hostId: 'host-1', + send: (payload) => void sent.push(payload), + provider, + }); + return { session, sent }; } -function attach(session: RemoteApiSession, cols: number, rows: number, surfaceId = 'surface-1'): void { +/** Let a promise-tailed handler run; microtasks are unaffected by fake timers. */ +async function settle(): Promise { + for (let i = 0; i < 8; i += 1) await Promise.resolve(); +} + +/** + * Resolving a surface is a promise — an owner in another webview is a round + * trip away, and the local path takes the same seam rather than a second one — + * so an attach lands a microtask later even when nothing is gated. + */ +async function attach( + session: RemoteApiSession, + cols: number, + rows: number, + surfaceId = 'surface-1', + requestId = 'attach-1', +): Promise { session.handle({ - requestId: 'attach-1', + requestId, method: REMOTE_METHODS.surfaceAttach, params: { surfaceId, cols, rows }, }); + await settle(); +} + +async function watchDirectory(session: RemoteApiSession, requestId = 'dir-1'): Promise { + session.handle({ requestId, method: REMOTE_METHODS.directoryWatch, params: {} }); + await settle(); } function decodeTerminalData(payload: SentPayload): string { @@ -94,73 +230,326 @@ function decodeTerminalData(payload: SentPayload): string { return utf8Decode(fromBase64Url((event.data as { bytes: string }).bytes)); } -describe('RemoteApiSession surface.attach', () => { - afterEach(() => { - vi.useRealTimers(); - registry.clear(); - setPlatform(new FakePtyAdapter()); +function terminalData(sent: SentPayload[]): string[] { + return sent + .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalData) + .map(decodeTerminalData); +} + +function snapshots(sent: SentPayload[]): Array<{ subId: string; entries: DirectoryEntry[] }> { + return sent + .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.directorySnapshot) + .map((p) => ({ + subId: (p as RemoteEventMsg).subId, + entries: ((p as RemoteEventMsg).data as { entries: DirectoryEntry[] }).entries, + })); +} + +function entry(surfaceId: string, title: string): DirectoryEntry { + return { + paneRef: surfaceId, + surfaceId, + type: 'terminal', + title, + focused: false, + alive: true, + ringing: false, + hasTODO: false, + }; +} + +function reply(sent: SentPayload[], requestId: string): RemoteResponse { + return sent.find((p) => (p as RemoteResponse).requestId === requestId) as RemoteResponse; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('RemoteApiSession hello', () => { + it('reports protocol v1, the host id, and the flat selfhost grants', () => { + const { session, sent } = makeSession(new FakeProvider()); + + session.handle({ requestId: 'hello-1', method: REMOTE_METHODS.hello, params: {} }); + + expect(sent).toEqual([ + { + requestId: 'hello-1', + ok: true, + result: { + protocolVersion: 1, + hostId: 'host-1', + grants: { input: true, layout: false }, + }, + }, + ]); + }); + + it('fails an unknown method rather than dropping it', () => { + const { session, sent } = makeSession(new FakeProvider()); + + session.handle({ requestId: 'x-1', method: 'surface.teleport', params: {} }); + + expect(sent).toEqual([ + { requestId: 'x-1', ok: false, error: 'unknown method: surface.teleport' }, + ]); + }); +}); + +describe('RemoteApiSession directory.watch', () => { + it('answers with the request id as subId and emits one snapshot per collect', async () => { + const provider = new FakeProvider(); + provider.entries = [entry('surface-1', 'near'), entry('surface-far', 'far')]; + const { session, sent } = makeSession(provider); + + await watchDirectory(session); + + expect(sent[0]).toEqual({ requestId: 'dir-1', ok: true, result: { subId: 'dir-1' } }); + // One collect, one snapshot: the provider answers for every surface the + // Host can reach, so there is no partial listing to send ahead of it. + expect(provider.collects).toBe(1); + expect(snapshots(sent)).toEqual([ + { subId: 'dir-1', entries: provider.entries }, + ]); + }); + + it('coalesces a burst of changes into one re-snapshot per debounce window', async () => { + vi.useFakeTimers(); + const provider = new FakeProvider(); + provider.entries = [entry('surface-1', 'before')]; + const { session, sent } = makeSession(provider); + await watchDirectory(session); + + provider.entries = [entry('surface-1', 'after')]; + provider.changeDirectory(); + provider.changeDirectory(); + provider.changeDirectory(); + + // Still inside the 150ms window: nothing re-collected yet. + vi.advanceTimersByTime(149); + await settle(); + expect(provider.collects).toBe(1); + + vi.advanceTimersByTime(1); + await settle(); + expect(provider.collects).toBe(2); + expect(snapshots(sent).map((s) => s.entries)).toEqual([ + [entry('surface-1', 'before')], + [entry('surface-1', 'after')], + ]); + + // A later change opens a fresh window rather than riding the spent timer. + provider.changeDirectory(); + vi.advanceTimersByTime(150); + await settle(); + expect(provider.collects).toBe(3); + }); + + it('drops a snapshot whose collect resolved after the subscription was replaced', async () => { + const provider = new FakeProvider(); + provider.entries = [entry('surface-1', 'stale')]; + const slow = gate(); + provider.collectGate = slow.promise; + const { session, sent } = makeSession(provider); + + await watchDirectory(session, 'dir-1'); + // The client re-watches (a reconnect) before the first collect answers. + provider.collectGate = null; + provider.entries = [entry('surface-1', 'fresh')]; + await watchDirectory(session, 'dir-2'); + slow.release(); + await settle(); + + // The client correlates by subId, so a snapshot for a subscription it has + // already replaced would be an answer to a question it stopped asking. + expect(snapshots(sent)).toEqual([ + { subId: 'dir-2', entries: [entry('surface-1', 'fresh')] }, + ]); + }); + + it('emits only the newest collect when two overlap and settle out of order', async () => { + // Two collects overlap whenever something changes during a slow round trip, + // and the near tier can answer long after the far one. Without a generation + // the older one emits last — and a collect that timed out to an empty + // answer would blank the phone's picker until the next change. + vi.useFakeTimers(); + const provider = new FakeProvider(); + provider.entries = [entry('surface-1', 'first')]; + const { session, sent } = makeSession(provider); + await watchDirectory(session); + expect(snapshots(sent)).toHaveLength(1); + + const slow = gate(); + provider.collectGate = slow.promise; + provider.entries = []; + provider.changeDirectory(); + vi.advanceTimersByTime(150); + await settle(); + expect(provider.collects).toBe(2); + + // A second change while the first is still in flight, answered immediately. + provider.collectGate = null; + provider.entries = [entry('surface-1', 'newest')]; + provider.changeDirectory(); + vi.advanceTimersByTime(150); + await settle(); + + slow.release(); + await settle(); + + expect(snapshots(sent).map((s) => s.entries)).toEqual([ + [entry('surface-1', 'first')], + [entry('surface-1', 'newest')], + ]); + }); + + it('watches once across repeated directory.watch requests', async () => { + const provider = new FakeProvider(); + const { session } = makeSession(provider); + + await watchDirectory(session, 'dir-1'); + await watchDirectory(session, 'dir-2'); + + expect(provider.watchers).toBe(1); }); - 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) }); + it('stops watching on dispose and drops a snapshot that lands afterwards', async () => { + const provider = new FakeProvider(); + const slow = gate(); + provider.collectGate = slow.promise; + const { session, sent } = makeSession(provider); + await watchDirectory(session); - attach(session, 100, 30); + session.dispose(); + slow.release(); + await settle(); + provider.changeDirectory(); + await settle(); + expect(provider.watchers).toBe(0); + expect(snapshots(sent)).toEqual([]); + }); + + it('keeps the last good snapshot and retries after collection rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const provider = new FakeProvider(); + provider.entries = [entry('surface-1', 'before')]; + const { session, sent } = makeSession(provider); + await watchDirectory(session, 'dir-1'); + + provider.collectError = new Error('peer unavailable'); + await watchDirectory(session, 'dir-2'); + + expect(snapshots(sent)).toEqual([ + { subId: 'dir-1', entries: [entry('surface-1', 'before')] }, + ]); + expect(warn).toHaveBeenCalledWith( + 'remote-host: directory collection failed', + provider.collectError, + ); + + provider.collectError = null; + provider.entries = [entry('surface-1', 'after')]; + await watchDirectory(session, 'dir-3'); + + expect(snapshots(sent)).toEqual([ + { subId: 'dir-1', entries: [entry('surface-1', 'before')] }, + { subId: 'dir-3', entries: [entry('surface-1', 'after')] }, + ]); + }); +}); + +describe('RemoteApiSession surface.attach', () => { + it('resizes through the handle and keeps the synchronous repaint data', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + + await attach(session, 100, 30); + + // Attach-is-the-resize goes through the owner, not the PTY. + expect(provider.handleResizes).toEqual([['pty-1', 100, 30]]); + expect(provider.ptyResizes).toEqual([]); 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, - }); + // The repaint fires while the attach is still being answered, so it is + // buffered and flushed after the response — never ahead of it. + 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', () => { + it('falls back to the surface size for a missing dimension', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-1', cols: 120 }, + }); + await settle(); + + expect(provider.handleResizes).toEqual([['pty-1', 120, 24]]); + expect(reply(sent, 'attach-1').result).toEqual({ cols: 120, rows: 24 }); + }); + + it('keeps the synchronous repaint data from the same-size PTY bounce', async () => { 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) }); + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); - attach(session, 80, 24); + await attach(session, 80, 24); - expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + // The size is already right, so the owner is left alone and only the PTY + // is bounced — that SIGWINCH is the whole point. + expect(provider.handleResizes).toEqual([]); + expect(provider.ptyResizes).toEqual([['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(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); + expect(provider.ptyResizes).toEqual([ + ['pty-1', 80, 23], + ['pty-1', 80, 24], + ]); }); - it('does not fire the same-size bounce restore after detaching', () => { + it('bounces a one-row surface upward, where a bounce is not a no-op', async () => { 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) }); + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 1); + const { session } = makeSession(provider); + + await attach(session, 80, 1); - attach(session, 80, 24); + // rows-1 would be 0 — clamped back to the same size, so no SIGWINCH and no + // repaint at all. + expect(provider.ptyResizes).toEqual([['pty-1', 80, 2]]); + vi.advanceTimersByTime(60); + expect(provider.ptyResizes.at(-1)).toEqual(['pty-1', 80, 1]); + }); + + it('does not fire the same-size bounce restore after detaching', async () => { + vi.useFakeTimers(); + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session } = makeSession(provider); + + await 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); + expect(provider.ptyResizes).toEqual([['pty-1', 80, 23]]); // Detach inside the ~60ms window, before the restore fires. session.handle({ @@ -168,49 +557,186 @@ describe('RemoteApiSession surface.attach', () => { 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); + expect(provider.ptyResizes).toEqual([['pty-1', 80, 23]]); }); - it('does not let a stale bounce restore clobber a newer attachment', () => { + it('does not let a stale bounce restore clobber a newer attachment', async () => { 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) }); + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + provider.addSurface('surface-2', 'pty-2', 80, 24); + const { session } = makeSession(provider); // First attach schedules a restore bounce for pty-1. - attach(session, 80, 24, 'surface-1'); - expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + await attach(session, 80, 24, 'surface-1'); + expect(provider.ptyResizes).toEqual([['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); + // Re-attaching to a different surface replaces the attachment + // (last-attach-wins) and must cancel the prior pty-1 restore. + await attach(session, 80, 24, 'surface-2', 'attach-2'); + expect(provider.ptyResizes.at(-1)).toEqual(['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); + // Only the current attachment's restore fires. + expect(provider.ptyResizes).toEqual([ + ['pty-1', 80, 23], + ['pty-2', 80, 23], + ['pty-2', 80, 24], + ]); + }); + + it('replaces the previous attachment, unsubscribing its stream and releasing it', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + provider.addSurface('surface-2', 'pty-2', 80, 24); + const { session, sent } = makeSession(provider); + + await attach(session, 100, 30, 'surface-1'); + await attach(session, 100, 30, 'surface-2', 'attach-2'); + sent.length = 0; + provider.emitData('pty-1', 'from the old attachment'); + + expect(provider.streamed).toEqual(['pty-1', 'pty-2']); + expect(provider.unstreamed).toEqual(['pty-1']); + expect(provider.released).toEqual(['pty-1']); + expect(terminalData(sent)).toEqual([]); + }); + + it('fails an attach for a surface nobody owns', async () => { + const provider = new FakeProvider(); + const { session, sent } = makeSession(provider); + + await attach(session, 80, 24, 'nobody'); + + expect(reply(sent, 'attach-1')).toEqual({ + requestId: 'attach-1', + ok: false, + error: 'no such surface: nobody', + }); + expect(provider.streamed).toEqual([]); + }); + + it('fails an attach when its owner cannot resolve the surface', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + provider.resolveError = new Error('owner unavailable'); + const { session, sent } = makeSession(provider); + + await attach(session, 100, 30); + + expect(reply(sent, 'attach-1')).toEqual({ + requestId: 'attach-1', + ok: false, + error: 'surface attach failed: owner unavailable', + }); + expect(provider.streamed).toEqual([]); + expect(provider.released).toEqual([]); + }); + + it('fails and unwinds an attach whose resize is rejected', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + provider.resizeError = new Error('owner unavailable'); + const { session, sent } = makeSession(provider); + + await attach(session, 100, 30); + + expect(reply(sent, 'attach-1')).toEqual({ + requestId: 'attach-1', + ok: false, + error: 'surface attach failed: owner unavailable', + }); + expect(provider.streamed).toEqual(['pty-1']); + expect(provider.unstreamed).toEqual(['pty-1']); + expect(provider.released).toEqual(['pty-1']); + }); + + it('fails an attach with no surfaceId without asking the provider', async () => { + const provider = new FakeProvider(); + const { session, sent } = makeSession(provider); + + session.handle({ requestId: 'attach-1', method: REMOTE_METHODS.surfaceAttach, params: {} }); + await settle(); + + expect(sent).toEqual([ + { requestId: 'attach-1', ok: false, error: 'no such surface: (none)' }, + ]); + expect(provider.resolved).toEqual([]); + }); + + it('fails a superseded attach and releases the handle it resolved late', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-slow', 'pty-slow', 80, 24); + provider.addSurface('surface-fast', 'pty-fast', 80, 24); + const slow = gate(); + provider.resolveGate = slow.promise; + const { session, sent } = makeSession(provider); + + // The client attaches one pane and switches to another before the first + // owner answers, so the two resolves land out of order. + session.handle({ + requestId: 'attach-slow', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-slow', cols: 80, rows: 24 }, + }); + provider.resolveGate = null; + await attach(session, 100, 30, 'surface-fast', 'attach-fast'); + slow.release(); + await settle(); + + // The superseded attach unwinds the handle it resolved instead of tearing + // down the newer attachment... + expect(provider.released).toEqual(['pty-slow']); + expect(provider.streamed).toEqual(['pty-fast']); + // ...and is answered, because the client holds a request pending until it is. + expect(reply(sent, 'attach-fast').ok).toBe(true); + expect(reply(sent, 'attach-slow').ok).toBe(false); + expect(reply(sent, 'attach-slow').error).toMatch(/superseded/); + + // Input still reaches the surface the client actually attached. + session.handle({ + requestId: 'write-1', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-fast', bytes: toBase64Url(utf8Encode('ls')) }, + }); + expect(provider.writes).toEqual([['pty-fast', 'ls']]); + }); + + it('releases a handle that resolves after dispose, and answers nothing', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const slow = gate(); + provider.resolveGate = slow.promise; + const { session, sent } = makeSession(provider); + + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-1', cols: 80, rows: 24 }, + }); + session.dispose(); + slow.release(); + await settle(); + + expect(provider.released).toEqual(['pty-1']); + expect(provider.streamed).toEqual([]); + // A disposed session has no transport left to answer on. + expect(sent).toEqual([]); }); +}); - 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) }); +describe('RemoteApiSession terminal input', () => { + it('rejects write and resize unless the surface is the current attachment', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const background = provider.addSurface('surface-2', 'pty-2', 100, 30); + const { session, sent } = makeSession(provider); - attach(session, 80, 24, 'surface-1'); + await attach(session, 80, 24, 'surface-1'); sent.length = 0; session.handle({ @@ -224,8 +750,8 @@ describe('RemoteApiSession surface.attach', () => { 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(provider.writes).toEqual([]); + expect(background).toEqual({ ptyId: 'pty-2', cols: 100, rows: 30 }); expect(sent).toEqual([ { requestId: 'write-background', @@ -252,7 +778,7 @@ describe('RemoteApiSession surface.attach', () => { params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('stale\r')) }, }); - expect(platform.writePty).not.toHaveBeenCalled(); + expect(provider.writes).toEqual([]); expect(sent).toEqual([ { requestId: 'write-detached', @@ -262,19 +788,35 @@ 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) }); + it('rejects a write with no surfaceId at all', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + await attach(session, 100, 30); + sent.length = 0; - 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); + session.handle({ + requestId: 'write-1', + method: REMOTE_METHODS.terminalWrite, + params: { bytes: toBase64Url(utf8Encode('x')) }, + }); + + expect(provider.writes).toEqual([]); + expect(sent).toEqual([ + { requestId: 'write-1', ok: false, error: 'no such surface: (none)' }, + ]); + }); + + it('keeps write and resize pinned to the surface resolved at attach', async () => { + const provider = new FakeProvider(); + const attached = provider.addSurface('surface-1', 'pty-1', 80, 24); + const swappedIn = provider.addSurface('surface-2', 'pty-2', 100, 30); + const { session, sent } = makeSession(provider); + + await attach(session, 90, 25, 'surface-1'); + // A Host-side pane swap moves a different terminal behind `surface-1`. + provider.surfaces.set('surface-1', swappedIn); + provider.surfaces.set('surface-2', attached); sent.length = 0; session.handle({ @@ -283,8 +825,7 @@ describe('RemoteApiSession surface.attach', () => { 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)); + expect(provider.writes).toEqual([['pty-1', 'still-attached\r']]); session.handle({ requestId: 'resize-after-swap', @@ -292,41 +833,199 @@ describe('RemoteApiSession surface.attach', () => { 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); + // The owner's resize is synchronous; only the reply waits on the handle, + // which for a pane elsewhere is a round trip. + expect(attached).toEqual({ ptyId: 'pty-1', cols: 120, rows: 40 }); + expect(swappedIn).toEqual({ ptyId: 'pty-2', cols: 100, rows: 30 }); + + await settle(); expect(sent).toEqual([ - { - requestId: 'write-after-swap', - ok: true, - result: {}, - }, + { 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 }, - }, + { requestId: 'resize-after-swap', ok: true, result: { cols: 120, rows: 40 } }, ]); }); - 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) }); + it('clamps a resize and keeps the current size for a dimension it cannot read', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + await attach(session, 90, 25); + provider.handleResizes.length = 0; + sent.length = 0; + + session.handle({ + requestId: 'resize-1', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', cols: 0, rows: 40.7 }, + }); + await settle(); + + expect(provider.handleResizes).toEqual([['pty-1', 1, 40]]); + expect(reply(sent, 'resize-1').result).toEqual({ cols: 1, rows: 40 }); + + session.handle({ + requestId: 'resize-2', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', rows: Number.NaN }, + }); + await settle(); + + // Neither dimension was usable, so the surface keeps the size it has. + expect(provider.handleResizes.at(-1)).toEqual(['pty-1', 1, 40]); + expect(reply(sent, 'resize-2').result).toEqual({ cols: 1, rows: 40 }); + }); + + it('answers a rejected terminal resize instead of leaving it pending', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + await attach(session, 100, 30); + provider.resizeError = new Error('owner unavailable'); + sent.length = 0; + + session.handle({ + requestId: 'resize-1', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, + }); + await settle(); + + expect(reply(sent, 'resize-1')).toEqual({ + requestId: 'resize-1', + ok: false, + error: 'terminal resize failed: owner unavailable', + }); + }); +}); + +describe('RemoteApiSession surface.detach', () => { + it('is idempotent, and a stale detach leaves a newer attachment alone', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + provider.addSurface('surface-2', 'pty-2', 80, 24); + const { session, sent } = makeSession(provider); - attach(session, 100, 30, 'surface-1'); + await attach(session, 100, 30, 'surface-1'); + session.handle({ + requestId: 'detach-1', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + // Detaching again names a surface that is no longer attached: a no-op, not + // an error. + session.handle({ + requestId: 'detach-1-again', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + await attach(session, 100, 30, 'surface-2', 'attach-2'); + sent.length = 0; + + // A detach the client sent before it switched panes must not kill the + // attachment it switched to. + session.handle({ + requestId: 'detach-stale', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + + expect(sent).toEqual([{ requestId: 'detach-stale', ok: true, result: {} }]); + expect(provider.unstreamed).toEqual(['pty-1']); + session.handle({ + requestId: 'write-1', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-2', bytes: toBase64Url(utf8Encode('ok')) }, + }); + expect(provider.writes).toEqual([['pty-2', 'ok']]); + }); +}); + +describe('RemoteApiSession teardown', () => { + it('waits for stream readiness and rejects an exit ordered before it', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + const ready = gate(); + provider.streamReadyGate = ready.promise; + + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-1', cols: 80, rows: 24 }, + }); + await settle(); + + // A peer subscription is installed in another process. Until its ack + // crosses back, even a same-size attach must not bounce or acknowledge. + expect(sent).toEqual([]); + expect(provider.ptyResizes).toEqual([]); + + provider.emitExit('pty-1', 23); + ready.release(); + await settle(); + + expect(reply(sent, 'attach-1')).toEqual({ + requestId: 'attach-1', + ok: false, + error: 'surface closed while attaching: surface-1', + }); + expect(sent.some((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalClosed)).toBe( + false, + ); + expect(provider.ptyResizes).toEqual([]); + }); + + it('fails the attach when the PTY exits while surface resolution is in flight', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + const held = gate(); + provider.resolveGate = held.promise; + + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-1', cols: 80, rows: 24 }, + }); + await settle(); + + // There is no sink until resolution finishes. The provider records this + // exit and replays it synchronously when the session tries to subscribe. + provider.emitExit('pty-1', 23); + held.release(); + await settle(); + + expect(reply(sent, 'attach-1')).toEqual({ + requestId: 'attach-1', + ok: false, + error: 'surface closed while attaching: surface-1', + }); + expect(sent.some((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalClosed)).toBe( + false, + ); + expect(provider.streamed).toEqual(['pty-1']); + expect(provider.unstreamed).toEqual(['pty-1']); + expect(provider.released).toEqual(['pty-1']); + // A dead PTY is never bounced or otherwise resized after the replay. + expect(provider.ptyResizes).toEqual([]); + expect(provider.handleResizes).toEqual([]); + }); + + it('tears down the attachment when the attached PTY exits', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + + await 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); + provider.emitExit('pty-1', 0); // The client is told the terminal closed... expect(sent).toEqual([ @@ -336,6 +1035,8 @@ describe('RemoteApiSession surface.attach', () => { data: { exitCode: 0 }, }, ]); + expect(provider.unstreamed).toEqual(['pty-1']); + expect(provider.released).toEqual(['pty-1']); sent.length = 0; // ...and the attachment is gone, so a later write/resize for that surface @@ -351,8 +1052,8 @@ describe('RemoteApiSession surface.attach', () => { params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, }); - expect(platform.writePty).not.toHaveBeenCalled(); - expect(platform.resizePty).not.toHaveBeenCalled(); + expect(provider.writes).toEqual([]); + expect(provider.handleResizes).toEqual([['pty-1', 100, 30]]); expect(sent).toEqual([ { requestId: 'write-after-exit', @@ -366,4 +1067,75 @@ describe('RemoteApiSession surface.attach', () => { }, ]); }); + + it('fails the attach when the PTY exits while its resize is still in flight', async () => { + // The stream is subscribed before the size settles, so an exit can land in + // the middle of an attach that asked for a different size. The attachment is + // already gone by the time the resize answers, so the attach is failed + // rather than acknowledged — the buffered `terminal.closed` would otherwise + // be flushed for a subscription the client is never given. + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + const held = gate(); + provider.resizeGate = held.promise; + + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-1', cols: 100, rows: 30 }, + }); + await settle(); + expect(provider.handleResizes).toEqual([['pty-1', 100, 30]]); + + provider.emitExit('pty-1', 0); + held.release(); + await settle(); + + expect(reply(sent, 'attach-1')).toEqual({ + requestId: 'attach-1', + ok: false, + error: 'surface closed while attaching: surface-1', + }); + expect(sent.some((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalClosed)).toBe( + false, + ); + expect(provider.unstreamed).toEqual(['pty-1']); + expect(provider.released).toEqual(['pty-1']); + }); + + it('cancels a pending bounce when the attached PTY exits inside the window', async () => { + vi.useFakeTimers(); + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session } = makeSession(provider); + + await attach(session, 80, 24); + provider.emitExit('pty-1', 1); + vi.advanceTimersByTime(60); + + // Restoring the rows of a PTY that is gone is at best pointless. + expect(provider.ptyResizes).toEqual([['pty-1', 80, 23]]); + }); + + it('dispose stops the stream, releases the handle, and ignores later requests', async () => { + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 24); + const { session, sent } = makeSession(provider); + + await attach(session, 100, 30); + await watchDirectory(session); + sent.length = 0; + + session.dispose(); + session.dispose(); // idempotent + + expect(provider.unstreamed).toEqual(['pty-1']); + expect(provider.released).toEqual(['pty-1']); + expect(provider.watchers).toBe(0); + + provider.emitData('pty-1', 'after dispose'); + session.handle({ requestId: 'hello-1', method: REMOTE_METHODS.hello, params: {} }); + expect(sent).toEqual([]); + }); }); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 327327fb..70b64ef0 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -5,7 +5,7 @@ * * - `hello` → capabilities (input yes, layout no). * - `directory.watch` → an immediate snapshot plus coalesced re-snapshots - * whenever pane state / activity / focus changes. + * whenever the provider says the directory could differ. * - `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. @@ -15,6 +15,12 @@ * * 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. + * + * Everything below the protocol — where a surface lives, how a PTY is read and + * written — is a {@link HostSurfaceProvider} call, so this module is + * environment-free: it never reaches for the platform adapter, the stores, or + * `document`, and runs unchanged in a webview or in the process that owns the + * PTYs (`host-surface-provider.ts`). */ import { @@ -26,6 +32,7 @@ import { utf8Decode, utf8Encode, type AttachParams, + type DirectoryEntry, type HelloResult, type RemoteEventMsg, type RemoteRequest, @@ -34,12 +41,7 @@ import { type TerminalResizeParams, type TerminalWriteParams, } 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'; +import type { HostSurfaceProvider, SurfaceHandle } from './host-surface-provider'; /** Coalesce window for directory re-snapshots (remote-api.md: "Host coalesces"). */ const DIRECTORY_DEBOUNCE_MS = 150; @@ -51,11 +53,15 @@ const FORCE_REPAINT_BOUNCE_MS = 60; interface Attachment { surfaceId: string; - ptyId: string; - entry: TerminalEntry; + /** + * The resolved surface. Pinned at attach — a pane swap must not move the + * attachment onto a different terminal — and it is the only thing here that + * knows where the pane actually lives (`host-surface-provider.ts`). + */ + handle: SurfaceHandle; subId: string; - onData: (detail: { id: string; data: string }) => void; - onExit: (detail: { id: string; exitCode: number }) => void; + /** Unsubscribes this attachment's PTY stream; nobody else holds it. */ + stopStream: () => void; /** Pending same-size repaint bounce (see FORCE_REPAINT_BOUNCE_MS), if any. */ bounceTimer: ReturnType | null; } @@ -64,23 +70,31 @@ export interface RemoteApiSessionOptions { hostId: string; /** Sends a remote-api response/event; the caller wraps it in a `msg` frame. */ send: (payload: RemoteResponse | RemoteEventMsg) => void; + /** Everything below the protocol: where surfaces live, and how PTYs are driven. */ + provider: HostSurfaceProvider; } export class RemoteApiSession { readonly #hostId: string; readonly #send: (payload: RemoteResponse | RemoteEventMsg) => void; + readonly #provider: HostSurfaceProvider; #directorySubId: string | null = null; #unsubDirectory: (() => void) | null = null; #directoryTimer: ReturnType | null = null; + #directoryGeneration = 0; #attachment: Attachment | null = null; + #attachGeneration = 0; + #disposed = false; constructor(options: RemoteApiSessionOptions) { this.#hostId = options.hostId; this.#send = options.send; + this.#provider = options.provider; } handle(data: unknown): void { + if (this.#disposed) return; const request = data as RemoteRequest; if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { return; @@ -108,6 +122,8 @@ export class RemoteApiSession { } dispose(): void { + if (this.#disposed) return; + this.#disposed = true; this.#directorySubId = null; if (this.#directoryTimer) { clearTimeout(this.#directoryTimer); @@ -132,29 +148,39 @@ 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 }; - } - #requireAttached(request: RemoteRequest, surfaceId: string): Attachment | null { if (this.#attachment?.surfaceId === surfaceId) return this.#attachment; this.#fail(request, `surface is not attached: ${surfaceId}`); return null; } + /** + * Answer one failed attach — and only when there is anyone to answer. + * + * Every failure on the attach path reads the same way: a disposed session has + * no transport left, and a generation that moved on means a newer attach owns + * the surface, which is what the client is told regardless of what actually + * went wrong, because that is the fact it has to act on. `reason` is the rest, + * for the paths where this attach is still the current one; omitted where + * being superseded is the only way to get there. Failing rather than dropping + * is load-bearing: the client holds the request pending, and its event + * subscription with it. + */ + #failAttach( + request: RemoteRequest, + surfaceId: string, + generation: number, + reason?: string, + ): void { + if (this.#disposed) return; + this.#fail( + request, + reason !== undefined && this.#attachGeneration === generation + ? reason + : `superseded by a newer attach: ${surfaceId}`, + ); + } + #attachedParams

( request: RemoteRequest, ): { params: P; attachment: Attachment } | null { @@ -183,57 +209,136 @@ export class RemoteApiSession { // The subscription id the client correlates snapshots by is this request id. this.#directorySubId = request.requestId; this.#ok(request, { subId: request.requestId }); - this.#emitDirectory(); + void 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); - } - }; + this.#unsubDirectory = this.#provider.watchDirectory(() => this.#scheduleDirectory()); } #scheduleDirectory(): void { if (this.#directorySubId === null || this.#directoryTimer) return; this.#directoryTimer = setTimeout(() => { this.#directoryTimer = null; - this.#emitDirectory(); + void this.#emitDirectory(); }, DIRECTORY_DEBOUNCE_MS); } - #emitDirectory(): void { + async #emitDirectory(): Promise { if (this.#directorySubId === null) return; - this.#event(this.#directorySubId, REMOTE_EVENTS.directorySnapshot, { - entries: collectDirectorySnapshot(), - }); + const subId = this.#directorySubId; + // Per collect, like the attach generation below and for the same reason: + // two collects overlap whenever something changes during a slow round trip, + // and they can settle in either order. Only the newest may emit, or a stale + // one — including a collect that timed out to an empty answer — lands on + // the client after a fresh snapshot and blanks its picker until the next + // change. + const generation = ++this.#directoryGeneration; + // One snapshot per collect. The provider answers for every surface the Host + // can reach, so there is no longer a subset that is known sooner than the + // rest — this replaces the old local-then-merged double emit, which existed + // only because the peer round trip was visible from here. + let entries: DirectoryEntry[]; + try { + entries = await this.#provider.collectDirectory(); + } catch (error) { + // This provider crosses a process/window boundary. A failed collection + // leaves the last good snapshot standing and a later invalidation (or + // re-watch) retries; it must not become an unhandled rejection that can + // take down the Node Host process. + if ( + this.#directorySubId === subId && + this.#directoryGeneration === generation && + !this.#disposed + ) { + console.warn('remote-host: directory collection failed', error); + } + return; + } + // The subscription may have been replaced or torn down while we waited. + if (this.#directorySubId !== subId || this.#directoryGeneration !== generation) return; + this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries }); } #attach(request: RemoteRequest): void { - const resolved = this.#resolveSurface(request); - if (!resolved) return; - const { params, entry } = resolved; + const params = request.params as AttachParams | undefined; + if (!params?.surfaceId) { + this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + return; + } + + // Where the pane lives — a registry here or an owner a round trip away — is + // a deployment fact, not a protocol concept, so it is settled below this + // line and never seen here (`host-surface-provider.ts`). + // + // Per attach, not per session: last-attach-wins has to hold while a + // resolve is in flight, and the two paths are wildly different lengths — a + // sibling's pane is a round trip away while a local one settles on the next + // microtask, so one shared epoch would let the older, slower attach land + // last and take the attachment. + const generation = ++this.#attachGeneration; + void this.#provider.resolveSurface(params.surfaceId, params).then( + (handle) => { + if (this.#disposed || this.#attachGeneration !== generation) { + // A foreign resolve starts its stream before returning the handle. If + // the session died or a newer attach superseded this one during that + // round trip, unwind it immediately. + handle?.release(); + this.#failAttach(request, params.surfaceId, generation); + return; + } + if (!handle) { + this.#fail(request, `no such surface: ${params.surfaceId}`); + return; + } + try { + this.#beginAttach(request, params, handle, generation); + } catch (error) { + // `streamPty` / the repaint bounce are provider calls too, and may + // throw before an attachment is fully installed. + if (this.#attachment?.handle === handle) this.#teardownAttachment(); + else handle.release(); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface attach failed: ${errorMessage(error)}`, + ); + } + }, + (error) => { + this.#failAttach( + request, + params.surfaceId, + generation, + `surface attach failed: ${errorMessage(error)}`, + ); + }, + ); + } + + #beginAttach( + request: RemoteRequest, + params: AttachParams, + handle: SurfaceHandle, + generation: number, + ): void { // v1: one attachment per session — replace any prior stream. this.#teardownAttachment(); - const ptyId = entry.ptyId; - const term = entry.terminal; - const cols = clampTerminalDimension(params.cols, term.cols); - const rows = clampTerminalDimension(params.rows, term.rows); - const platform = getPlatform(); + const ptyId = handle.ptyId; + const cols = clampTerminalDimension(params.cols, handle.cols); + const rows = clampTerminalDimension(params.rows, handle.rows); + const sameSize = handle.cols === cols && handle.rows === rows; const subId = request.requestId; const pendingEvents: Array<{ event: string; data: unknown }> = []; let streaming = false; + // A production provider replays an exit that happened before this + // subscription was installed (the surface resolve is a process/window round + // trip). Local replay is synchronous, so keep that callback safe before + // `attachment` exists and fail rather than installing a dead PTY. Peer + // replay is ordered before `stream.ready` and follows the installed path. + let attachment: Attachment | null = null; + let closedWhileSubscribing = false; const emitOrBuffer = (event: string, data: unknown): void => { if (streaming) { this.#event(subId, event, data); @@ -241,71 +346,143 @@ export class RemoteApiSession { 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); - emitOrBuffer(REMOTE_EVENTS.terminalData, { bytes: toBase64Url(bytes) }); - }; - 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); - const attachment: Attachment = { + const stream = this.#provider.streamPty(ptyId, { + onData: (data) => { + // 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 = data; + const bytes = typeof raw === 'string' ? utf8Encode(raw) : (raw as Uint8Array); + emitOrBuffer(REMOTE_EVENTS.terminalData, { bytes: toBase64Url(bytes) }); + }, + onExit: (exitCode) => { + // 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 unsubscribes this stream mid-callback, which is safe — the + // subscription is this attachment's alone, so nothing is left to fire — + // and nulls #attachment so #requireAttached fails and the bounce timer + // is cleared. + emitOrBuffer(REMOTE_EVENTS.terminalClosed, { exitCode }); + if (attachment && this.#attachment === attachment) { + this.#teardownAttachment(); + } else { + closedWhileSubscribing = true; + } + }, + }); + if (closedWhileSubscribing) { + stream.stop(); + handle.release(); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface closed while attaching: ${params.surfaceId}`, + ); + return; + } + attachment = { surfaceId: params.surfaceId, - ptyId, - entry, + handle, subId, - onData, - onExit, + stopStream: stream.stop, bounceTimer: null, }; this.#attachment = attachment; + const installedAttachment = attachment; // 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 { + // stream is subscribed first because some PTYs repaint synchronously. For a + // sibling window, `ready` waits for the owner's subscription acknowledgement + // so an exit replay sent before that acknowledgement wins the race here. + // A sibling's owner already applied the size inside the resolve round trip, + // so its handle resolves at the requested size and takes the bounce below. + const finish = (size: { cols: number; rows: number }): void => { + if (this.#disposed) return; + if (this.#attachGeneration !== generation || this.#attachment !== installedAttachment) { + if (this.#attachment === installedAttachment) this.#teardownAttachment(); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface closed while attaching: ${params.surfaceId}`, + ); + return; + } + const result: TerminalAttachResult = { cols: size.cols, rows: size.rows }; + this.#ok(request, result); + streaming = true; + for (const event of pendingEvents) { + this.#event(subId, event.event, event.data); + } + }; + + const beginResize = (): void => { + if (this.#disposed) return; + if (this.#attachGeneration !== generation || this.#attachment !== installedAttachment) { + if (this.#attachment === installedAttachment) this.#teardownAttachment(); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface closed while attaching: ${params.surfaceId}`, + ); + return; + } + + if (!sameSize) { + // The result promises the size the PTY now has, so do not acknowledge + // the attach until the owner has actually applied it. Rejection is a + // normal protocol error, not an unhandled Host-process rejection. + void handle.resize(cols, rows).then(finish, (error) => { + if (this.#attachment === installedAttachment) this.#teardownAttachment(); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface attach failed: ${errorMessage(error)}`, + ); + }); + return; + } + // Same size: force one repaint with a quick rows bounce on the PTY only, // 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); + this.#provider.resizePty(ptyId, cols, bounced); // 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); + installedAttachment.bounceTimer = setTimeout(() => { + installedAttachment.bounceTimer = null; + if (this.#attachment !== installedAttachment) return; + this.#provider.resizePty(ptyId, cols, rows); }, FORCE_REPAINT_BOUNCE_MS); - } + finish({ cols: handle.cols, rows: handle.rows }); + }; - 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); - } + void stream.ready.then(beginResize, (error) => { + if (this.#disposed) return; + const closed = this.#attachment !== installedAttachment; + if (this.#attachment === installedAttachment) this.#teardownAttachment(); + this.#failAttach( + request, + params.surfaceId, + generation, + closed + ? `surface closed while attaching: ${params.surfaceId}` + : `surface attach failed: ${errorMessage(error)}`, + ); + }); } #detach(request: RemoteRequest): void { @@ -323,8 +500,8 @@ export class RemoteApiSession { const resolved = this.#attachedParams(request); if (!resolved) return; const { params, attachment } = resolved; - // Feed the existing PTY input path; the local echo returns via onPtyData. - getPlatform().writePty(attachment.ptyId, utf8Decode(fromBase64Url(params.bytes))); + // Feed the existing PTY input path; the local echo returns via the stream. + this.#provider.writePty(attachment.handle.ptyId, utf8Decode(fromBase64Url(params.bytes))); this.#ok(request, {}); } @@ -332,13 +509,24 @@ export class RemoteApiSession { const resolved = this.#attachedParams(request); if (!resolved) 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); - if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); - const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; - this.#ok(request, result); + const handle = attachment.handle; + const cols = clampTerminalDimension(params.cols, handle.cols); + const rows = clampTerminalDimension(params.rows, handle.rows); + + void handle.resize(cols, rows).then( + (size) => { + if (this.#disposed) return; + if (this.#attachment !== attachment) { + this.#fail(request, `surface is no longer attached: ${params.surfaceId}`); + return; + } + this.#ok(request, { cols: size.cols, rows: size.rows } satisfies TerminalAttachResult); + }, + (error) => { + if (this.#disposed) return; + this.#fail(request, `terminal resize failed: ${errorMessage(error)}`); + }, + ); } #teardownAttachment(): void { @@ -347,9 +535,14 @@ export class RemoteApiSession { clearTimeout(this.#attachment.bounceTimer); this.#attachment.bounceTimer = null; } - const platform = getPlatform(); - platform.offPtyData(this.#attachment.onData); - platform.offPtyExit(this.#attachment.onExit); + this.#attachment.stopStream(); + // Unwinds whatever holding the surface cost — a forwarded stream for an + // owner elsewhere, nothing at all for one the provider drives directly. + this.#attachment.handle.release(); this.#attachment = null; } } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'internal error'; +} diff --git a/lib/src/remote/host/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts index c69037db..ca395e44 100644 --- a/lib/src/remote/host/remote-host.test.ts +++ b/lib/src/remote/host/remote-host.test.ts @@ -1,4 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Holds one `authorizeConnection` call open per queued gate, in call order, so a + * test can make an *older* evaluation finish last. Everything else about the + * module is the real thing — the decision itself is never faked. + */ +const authProbe = vi.hoisted(() => ({ gates: [] as Array | undefined>, calls: 0 })); +vi.mock('server-lib-common', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + authorizeConnection: async (context: never, request: never) => { + const gate = authProbe.gates[authProbe.calls++]; + const decision = await real.authorizeConnection(context, request); + await gate; + return decision; + }, + }; +}); + import { DEFAULT_PAIRING_TTL_MS, WS_CLOSE_HOST_REPLACED, @@ -13,54 +33,10 @@ import { type HostAclRecord, type PairingRequest, } from 'server-lib-common'; -import { RemoteHost, type WebSocketLike } from './remote-host'; +import { RemoteHost } 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.closeWith(1000); - } - - /** Emit a close event with a specific code, as the relay or the network would. */ - closeWith(code: number): void { - this.readyState = 3; - this.#emit('close', { code }); - } - - #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); - } -} +import { FakeSocket } from '../test-fake-socket'; const ENROLLMENT: HostEnrollment = { serverUrl: 'https://host.example', @@ -141,6 +117,20 @@ async function runConnect( return flushUntil(() => socket.frames('decision')[0]); } +/** A promise a test releases by hand. */ +function gate(): { promise: Promise; release: () => void } { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +/** Let every already-queued microtask run. */ +async function settle(): Promise { + for (let i = 0; i < 8; i += 1) await Promise.resolve(); +} + describe('RemoteHost frame handling', () => { let socket: FakeSocket; let savedRecords: HostAclRecord[] = []; @@ -171,6 +161,8 @@ describe('RemoteHost frame handling', () => { beforeEach(() => { socket = new FakeSocket(); + authProbe.gates.length = 0; + authProbe.calls = 0; }); it('pair → local approval → pair-result with the ACL record, and persists', () => { @@ -220,6 +212,38 @@ describe('RemoteHost frame handling', () => { expect(savedRecords).toEqual([]); }); + it('ignores approval callbacks superseded under the same client id', () => { + makeHost(); + const first = { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest; + socket.receive({ t: 'pair', clientId: 'c1', request: first }); + const stale = approvals[0]!; + + const replacement = { + ...first, + devicePublicKey: 'device-2', + requestedLabel: 'Android Chrome', + }; + socket.receive({ t: 'pair', clientId: 'c1', request: replacement }); + expect(approvals[1]!.pairingId).not.toBe(stale.pairingId); + + stale.approve(); + stale.deny(); + expect(socket.frames('pair-result')).toEqual([]); + expect(savedRecords).toEqual([]); + + approvals[1]!.approve(); + expect(socket.frames('pair-result')[0]).toMatchObject({ + approved: true, + record: { devicePublicKey: 'device-2' }, + }); + }); + it('expired approval → pair-result approved:false, ACL untouched', () => { let now = 1_000; makeHost(() => [], () => now); @@ -283,6 +307,21 @@ describe('RemoteHost frame handling', () => { ); }); + it('contains and denies a malformed connect2 from the relay', async () => { + makeHost(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + socket.receive({ t: 'connect2', clientId: 'c1', request: {} }); + + const decision = await flushUntil(() => socket.frames('decision')[0]); + expect(decision).toMatchObject({ clientId: 'c1', allowed: false }); + expect(decision.failures).toEqual( + expect.arrayContaining(['passkey-assertion-invalid', 'device-signature-invalid']), + ); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + it('pair then connect2 allows and omits failures', async () => { makeHost(); const authenticator = await createAuthenticator(ENROLLMENT.rpId); @@ -334,6 +373,7 @@ describe('RemoteHost frame handling', () => { reconnect: false, createWebSocket: () => (socket = new FakeSocket()), loadAcl: () => [], + saveAcl: () => {}, requestApproval: (pending) => pending.approve(), dismissApproval: () => {}, createSession: () => ({ @@ -383,12 +423,106 @@ describe('RemoteHost frame handling', () => { socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r', method: 'hello' } }); expect(handled).toHaveLength(1); + // A new, malformed authorization attempt fails closed and revokes this + // connection's message gate. The relay is not an authority merely because + // this clientId was allowed once. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + socket.sent.length = 0; + socket.receive({ t: 'connect2', clientId: 'c1', request: {} }); + await flushUntil(() => socket.frames('decision')[0]); + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r2', method: 'hello' } }); + expect(handled).toHaveLength(1); + expect(disposed).toBe(1); + warn.mockRestore(); + // client-gone disposes the session and re-gates. socket.receive({ t: 'client-gone', clientId: 'c1' }); + // The failed re-authorization already disposed it; client-gone is + // idempotent rather than disposing the old session twice. expect(disposed).toBe(1); - socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r2', method: 'hello' } }); + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r3', method: 'hello' } }); expect(handled).toHaveLength(1); }); + + it('lets only the newest connect2 answer, even when an older one lands last', async () => { + // Verification is async and the relay can start a second attempt while the + // first is still running. An older `allowed` landing last would re-open the + // gate the newer attempt closed — the relay would then have talked this Host + // into establishing a client it had just denied. + const handled: unknown[] = []; + savedRecords = []; + approvals = []; + const host = new RemoteHost({ + enrollment: ENROLLMENT, + reconnect: false, + createWebSocket: () => (socket = new FakeSocket()), + loadAcl: () => [], + saveAcl: () => {}, + requestApproval: (pending) => pending.approve(), + dismissApproval: () => {}, + createSession: () => ({ handle: (data) => handled.push(data), dispose: () => {} }), + }); + host.start(); + socket.open(); + + 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, + }); + + // The older attempt would be allowed — and is held open until after the + // newer one has been answered. + const held = gate(); + authProbe.gates[authProbe.calls] = held.promise; + socket.sent.length = 0; + socket.receive({ t: 'connect', clientId: 'c1' }); + const challenge = socket.frames('challenge')[0]!.challenge as string; + socket.receive({ + t: 'connect2', + clientId: 'c1', + request: { + 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), + }, + } satisfies ConnectionRequest, + }); + await settle(); + expect(socket.frames('decision')).toHaveLength(0); + + // The newer attempt is malformed, so it denies and closes the gate. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + socket.receive({ t: 'connect2', clientId: 'c1', request: {} }); + expect(await flushUntil(() => socket.frames('decision')[0])).toMatchObject({ allowed: false }); + + held.release(); + await settle(); + + // The superseded evaluation answers nothing at all — a second `decision` + // would settle a request the client is no longer waiting on. + expect(socket.frames('decision')).toHaveLength(1); + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r', method: 'hello' } }); + expect(handled).toHaveLength(0); + warn.mockRestore(); + }); }); describe('RemoteHost close-code policy', () => { diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts index be9b05fa..ea5b15b6 100644 --- a/lib/src/remote/host/remote-host.ts +++ b/lib/src/remote/host/remote-host.ts @@ -32,6 +32,7 @@ import { WS_ROUTES, WS_TOKEN_PARAM, authorizeConnection, + type ConnectionDecision, type ConnectionPolicy, type ConnectionRequest, type HostAclRecord, @@ -41,12 +42,8 @@ import { } from 'server-lib-common'; import type { HostEnrollment } from './enrollment'; import type { RemoteWebSocket } from '../ws'; -import { loadHostAcl, saveAclRecords } from './acl'; -import { - enqueuePairingApproval, - resolvePairingApproval, - type PendingPairing, -} from './pairing-approval'; +import { loadHostAcl } from './acl'; +import type { PendingPairing } from './pairing-approval'; /** The remote-api handler this controller drives per authorized client. */ export interface RemoteApiSessionLike { @@ -61,6 +58,12 @@ export type WebSocketLike = RemoteWebSocket; interface ClientState { /** True once the Host allowed this client's connection — the `msg` gate. */ established: boolean; + /** + * Bumped by every authorization attempt (see {@link RemoteHost.#resetAuthorization}). + * `authorizeConnection` is async, so two attempts for one client can be in + * flight at once; only the newest may answer or re-open the gate. + */ + authGeneration: number; /** The in-flight pairing awaiting local approval, if any. */ pending?: PendingPairing; /** The remote-api handler, created on the first authorized `msg`. */ @@ -88,12 +91,19 @@ export interface RemoteHostOptions { 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; + /** + * Where the ACL comes from and goes. Required, with no webview-store default: + * this controller runs in the Tauri sidecar and the VS Code extension host as + * well as in a webview, so a default would drag `localStorage` into both Node + * bundles — and a forgotten `saveAcl` has to be a type error rather than an + * approval that is lost at the next restart. + */ + loadAcl: (hostId: string) => HostAclRecord[]; + saveAcl: (hostId: string, records: readonly HostAclRecord[]) => void; + /** Surface a pairing request for local approval. */ + requestApproval: (pending: PendingPairing) => void; + /** Dismiss a surfaced request once resolved. */ + dismissApproval: (clientId: string) => void; now?: () => number; /** Auto-reconnect with backoff (default true; tests pass false). */ reconnect?: boolean; @@ -144,9 +154,9 @@ export class RemoteHost { 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.#saveAcl = options.saveAcl; + this.#requestApproval = options.requestApproval; + this.#dismissApproval = options.dismissApproval; this.#reconnect = options.reconnect ?? true; } @@ -267,7 +277,7 @@ export class RemoteHost { #clientState(clientId: string): ClientState { let state = this.#clients.get(clientId); if (!state) { - state = { established: false }; + state = { established: false, authGeneration: 0 }; this.#clients.set(clientId, state); } return state; @@ -319,8 +329,9 @@ export class RemoteHost { const ticket = this.#ceremony.begin(request); const pending: PendingPairing = { clientId, + pairingId: ticket.pairingId, request, - requestedAt: this.#now(), + requestedAt: ticket.requestedAt, approve: (label) => this.#approvePairing(clientId, ticket.pairingId, label), deny: (error) => this.#denyPairing(clientId, ticket.pairingId, error), }; @@ -331,7 +342,10 @@ export class RemoteHost { /** The local approval — the ONLY path that writes the ACL. */ #approvePairing(clientId: string, pairingId: string, label?: string): void { const state = this.#clients.get(clientId); - if (!state?.pending) return; // already resolved + // The service checks this at its bridge boundary too; keep the controller's + // ACL write bound to its own current ticket even if another caller retains + // an older callback. + if (!state?.pending || state.pending.pairingId !== pairingId) return; state.pending = undefined; let record: HostAclRecord; try { @@ -353,7 +367,7 @@ export class RemoteHost { #denyPairing(clientId: string, pairingId: string, error = 'pairing denied by host'): void { const state = this.#clients.get(clientId); - if (!state?.pending) return; + if (!state?.pending || state.pending.pairingId !== pairingId) return; state.pending = undefined; try { this.#ceremony.deny(pairingId); @@ -365,21 +379,52 @@ export class RemoteHost { } #onConnect(clientId: string): void { + this.#resetAuthorization(clientId); 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.#clientState(clientId).established = true; + // A new authorization attempt closes the old gate first. The relay is not + // an authority and may be compromised, so it cannot keep a once-authorized + // client established by following it with a malformed attempt. + const state = this.#resetAuthorization(clientId); + const generation = state.authGeneration; + // Verification is async, so the relay can start a second attempt while this + // one is still running. Whichever finishes last would otherwise win, and an + // older `allowed` landing after a newer attempt would re-open the gate that + // attempt just closed. A superseded evaluation answers nothing at all — its + // replacement is what the client is waiting on. + const superseded = (): boolean => + this.#clients.get(clientId) !== state || state.authGeneration !== generation; + let decision: ConnectionDecision; + try { + decision = await authorizeConnection( + { + hostId: this.#enrollment.hostId, + acl: this.#acl, + challenges: this.#challenges, + policy: this.#policy, + }, + request, + ); + } catch (error) { + // `connect2` came from the relay, not a trusted typed caller. Structural + // failures must be an ordinary denial: this Host now runs in Node, where + // letting the async handler reject can terminate the sidecar or extension + // host rather than merely logging in a webview. + console.warn('remote-host: malformed connection request', error); + if (superseded()) return; + this.#send({ + t: 'decision', + clientId, + allowed: false, + failures: ['passkey-assertion-invalid', 'device-signature-invalid'], + }); + return; + } + if (superseded()) return; + if (decision.allowed) state.established = true; // `failures` is optional on the wire; omit it on an allowed decision. this.#send({ t: 'decision', @@ -404,6 +449,16 @@ export class RemoteHost { session.handle(data); } + /** A fresh authorization attempt replaces any prior control session. */ + #resetAuthorization(clientId: string): ClientState { + const state = this.#clientState(clientId); + state.established = false; + state.authGeneration += 1; + state.session?.dispose(); + state.session = undefined; + return state; + } + #onClientGone(clientId: string): void { this.#clients.get(clientId)?.session?.dispose(); this.#clients.delete(clientId); diff --git a/lib/src/remote/host/store.ts b/lib/src/remote/host/store.ts new file mode 100644 index 00000000..e8080db7 --- /dev/null +++ b/lib/src/remote/host/store.ts @@ -0,0 +1,14 @@ +/** + * The persisted-key names for the Host's own state, kept apart from the code + * that reads them because three places have to agree on them: the webview's + * legacy `localStorage` copy (`enrollment.ts`, `acl.ts` → `ACL_KEY_PREFIX`), + * the one-shot adoption that hands that copy to the service, and the VS Code + * extension host's store (`vscode-ext/src/remote-host-store.ts`), which writes + * the same names into `SecretStorage`. + * + * `ENROLLMENT_KEY` lives here rather than in `enrollment.ts` so the extension + * host can import it without pulling `server-lib-common` into its bundle. A key + * that drifted between any two of them would strand an enrollment that is still + * on disk. + */ +export const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; diff --git a/lib/src/remote/test-fake-socket.ts b/lib/src/remote/test-fake-socket.ts new file mode 100644 index 00000000..f4b22dcf --- /dev/null +++ b/lib/src/remote/test-fake-socket.ts @@ -0,0 +1,74 @@ +/** + * The fake `WebSocket` both ends of the remote stack are tested against. + * + * Test-only, and shared on purpose: the Host controller, the Host service, and + * the Pocket client all speak {@link RemoteWebSocket} and all need the same four + * things — record what was sent, deliver a server frame, open, and close with a + * code. Three private copies drifted into three different ideas of what a close + * does, which is exactly the behavior the close-code policy turns on. + */ + +import type { RemoteWebSocket } from './ws'; + +export class FakeSocket implements RemoteWebSocket { + /** `CONNECTING` until {@link open}, as a real socket is. */ + readyState = 0; + /** + * Whether `close()` fires its own `close` event. A browser always does; a test + * that replaces a socket without letting the old one settle sets this false. + */ + closeEmits = true; + 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) as Record); + } + + close(): void { + this.readyState = 3; + if (this.closeEmits) this.closeWith(1000); + } + + open(): void { + this.readyState = 1; + this.#emit('open', {}); + } + + /** Emit a close event with a specific code, as the relay or the network would. */ + closeWith(code: number): void { + this.readyState = 3; + this.#emit('close', { code }); + } + + /** The server or the network dropped the connection — no `close()` from us. */ + drop(): void { + this.closeWith(1006); + } + + /** A rejected upgrade: the browser fires `error` with no status, never `open`. */ + emitError(): void { + this.readyState = 3; + this.#emit('error', {}); + } + + /** Deliver one frame from the far end. */ + receive(frame: unknown): void { + this.#emit('message', { data: JSON.stringify(frame) }); + } + + /** Every frame this socket was asked to send of one wire type. */ + frames(t: string): Array> { + return this.sent.filter((frame) => frame.t === t); + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 116be0aa..fc972100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: node-pty: specifier: 1.2.0-beta.15 version: 1.2.0-beta.15 + ws: + specifier: ^8.18.0 + version: 8.21.3 devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 @@ -297,6 +300,9 @@ importers: '@types/vscode': specifier: 1.85.0 version: 1.85.0 + '@types/ws': + specifier: ^8.5.0 + version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.0.5(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) @@ -318,6 +324,9 @@ importers: vite: specifier: ^8.0.14 version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.6 + version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) website: dependencies: @@ -2254,6 +2263,9 @@ packages: '@types/web-push@3.6.4': resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typespec/ts-http-runtime@0.3.6': resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==} engines: {node: '>=20.0.0'} @@ -4430,18 +4442,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -4984,7 +4984,7 @@ snapshots: dependencies: '@hono/node-server': 2.1.1(hono@4.13.2) hono: 4.13.2 - ws: 8.21.0 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6083,6 +6083,10 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.3 + '@typespec/ts-http-runtime@0.3.6(supports-color@7.2.0)': dependencies: http-proxy-agent: 7.0.2(supports-color@7.2.0) @@ -8172,8 +8176,6 @@ snapshots: wrappy@1.0.2: optional: true - ws@8.21.0: {} - ws@8.21.3: {} wsl-utils@0.1.0: diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs new file mode 100644 index 00000000..60aa2b80 --- /dev/null +++ b/scripts/csp-defaults.mjs @@ -0,0 +1,87 @@ +// The one definition of where a Host may reach a relay server, shared by both +// Hosts' build scripts. +// +// Both Hosts now run outside any webview — standalone's in the sidecar, VS +// Code's in the extension host — so neither is fenced by a CSP and both bake +// this list into their bundle instead (`standalone/scripts/build-sidecar-proxy.mjs`, +// `vscode-ext/scripts/esbuild.mjs`), where the service refuses any origin +// outside it. The *fact* is one fact — duplicating it meant a change to the +// SaaS origin could ship one Host pointed at the old one. See +// docs/specs/server.md → "Host webview CSP". + +import { readFileSync } from 'node:fs'; + +/** The identifier esbuild substitutes; read by `lib/src/host/remote/connect-src.ts`. */ +export const CONNECT_SRC_PLACEHOLDER = '__DORMOUSE_REMOTE_CONNECT_SRC__'; + +/** The remote-server `connect-src` sources baked into the published builds. */ +export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +/** + * The grammar one source must have, duplicated from + * `lib/src/host/remote/connect-src.ts` — a build script cannot import + * TypeScript, and `lib/src/host/remote/connect-src.test.ts` asserts the two + * patterns are the same string. + */ +export const CONNECT_SRC_SOURCE_PATTERN = /^((?:https?|wss?):)\/\/([^/:]+)(?::(\*|\d+))?$/i; + +function isSupportedSource(source) { + const match = CONNECT_SRC_SOURCE_PATTERN.exec(source); + if (!match) return false; + const rawPort = match[3]; + if (rawPort === undefined || rawPort === '*') return true; + const port = Number(rawPort); + return Number.isInteger(port) && port >= 1 && port <= 65_535; +} + +/** + * The sources this build should use: the selfhoster's `DORMOUSE_REMOTE_CONNECT_SRC` + * if set and non-empty, otherwise the shipped default. Logs to stderr when it + * overrides, so a custom build says so in its output. + * + * An override the runtime matcher cannot parse fails the build. Silently it + * matches nothing — `originAllowedByConnectSrc` fails closed on a source it + * cannot read — so a trailing slash or a missing scheme produces a binary that + * builds green and then refuses to enroll against the very server it was built + * for, with an error naming the list it was already given. + */ +export function resolveRemoteConnectSrc(env = process.env, label = 'build') { + const override = env.DORMOUSE_REMOTE_CONNECT_SRC?.trim(); + if (!override) return DEFAULT_REMOTE_CONNECT_SRC; + for (const source of override.split(/\s+/)) { + if (!source || isSupportedSource(source)) continue; + throw new Error( + `[${label}] DORMOUSE_REMOTE_CONNECT_SRC: "${source}" is not a source the remote Host can ` + + 'match. Each entry must use http, https, ws, or wss with a host and an optional ' + + ':port (1–65535) or :* — no trailing slash or path ' + + `(e.g. "${DEFAULT_REMOTE_CONNECT_SRC}").`, + ); + } + console.error(`[${label}] connect-src remote sources overridden: ${override}`); + return override; +} + +/** + * Fail the build if the `define` did not reach `bundlePath`. + * + * The source reads the placeholder as a `declare const`, so a lost define + * compiles fine and only shows up at runtime — as a Host that silently uses the + * shipped default allowlist instead of the selfhoster's origins. Both bundles + * bake the same variable, so both fail on the same class of drift: someone + * re-inlines the esbuild call, or adds an entry point that pulls in the Host + * without the define. + */ +export function assertConnectSrcBaked(bundlePath, remoteSrc) { + const bundle = readFileSync(bundlePath, 'utf8'); + if (bundle.includes(CONNECT_SRC_PLACEHOLDER)) { + throw new Error( + `connect-src: ${CONNECT_SRC_PLACEHOLDER} survived into ${bundlePath} — the esbuild define ` + + 'did not apply, and the remote Host would use the built-in default sources.', + ); + } + if (!bundle.includes(remoteSrc)) { + throw new Error( + `connect-src: ${bundlePath} does not contain the resolved sources (${remoteSrc}).`, + ); + } +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..bb3b9b7e --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,104 @@ +/** + * Environment → {@link ServerConfig}. Pure and separate from `index.ts` so the + * mapping is testable without binding a port or mutating `process.env` + * (docs/specs/server.md, "Configuration"). + */ + +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defaultVapidSubject, type VapidKeys } from './push.js'; + +/** Everything the entrypoint needs, resolved from the environment. */ +export interface ServerConfig { + port: number; + /** + * Interface to bind. `undefined` listens on every interface, which is what a + * container wants; a host that fronts the server with a TLS proxy on the same + * machine must set `DORMOUSE_BIND_HOST=127.0.0.1` so the plaintext port is not + * reachable from the LAN or a tailnet. + */ + bindHost: string | undefined; + setupPassword: string; + origin: string; + stateDir: string; + pocketDir: string; + /** + * The configured VAPID keypair, or `null` to mint and persist one on disk — + * which is the entrypoint's job, not this pure mapping's. + */ + vapidKeys: VapidKeys | null; + /** + * The operator contact the push JWT is signed with. `null` means push is off: + * `web-push` cannot construct a send without a subject, and this server's own + * origin is unusable as one on a loopback dev server. + */ + vapidSubject: string | null; +} + +/** Thrown for a missing or unusable environment; the entrypoint exits on it. */ +export class ConfigError extends Error {} + +type Env = Record; + +export function readConfig(env: Env = process.env): ServerConfig { + // Blank is unset, the way `DORMOUSE_BIND_HOST` reads it below. `Number('')` is + // 0, which passes the range check and asks the OS for an ephemeral port — so + // a `PORT=` left empty in a `.env` would silently move the server off 3000 + // and out from under the proxy in front of it. An explicit `PORT=0` is + // refused for the same reason rather than honoured: nothing can be pointed at + // a port that changes every restart. + const rawPort = env.PORT?.trim() || undefined; + const port = Number(rawPort ?? 3000); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ConfigError(`PORT must be an integer between 1 and 65535, got ${env.PORT}`); + } + + const setupPassword = env.DORMOUSE_SETUP_PASSWORD; + if (!setupPassword) { + throw new ConfigError( + 'DORMOUSE_SETUP_PASSWORD is required — it gates account creation and host enrollment.', + ); + } + + const bindHost = env.DORMOUSE_BIND_HOST?.trim() || undefined; + const origin = env.DORMOUSE_ORIGIN ?? `http://localhost:${port}`; + const stateDir = env.DORMOUSE_STATE_DIR ?? './data'; + + // Default to `lib/dist-pocket` resolved from this compiled file's location + // (server/dist/config.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 = env.DORMOUSE_POCKET_DIR ?? join(repoRoot, 'lib', 'dist-pocket'); + + // VAPID keys sign the push JWT and identify this server to every push service. + // Supply both through env to control them; supply neither and the entrypoint + // mints a pair once and persists it, so a selfhost POC needs no key ceremony. + // Supplying exactly one is a misconfiguration, not a default worth guessing + // at: the pair must match or every subscription silently stops working. + const publicKey = env.DORMOUSE_VAPID_PUBLIC_KEY; + const privateKey = env.DORMOUSE_VAPID_PRIVATE_KEY; + if (!!publicKey !== !!privateKey) { + throw new ConfigError( + 'DORMOUSE_VAPID_PUBLIC_KEY and DORMOUSE_VAPID_PRIVATE_KEY must be set together, or neither.', + ); + } + const vapidKeys = publicKey && privateKey ? { publicKey, privateKey } : null; + // An unset subject falls back to this server's own origin, which + // `defaultVapidSubject` refuses for a loopback dev server — there push is + // switched off rather than left half-working, because a phone cannot route to + // localhost anyway and a subject a push service rejects made every iPhone + // delivery fail silently. + const vapidSubject = env.DORMOUSE_VAPID_SUBJECT ?? defaultVapidSubject(origin); + + return { + port, + bindHost, + setupPassword, + origin, + stateDir, + pocketDir, + vapidKeys, + vapidSubject, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts index 35e4ea0c..82da7a6d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,68 +1,40 @@ /** - * 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. + * Process entrypoint: read the environment via {@link readConfig}, resolve the + * VAPID keypair (which touches disk, so it stays here rather than in the pure + * config mapping), and bind a port. Kept separate from `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'; +import { ConfigError, readConfig } from './config.js'; import { assertVapidKeyPair, assertVapidSubject, createWebPushSender, - defaultVapidSubject, generateVapidKeys, } from './push.js'; import { VapidStore } from './state.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); +function loadConfig() { + try { + return readConfig(); + } catch (err) { + if (err instanceof ConfigError) { + console.error(err.message); + process.exit(1); + } + throw err; + } } -const origin = process.env.DORMOUSE_ORIGIN ?? `http://localhost:${port}`; -const stateDir = process.env.DORMOUSE_STATE_DIR ?? './data'; - -// 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 { port, bindHost, vapidKeys, vapidSubject, ...appConfig } = loadConfig(); +const { origin, stateDir } = appConfig; -// VAPID keys sign the push JWT and identify this server to every push service. -// Supply both through env to control them; supply neither and the server mints -// a pair once and persists it (0o600), so a selfhost POC needs no key ceremony. -// Supplying exactly one is a misconfiguration, not a default worth guessing at: -// the pair must match or every subscription silently stops working. -const envVapidPublic = process.env.DORMOUSE_VAPID_PUBLIC_KEY; -const envVapidPrivate = process.env.DORMOUSE_VAPID_PRIVATE_KEY; -if (!!envVapidPublic !== !!envVapidPrivate) { - console.error( - 'DORMOUSE_VAPID_PUBLIC_KEY and DORMOUSE_VAPID_PRIVATE_KEY must be set together, or neither.', - ); - process.exit(1); -} -const vapid = - envVapidPublic && envVapidPrivate - ? { publicKey: envVapidPublic, privateKey: envVapidPrivate } - : await new VapidStore(stateDir).loadOrCreate(generateVapidKeys); -// The JWT is signed with an operator contact, so no subject means no push at -// all — `web-push` cannot construct a send without one. An unset -// DORMOUSE_VAPID_SUBJECT therefore falls back to this server's own origin, -// which is unusable only for a loopback dev server. There push is switched off -// rather than left half-working: a phone cannot route to localhost anyway, and -// booting with a subject a push service rejects is what made every iPhone -// delivery fail silently before. -const vapidSubject = process.env.DORMOUSE_VAPID_SUBJECT ?? defaultVapidSubject(origin); +// The one part of the VAPID story that is not a pure env read: with no keys +// configured, mint a pair once and persist it (0o600). +const vapid = vapidKeys ?? (await new VapidStore(stateDir).loadOrCreate(generateVapidKeys)); try { assertVapidKeyPair(vapid); if (vapidSubject !== null) assertVapidSubject(vapidSubject); @@ -78,10 +50,7 @@ if (vapidSubject === null) { } const { app, injectWebSocket } = createApp({ - setupPassword, - origin, - stateDir, - pocketDir, + ...appConfig, // Both together or neither: advertising a key the server has no subject to // sign with would let a phone register against a push it can never receive. ...(vapidSubject === null @@ -92,9 +61,16 @@ const { app, injectWebSocket } = createApp({ }), }); -const server = serve({ fetch: app.fetch, port }, (info) => { - console.log(`server listening on http://localhost:${info.port} (origin ${origin})`); -}); +// `hostname` is omitted rather than passed as undefined so @hono/node-server +// keeps its listen-on-every-interface default (what a container wants). +const server = serve( + { fetch: app.fetch, port, ...(bindHost ? { hostname: bindHost } : {}) }, + (info) => { + console.log( + `server listening on http://${bindHost ?? '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/test/bind-host.test.mjs b/server/test/bind-host.test.mjs new file mode 100644 index 00000000..b8a466fd --- /dev/null +++ b/server/test/bind-host.test.mjs @@ -0,0 +1,123 @@ +/** + * `DORMOUSE_BIND_HOST` must actually bound the listening socket, not merely be + * recorded in config: the selfhost install fronts plain HTTP with a local TLS + * proxy, so the plaintext port must not be reachable from the LAN or a tailnet + * (docs/specs/server.md, "Configuration"). Spawns the real entrypoint. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:net'; +import { mkdtemp } from 'node:fs/promises'; +import { networkInterfaces, tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const ENTRYPOINT = join(here, '..', 'dist', 'index.js'); + +/** A non-loopback IPv4 of this machine, or undefined on an isolated runner. */ +function externalIpv4() { + for (const addrs of Object.values(networkInterfaces())) { + for (const addr of addrs ?? []) { + if (addr.family === 'IPv4' && !addr.internal) return addr.address; + } + } + return undefined; +} + +/** A port that was free a moment ago — good enough for a spawned child. */ +function freePort() { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.on('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); + }); +} + +async function startServer(extraEnv) { + const port = await freePort(); + const stateDir = await mkdtemp(join(tmpdir(), 'dormouse-bind-')); + const child = spawn(process.execPath, [ENTRYPOINT], { + env: { + ...process.env, + DORMOUSE_SETUP_PASSWORD: 'correct horse battery staple', + DORMOUSE_STATE_DIR: stateDir, + DORMOUSE_POCKET_DIR: join(stateDir, 'no-pocket-build'), + PORT: String(port), + ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('server did not report listening')), 15_000); + child.stdout.on('data', (chunk) => { + if (String(chunk).includes('server listening')) { + clearTimeout(timer); + resolve(); + } + }); + child.on('exit', (code) => { + clearTimeout(timer); + reject(new Error(`server exited early with code ${code}`)); + }); + }); + } catch (error) { + // Nobody else has a handle on this child yet — the caller registers + // `t.after(stop)` only once this resolves — so a rejection here would leave + // a server holding `port` for the rest of the run. + child.kill(); + throw error; + } + + return { port, stop: () => child.kill() }; +} + +/** Resolves true if /api/hello answers at `host` within a short budget. */ +async function reachable(host, port) { + try { + const res = await fetch(`http://${host}:${port}/api/hello`, { + signal: AbortSignal.timeout(3_000), + }); + return res.ok; + } catch { + return false; + } +} + +test('DORMOUSE_BIND_HOST=127.0.0.1 serves loopback only', async (t) => { + const external = externalIpv4(); + const { port, stop } = await startServer({ DORMOUSE_BIND_HOST: '127.0.0.1' }); + t.after(stop); + + assert.equal(await reachable('127.0.0.1', port), true, 'loopback must answer'); + + if (!external) { + t.diagnostic('no non-loopback IPv4 on this machine; skipped the exposure half'); + return; + } + assert.equal( + await reachable(external, port), + false, + `plaintext port must not be reachable at ${external}`, + ); +}); + +test('without DORMOUSE_BIND_HOST the server still listens on every interface', async (t) => { + const external = externalIpv4(); + if (!external) { + t.skip('no non-loopback IPv4 on this machine'); + return; + } + const { port, stop } = await startServer({}); + t.after(stop); + + assert.equal(await reachable('127.0.0.1', port), true); + assert.equal(await reachable(external, port), true, 'the container default must be preserved'); +}); diff --git a/server/test/config.test.mjs b/server/test/config.test.mjs new file mode 100644 index 00000000..e437d340 --- /dev/null +++ b/server/test/config.test.mjs @@ -0,0 +1,105 @@ +/** + * The environment → config mapping (docs/specs/server.md, "Configuration"). + * Pure, so no port is bound here; `bind-host.test.mjs` covers the actual listen. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ConfigError, readConfig } from '../dist/config.js'; + +const MINIMAL = { DORMOUSE_SETUP_PASSWORD: 'correct horse battery staple' }; + +test('defaults: port 3000, every interface, localhost origin', () => { + const config = readConfig({ ...MINIMAL }); + assert.equal(config.port, 3000); + assert.equal(config.bindHost, undefined); + assert.equal(config.origin, 'http://localhost:3000'); + assert.equal(config.stateDir, './data'); +}); + +test('DORMOUSE_BIND_HOST pins the listen interface', () => { + const config = readConfig({ ...MINIMAL, DORMOUSE_BIND_HOST: '127.0.0.1' }); + assert.equal(config.bindHost, '127.0.0.1'); +}); + +test('a blank DORMOUSE_BIND_HOST is treated as unset, not as an empty host', () => { + assert.equal(readConfig({ ...MINIMAL, DORMOUSE_BIND_HOST: '' }).bindHost, undefined); + assert.equal(readConfig({ ...MINIMAL, DORMOUSE_BIND_HOST: ' ' }).bindHost, undefined); +}); + +test('the default origin follows PORT', () => { + assert.equal(readConfig({ ...MINIMAL, PORT: '3100' }).origin, 'http://localhost:3100'); +}); + +test('DORMOUSE_ORIGIN wins over the port-derived default', () => { + const config = readConfig({ ...MINIMAL, PORT: '3100', DORMOUSE_ORIGIN: 'https://dor.example.ts.net' }); + assert.equal(config.origin, 'https://dor.example.ts.net'); +}); + +test('a missing setup password is a ConfigError, not a silent start', () => { + assert.throws(() => readConfig({}), ConfigError); +}); + +test('an unusable PORT is a ConfigError', () => { + assert.throws(() => readConfig({ ...MINIMAL, PORT: 'https' }), ConfigError); + assert.throws(() => readConfig({ ...MINIMAL, PORT: '70000' }), ConfigError); +}); + +test('a blank PORT is treated as unset, not as port 0', () => { + // `Number('')` is 0, which asks the OS for an ephemeral port — so a `PORT=` + // left empty in a `.env` would move the server off 3000 and out from under + // whatever proxy is pointed at it. + assert.equal(readConfig({ ...MINIMAL, PORT: '' }).port, 3000); + assert.equal(readConfig({ ...MINIMAL, PORT: ' ' }).port, 3000); +}); + +test('an explicit PORT=0 is refused rather than randomized', () => { + // Nothing can be pointed at a port that changes on every restart. + assert.throws(() => readConfig({ ...MINIMAL, PORT: '0' }), ConfigError); +}); + +test('state and pocket dirs are overridable, with a cwd-independent pocket default', () => { + const config = readConfig({ ...MINIMAL, DORMOUSE_STATE_DIR: '/var/lib/dormouse' }); + assert.equal(config.stateDir, '/var/lib/dormouse'); + assert.match(config.pocketDir, /lib[/\\]dist-pocket$/); + assert.equal(readConfig({ ...MINIMAL, DORMOUSE_POCKET_DIR: '/app/pocket' }).pocketDir, '/app/pocket'); +}); + +test('no VAPID keys in the environment leaves them for the entrypoint to mint', () => { + assert.equal(readConfig({ ...MINIMAL }).vapidKeys, null); +}); + +test('a VAPID keypair is taken from the environment as a pair', () => { + const config = readConfig({ + ...MINIMAL, + DORMOUSE_VAPID_PUBLIC_KEY: 'pub', + DORMOUSE_VAPID_PRIVATE_KEY: 'priv', + }); + assert.deepEqual(config.vapidKeys, { publicKey: 'pub', privateKey: 'priv' }); +}); + +test('half a VAPID keypair is a ConfigError, not a guessed default', () => { + // A mismatched pair stops every subscription working, silently. + assert.throws(() => readConfig({ ...MINIMAL, DORMOUSE_VAPID_PUBLIC_KEY: 'pub' }), ConfigError); + assert.throws(() => readConfig({ ...MINIMAL, DORMOUSE_VAPID_PRIVATE_KEY: 'priv' }), ConfigError); +}); + +test('DORMOUSE_VAPID_SUBJECT wins over the origin-derived default', () => { + const config = readConfig({ + ...MINIMAL, + DORMOUSE_ORIGIN: 'https://dor.example.ts.net', + DORMOUSE_VAPID_SUBJECT: 'mailto:admin@example.com', + }); + assert.equal(config.vapidSubject, 'mailto:admin@example.com'); +}); + +test('the VAPID subject falls back to a routable origin, and to nothing on loopback', () => { + assert.equal( + readConfig({ ...MINIMAL, DORMOUSE_ORIGIN: 'https://dor.example.ts.net' }).vapidSubject, + 'https://dor.example.ts.net', + ); + // A loopback dev server: push is off rather than half-working, since Apple + // rejects such a JWT and every delivery would fail silently. + assert.equal(readConfig({ ...MINIMAL }).vapidSubject, null); +}); diff --git a/standalone/package.json b/standalone/package.json index e4c4ab15..d988cd0b 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -11,7 +11,7 @@ "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", - "stage:sidecar-proxy": "node scripts/build-sidecar-proxy.mjs", + "stage:sidecar-proxy": "pnpm --filter server-lib-common build && node scripts/build-sidecar-proxy.mjs", "tauri": "pnpm run stage && node scripts/tauri.mjs", "test": "vitest run && node --test scripts/*.test.mjs" }, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 95ec9623..2f525996 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -3,21 +3,37 @@ // TypeScript source while the sidecar itself stays plain CJS. // - lib/src/host/iframe-proxy.ts → sidecar/iframe-proxy.cjs // - lib/src/host/agent-browser-host.ts → sidecar/agent-browser-host.cjs -// See docs/specs/dor-browser.md. +// - lib/src/host/remote/sidecar-entry.ts → sidecar/remote-host.cjs +// See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { + assertConnectSrcBaked, + CONNECT_SRC_PLACEHOLDER, + resolveRemoteConnectSrc, +} from '../../scripts/csp-defaults.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const libHost = path.resolve(here, '../../lib/src/host'); const sidecar = path.resolve(here, '../sidecar'); +// Where the remote Host may reach a relay server. The Host runs in the sidecar, +// so this is the enforcement point — there is no webview CSP in front of it. +const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); + const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, { entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' }, + { + entry: 'remote/sidecar-entry.ts', + out: 'remote-host.cjs', + define: { [CONNECT_SRC_PLACEHOLDER]: JSON.stringify(remoteSrc) }, + assertBaked: true, + }, ]; -for (const { entry, out } of bundles) { +for (const { entry, out, define, assertBaked } of bundles) { const outfile = path.resolve(sidecar, out); await build({ entryPoints: [path.resolve(libHost, entry)], @@ -27,6 +43,8 @@ for (const { entry, out } of bundles) { format: 'cjs', target: 'node24', logLevel: 'warning', + ...(define ? { define } : {}), }); + if (assertBaked) assertConnectSrcBaked(outfile, remoteSrc); console.log(`[sidecar] built ${path.relative(process.cwd(), outfile)}`); } diff --git a/standalone/scripts/csp.mjs b/standalone/scripts/csp.mjs deleted file mode 100644 index 8815ffff..00000000 --- a/standalone/scripts/csp.mjs +++ /dev/null @@ -1,31 +0,0 @@ -// 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 deleted file mode 100644 index e1576555..00000000 --- a/standalone/scripts/csp.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -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:;') && !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/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 737ca17c..c0996888 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -28,6 +28,9 @@ const controlSocket = process.platform === 'win32' ? `\\\\.\\pipe\\dormouse-${process.pid}-browser-dor` : path.join(os.tmpdir(), `dormouse-${process.pid}-browser-dor.sock`); const controlToken = Math.random().toString(36).slice(2); +// The remote Host persists its enrollment + ACL here, under the harness's own +// temp dir so a dev run never touches the installed app's state. +const stateDir = path.join(os.tmpdir(), `dormouse-${process.pid}-browser-state`); const pending = new Map(); const sseClients = new Set(); @@ -84,6 +87,9 @@ const fireAndForget = { pty_kill: ({ id }) => writeSidecar('pty:kill', { id }), pty_request_init: () => writeSidecar('pty:requestInit'), dor_control_response: ({ response }) => writeSidecar('dor:controlResponse', response), + // The remote Host's whole bridge rides one passthrough, exactly as it does + // through Rust (`remote_host_command` in src-tauri/src/lib.rs). + remote_host_command: ({ payload }) => writeSidecar('remoteHost:command', payload), kill_sidecar_now: () => shutdown(), }; @@ -197,10 +203,12 @@ function startSidecar() { DORMOUSE_CLI_JS: dorEntrypoint, DORMOUSE_CONTROL_SOCKET: controlSocket, DORMOUSE_CONTROL_TOKEN: controlToken, + DORMOUSE_STATE_DIR: stateDir, }, }); log(`sidecar pid=${sidecar.pid}`); log(`dor control socket: ${controlSocket}`); + log(`remote host state dir: ${stateDir}`); createInterface({ input: sidecar.stdout }).on('line', (line) => { let msg; diff --git a/standalone/scripts/tauri-conf.test.mjs b/standalone/scripts/tauri-conf.test.mjs new file mode 100644 index 00000000..3cc0e962 --- /dev/null +++ b/standalone/scripts/tauri-conf.test.mjs @@ -0,0 +1,26 @@ +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'; + +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; + +// The remote Host moved into the sidecar, so the webview never speaks to a relay +// server and its connect-src must not be able to. The allowlist that does apply +// is baked into the sidecar bundle by build-sidecar-proxy.mjs +// (docs/specs/server.md → "Host webview CSP"). +test('the webview cannot reach a relay server', () => { + assert.ok(!csp.includes('dormouse.sh'), 'no SaaS relay sources in the webview CSP'); + // 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:;') && !csp.includes(' https: '), 'no bare https: source'); + assert.ok(!csp.includes(' wss:;') && !csp.includes(' wss: '), 'no bare wss: source'); +}); + +test('localhost stays allowed for dev and the loopback proxies', () => { + assert.ok(csp.includes('http://localhost:*') && csp.includes('ws://localhost:*')); + assert.ok(csp.startsWith("default-src 'self'")); +}); diff --git a/standalone/scripts/tauri.mjs b/standalone/scripts/tauri.mjs index 6eeb3e95..bbe6cb7e 100644 --- a/standalone/scripts/tauri.mjs +++ b/standalone/scripts/tauri.mjs @@ -1,33 +1,16 @@ #!/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. +// Wraps the Tauri CLI so `pnpm tauri …` always stages the sidecar bundles first. // -// 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. +// The remote-server allowlist is no longer a webview concern: the Host runs in +// the sidecar, and `DORMOUSE_REMOTE_CONNECT_SRC` is baked into that bundle by +// `build-sidecar-proxy.mjs` (which `pnpm run stage` runs ahead of this). The +// webview's CSP in tauri.conf.json has no remote sources at all. // // 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' }); +const child = spawn('pnpm', ['exec', 'tauri', ...process.argv.slice(2)], { stdio: 'inherit' }); child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 5b7906f9..e4669e14 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -18,6 +18,10 @@ const { createIframeProxyUrl } = require('./iframe-proxy.cjs'); // for the agent-browser host capabilities, run here exactly as the VS Code // extension host runs it. See docs/specs/dor-browser.md → "Agent-Browser Host Capabilities". const { createAgentBrowserHost } = require('./agent-browser-host.cjs'); +// Same pattern again: lib/src/host/remote/sidecar-entry.ts is the remote Host — +// the relay socket, the enrollment, the ACL, and remote-api v1 — running next to +// the PTYs it serves. See docs/specs/remote-api.md. +const { createSidecarRemoteHost } = require('./remote-host.cjs'); const agentBrowser = createAgentBrowserHost({ writeClipboardText: (text) => clipboard.writeClipboardText(text), @@ -29,9 +33,23 @@ function send(event, data) { } const mgr = create((event, data) => { + // Tap output and exits for the remote Host before they go to the webview. A + // remote listener must never be able to break the local pipe, so its failure + // is logged and the send happens either way. + try { + remoteHost.onPtyEvent(event, data); + } catch (err) { + console.error(`[sidecar] remote host ${event} tap failed:`, err && err.message || err); + } send(`pty:${event}`, data); }, nodePty); +const remoteHost = createSidecarRemoteHost({ + send, + stateDir: process.env.DORMOUSE_STATE_DIR, + mgr, +}); + const dorControl = createDorControlServer({ socketPath: process.env.DORMOUSE_CONTROL_SOCKET, token: process.env.DORMOUSE_CONTROL_TOKEN, @@ -70,6 +88,7 @@ rl.on('line', (line) => { case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout, data.requestId); break; case 'sidecar:shutdown': shutdown(); break; case 'dor:controlResponse': dorControl?.respond(data); break; + case 'remoteHost:command': remoteHost.handleCommand(data); break; case 'iframe:createProxyUrl': // Log to stderr — stdout is the JSON-lines protocol channel. respondAsync('iframe:proxyUrl', data.requestId, async () => ({ @@ -155,6 +174,7 @@ async function shutdown() { ]); } catch {} dorControl?.close(); + remoteHost.dispose(); mgr.killAll(); process.exit(0); } diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 36439322..699e583b 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1128,6 +1128,13 @@ module.exports.create = function create(send, ptyModule) { if (p) p.resize(cols, rows); } + // Synchronous lifetime observation for the remote Host's atomic + // subscribe-then-check. Natural exits delete the generation from `ptys`, and + // a spawn under the same id installs the new generation before it can emit. + function hasPty(id) { + return ptys.has(id); + } + function kill(id) { const p = ptys.get(id); if (p) { @@ -1239,5 +1246,5 @@ module.exports.create = function create(send, ptyModule) { send('shells', { shells: detectAvailableShells(), requestId }); } - return { spawn, write, resize, kill, killAll, list, getCwd, getOpenPorts, getScrollback, interrupt, gracefulKillAll, getShells }; + return { spawn, write, resize, hasPty, kill, killAll, list, getCwd, getOpenPorts, getScrollback, interrupt, gracefulKillAll, getShells }; }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 1c6375cc..c5c27e12 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -349,9 +349,13 @@ test('create buffers scrollback for getScrollback requests', () => { }, }); + assert.equal(mgr.hasPty('pane-1'), false); mgr.spawn('pane-1'); + assert.equal(mgr.hasPty('pane-1'), true); listeners.data?.('hello'); listeners.data?.(' world'); + listeners.exit?.({ exitCode: 0, signal: undefined }); + assert.equal(mgr.hasPty('pane-1'), false); mgr.getScrollback('pane-1', 'req-1'); assert.deepEqual(events.at(-1), { diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index ce126f5c..d9fb1174 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -417,6 +417,19 @@ fn pty_request_init(state: tauri::State<'_, SidecarState>) { send_to_sidecar(&state, msg.to_string()); } +// One passthrough for the whole remote-host bridge: the webview and the sidecar +// service share a contract (lib/src/host/remote/service-protocol.ts) that Rust +// has no reason to know, so the payload rides through opaquely. Replies come +// back on the sidecar's own stdout events, not from this invoke. +#[tauri::command] +fn remote_host_command(state: tauri::State<'_, SidecarState>, payload: JsonValue) { + let msg = serde_json::json!({ + "event": "remoteHost:command", + "data": payload, + }); + send_to_sidecar(&state, msg.to_string()); +} + #[tauri::command] fn dor_control_response(state: tauri::State<'_, SidecarState>, response: DorControlResponse) { let msg = serde_json::json!({ @@ -1133,6 +1146,26 @@ fn resolve_dor_cli_paths(sidecar_path: &Path, manifest_dir: &Path) -> DorCliPath dor_cli_paths_from_root(manifest_dir.join("..").join("..").join("dor")) } +// Where the sidecar's remote Host persists its enrollment (a bearer credential) +// and its ACL, as one 0600 file it writes itself +// (lib/src/host/remote/host-state-store.ts). Created here so a first launch +// hands the sidecar a directory that exists; if it can't be made, the sidecar is +// told nothing and runs without persistence rather than not at all. +fn remote_host_state_dir(app: &AppHandle) -> Option { + let dir = match app.path().app_data_dir() { + Ok(dir) => dir, + Err(e) => { + append_log(format!("[sidecar] app_data_dir unavailable: {e}")); + return None; + } + }; + if let Err(e) = create_dir_all(&dir) { + append_log(format!("[sidecar] create state dir: {e}")); + return None; + } + Some(dir.to_string_lossy().into_owned()) +} + fn start_sidecar(app: &AppHandle) -> Result { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let sidecar_path = resolve_sidecar_path(app.path().resource_dir().ok(), manifest_dir); @@ -1141,6 +1174,7 @@ fn start_sidecar(app: &AppHandle) -> Result { let dor_node_path = resolve_dor_node_path(&node_path, app); let dor_control_socket = dor_control_socket_path(); let dor_control_token = dor_control_token(); + let state_dir = remote_host_state_dir(app); append_log(format!( "[sidecar] resolved script: {}", sidecar_path.display() @@ -1156,6 +1190,10 @@ fn start_sidecar(app: &AppHandle) -> Result { dor_cli_paths.entrypoint.display() )); append_log(format!("[dor] control socket: {dor_control_socket}")); + append_log(format!( + "[remote-host] state dir: {}", + state_dir.as_deref().unwrap_or("(none)") + )); let mut wrap = CommandWrap::with_new(&node_path, |c| { c.arg(&sidecar_path) @@ -1165,6 +1203,7 @@ fn start_sidecar(app: &AppHandle) -> Result { .env("DORMOUSE_CLI_JS", &dor_cli_paths.entrypoint) .env("DORMOUSE_CONTROL_SOCKET", &dor_control_socket) .env("DORMOUSE_CONTROL_TOKEN", &dor_control_token) + .env("DORMOUSE_STATE_DIR", state_dir.as_deref().unwrap_or("")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1411,6 +1450,7 @@ pub fn run() { iframe_create_proxy_url, pty_request_init, dor_control_response, + remote_host_command, kill_sidecar_now, quit_ack, quit_progress, diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 27f0dd1c..00666088 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:* ws://127.0.0.1:* ws://localhost:* https://*.dormouse.sh wss://*.dormouse.sh; 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:*; frame-src http://127.0.0.1:* http://localhost:*" } }, "bundle": { diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index a884f019..60ca48eb 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -11,7 +11,21 @@ import type { OpenPort, PlatformAdapter, PtyInfo, + RemoteHostLink, } from "dormouse-lib/lib/platform/types"; +import { + answerAskCommand, + createRemoteHostLinkClient, + notifyCommand, +} from "dormouse-lib/host/remote/link-client"; +import { + REMOTE_HOST_ASK_EVENT, + REMOTE_HOST_EVENT_EVENT, + REMOTE_HOST_RESULT_EVENT, + type RemoteHostAsk, + type RemoteHostCommand, + type RemoteHostResult, +} from "dormouse-lib/host/remote/service-protocol"; import { AlertManager } from "dormouse-lib/lib/alert-manager"; import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; @@ -46,6 +60,15 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private protocolParsers = new Map(); private alertManager = new AlertManager(); private unlistenHost: (() => void) | null = null; + // Remote-host bridge, identical in shape to TauriAdapter's — the dev harness + // forwards the same `remoteHost:*` messages over its own transport. + private readonly remoteHostClient = createRemoteHostLinkClient({ + sendCommand: (command) => this.sendRemoteHostCommand(command), + answerAsk: (askId, results) => this.sendRemoteHostCommand(answerAskCommand(askId, results)), + notify: () => this.sendRemoteHostCommand(notifyCommand()), + }); + + readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; constructor(private readonly host: BrowserSidecarHost) { this.alertManager.onStateChange((id, state) => { @@ -77,10 +100,15 @@ export class BrowserSidecarAdapter implements PlatformAdapter { this.protocolParsers.clear(); this.unlistenHost?.(); this.unlistenHost = null; + this.remoteHostClient.dispose(); this.host.send("kill_sidecar_now"); this.host.close(); } + private sendRemoteHostCommand(command: RemoteHostCommand): void { + this.host.send("remote_host_command", { payload: command }); + } + async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> { try { return await this.host.invoke("get_available_shells"); @@ -259,6 +287,13 @@ export class BrowserSidecarAdapter implements PlatformAdapter { const parsed = this.getProtocolParser(id).process(text); applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData }); + } else if (event === REMOTE_HOST_RESULT_EVENT) { + this.remoteHostClient.onResult(data as RemoteHostResult); + } else if (event === REMOTE_HOST_ASK_EVENT) { + const ask = data as RemoteHostAsk; + this.remoteHostClient.onAsk(ask.rhId, ask.op, ask.params); + } else if (event === REMOTE_HOST_EVENT_EVENT) { + this.remoteHostClient.onEvent(data); } else if (event === "dor:controlRequest") { const payload = data as DorControlRequestPayload; const respond = (response: DorControlResult) => { diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index c909e045..505f794b 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { setPlatform } from "dormouse-lib/lib/platform"; +import { installPeerSurfaceResponder } from "dormouse-lib/remote/host/peer-surfaces"; import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; import { resumeOrRestore } from "dormouse-lib/lib/reconnect"; import { seedShellStore } from "dormouse-lib/lib/shell-store"; @@ -84,6 +85,16 @@ async function bootstrap() { const platform = await createPlatform(); setPlatform(platform); await platform.init(); + // The remote Host runs in the sidecar, which owns the PTYs but not this + // webview's view of them: what a pane is called, and how big its xterm is. + // Installing the responder is what makes those answerable + // (docs/specs/remote-api.md). + // + // After `init()`, not before: the responder asks the Host whether there is + // one at all, and nothing could carry the answer back until the adapter has + // its listeners. An ask that arrives in the gap goes unanswered, which is + // what the Host's budget is for. + installPeerSurfaceResponder(); // Shell detection is a webview -> Rust -> sidecar round trip, so start it now // and await it below: it overlaps the dynamic imports and theme restore // rather than adding its latency to cold boot. diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 7873f6fd..87f2ea91 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -15,6 +15,7 @@ vi.mock("@tauri-apps/plugin-shell", () => ({ })); import { invoke as rawInvoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { TauriAdapter } from "./tauri-adapter"; const tick = () => new Promise((r) => setTimeout(r, 0)); @@ -97,3 +98,93 @@ describe("TauriAdapter legacy session cleanup", () => { adapter.shutdown(); }); }); + +// The remote Host lives in the sidecar; this is the webview's end of the bridge +// (lib/src/host/remote/service-protocol.ts). Correlation is `rhId`, never +// `requestId` — Rust swallows any sidecar line carrying the latter to resolve +// its own pending invokes. +// +// Only what this transport adds is covered here: one invoke carries everything, +// so an answer and a notify ride it as ordinary commands. The correlation, +// timeout, always-answer, and dispose rules are the shared client's +// (lib/src/host/remote/link-client.test.ts). +describe("TauriAdapter remote host link", () => { + type Payload = { rhId: string; cmd: string; params?: unknown }; + + async function bridged() { + const handlers = new Map void>(); + vi.mocked(listen).mockImplementation((async ( + event: string, + handler: (e: { payload: unknown }) => void, + ) => { + handlers.set(event, handler); + return () => {}; + }) as unknown as typeof listen); + const invoke = vi.mocked(rawInvoke); + invoke.mockClear(); + invoke.mockResolvedValue(undefined); + + const adapter = new TauriAdapter(); + await adapter.init(); + invoke.mockClear(); + + const sent = (): Payload[] => + invoke.mock.calls + .filter(([cmd]) => cmd === "remote_host_command") + .map(([, args]) => (args as { payload: Payload }).payload); + const deliver = (event: string, payload: unknown): void => { + handlers.get(event)?.({ payload }); + }; + return { adapter, sent, deliver }; + } + + it("resolves a command by its rhId", async () => { + const { adapter, sent, deliver } = await bridged(); + const pending = adapter.remoteHost.command("status"); + + const payload = sent()[0]!; + expect(payload.cmd).toBe("status"); + // A result for someone else's rhId must not resolve this one. + deliver("remoteHost:result", { rhId: "other", result: { enrolled: false } }); + deliver("remoteHost:result", { rhId: payload.rhId, result: { enrolled: true } }); + + expect(await pending).toEqual({ enrolled: true }); + }); + + it("answers an ask from the registered responder", async () => { + const { adapter, sent, deliver } = await bridged(); + adapter.remoteHost.respond("surfaceOp", (params) => [ + { ptyId: "pty-1", ...(params as Record) }, + ]); + + deliver("remoteHost:ask", { rhId: "ask-1", op: "surfaceOp", params: { surfaceId: "s1" } }); + + expect(sent()[0]).toMatchObject({ + cmd: "answer", + params: { rhId: "ask-1", results: [{ ptyId: "pty-1", surfaceId: "s1" }] }, + }); + }); + + it("fans a sidecar event out by name", async () => { + const { adapter, deliver } = await bridged(); + const seen: unknown[] = []; + adapter.remoteHost.on("pairing-queue", (data) => void seen.push(data)); + + deliver("remoteHost:event", { name: "pairing-queue", queue: [{ clientId: "c1" }] }); + expect(seen).toEqual([{ name: "pairing-queue", queue: [{ clientId: "c1" }] }]); + }); + + it("notifies without waiting for anything", async () => { + const { adapter, sent } = await bridged(); + adapter.remoteHost.notify(); + expect(sent()[0]).toMatchObject({ cmd: "notify" }); + expect(sent()[0]!.params).toBeUndefined(); + }); + + it("rejects what is still in flight when the sidecar is killed", async () => { + const { adapter } = await bridged(); + const pending = adapter.remoteHost.command("status"); + adapter.shutdown(); + await expect(pending).rejects.toThrow("remote host bridge closed"); + }); +}); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index f76b4970..8b243bc1 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -14,7 +14,21 @@ import type { OpenPort, PlatformAdapter, PtyInfo, + RemoteHostLink, } from "dormouse-lib/lib/platform/types"; +import { + answerAskCommand, + createRemoteHostLinkClient, + notifyCommand, +} from "dormouse-lib/host/remote/link-client"; +import { + REMOTE_HOST_ASK_EVENT, + REMOTE_HOST_EVENT_EVENT, + REMOTE_HOST_RESULT_EVENT, + type RemoteHostAsk, + type RemoteHostCommand, + type RemoteHostResult, +} from "dormouse-lib/host/remote/service-protocol"; import { AlertManager } from "dormouse-lib/lib/alert-manager"; import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; @@ -72,6 +86,21 @@ export class TauriAdapter implements PlatformAdapter { private flushHandlers = new Set<(detail: { requestId: string }) => void>(); private pendingFlushRequests = new Map void>(); private nextFlushRequestId = 0; + // --- Remote host bridge (docs/specs/remote-api.md) --- + // + // The Host lives in the sidecar, next to the PTYs. This webview forwards its + // console commands, answers what only it knows (pane names, xterm sizes), and + // mirrors the pairing queue. Only the transport is this adapter's: one Rust + // invoke carries everything, so an answer and a notify ride it as ordinary + // commands, and correlation is `rhId` — never `requestId`, which Rust + // swallows on any sidecar line that carries it. + private readonly remoteHostClient = createRemoteHostLinkClient({ + sendCommand: (command) => this.sendRemoteHostCommand(command), + answerAsk: (askId, results) => this.sendRemoteHostCommand(answerAskCommand(askId, results)), + notify: () => this.sendRemoteHostCommand(notifyCommand()), + }); + + readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; constructor() { // Wire alert manager state changes to handlers @@ -85,8 +114,12 @@ export class TauriAdapter implements PlatformAdapter { async init(): Promise { // Set up event listeners for PTY events from the Rust backend // (The Rust backend manages the Node.js sidecar lifecycle via std::process::Command) - this.unlistenFns.push( - await listen<{ id: string; data: string }>("pty:data", (event) => { + // + // Registered together rather than one await after another: every `listen` + // is an independent round trip to Rust, and serializing them puts the whole + // set in front of the first paint. + this.unlistenFns.push(...(await Promise.all([ + listen<{ id: string; data: string }>("pty:data", (event) => { const { id, data } = event.payload; const parsed = this.getProtocolParser(id).process(data); applyTerminalProtocolEvents(this.alertManager, id, parsed.events); @@ -103,28 +136,22 @@ export class TauriAdapter implements PlatformAdapter { handler({ id, data: parsed.visibleData }); } }), - ); - this.unlistenFns.push( - await listen<{ id: string; exitCode: number }>("pty:exit", (event) => { + listen<{ id: string; exitCode: number }>("pty:exit", (event) => { this.alertManager.onExit(event.payload.id, event.payload.exitCode); this.protocolParsers.delete(event.payload.id); for (const handler of this.exitHandlers) { handler(event.payload); } }), - ); - this.unlistenFns.push( - await listen<{ ptys: PtyInfo[] }>("pty:list", (event) => { + listen<{ ptys: PtyInfo[] }>("pty:list", (event) => { for (const handler of this.listHandlers) { handler(event.payload); } }), - ); - this.unlistenFns.push( - await listen<{ id: string; data: string }>("pty:replay", (event) => { + listen<{ id: string; data: string }>("pty:replay", (event) => { // Replay arrives as raw buffered output. Run it through the protocol // parser so semantic OSCs (CWD, prompt, title) repopulate pane state // and are stripped before xterm sees them, mirroring live pty:data. @@ -135,19 +162,28 @@ export class TauriAdapter implements PlatformAdapter { handler({ id, data: parsed.visibleData }); } }), - ); - // Inert while dragDropEnabled=false in tauri.conf.json. See diffplug/dormouse#38 and tauri-apps/tauri#14373. - this.unlistenFns.push( - await listen<{ paths: string[] }>("dormouse://files-dropped", (event) => { + // Inert while dragDropEnabled=false in tauri.conf.json. See diffplug/dormouse#38 and tauri-apps/tauri#14373. + listen<{ paths: string[] }>("dormouse://files-dropped", (event) => { const paths = event.payload.paths ?? []; if (paths.length === 0) return; for (const handler of this.filesDroppedHandlers) handler(paths); }), - ); - this.unlistenFns.push( - await listen("dor:controlRequest", (event) => { + listen(REMOTE_HOST_RESULT_EVENT, (event) => { + this.remoteHostClient.onResult(event.payload); + }), + + listen(REMOTE_HOST_ASK_EVENT, (event) => { + const ask = event.payload; + this.remoteHostClient.onAsk(ask.rhId, ask.op, ask.params); + }), + + listen<{ name?: string }>(REMOTE_HOST_EVENT_EVENT, (event) => { + this.remoteHostClient.onEvent(event.payload); + }), + + listen("dor:controlRequest", (event) => { const payload = event.payload; const respond = (response: DorControlResult) => { rawInvoke("dor_control_response", { @@ -170,7 +206,7 @@ export class TauriAdapter implements PlatformAdapter { }, })); }), - ); + ]))); await this.hydrateSessionStore(); } @@ -197,6 +233,8 @@ export class TauriAdapter implements PlatformAdapter { unlisten(); } this.unlistenFns = []; + // Nothing will answer what is outstanding once the sidecar is gone. + this.remoteHostClient.dispose(); invoke("kill_sidecar_now"); } @@ -443,6 +481,12 @@ export class TauriAdapter implements PlatformAdapter { ); } + private sendRemoteHostCommand(command: RemoteHostCommand): void { + rawInvoke("remote_host_command", { payload: command }).catch((err) => + console.error("[tauri-adapter] remote_host_command failed:", err), + ); + } + // --- Alert management (local AlertManager) --- alertRemove(id: string): void { diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 05bd8863..788179be 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -101,30 +101,34 @@ "scripts": { "postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true", "build:frontend": "vite build --config vite.config.ts", - "pretypecheck": "pnpm --filter dor-lib-common build", + "pretypecheck": "pnpm --filter dor-lib-common build && pnpm --filter server-lib-common build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "pnpm typecheck", - "build": "pnpm stage:dor-cli && esbuild src/extension.ts --bundle --outdir=dist --external:vscode --external:node-pty --format=cjs --platform=node && esbuild src/pty-host.js --bundle --outfile=dist/pty-host.js --external:node-pty --format=cjs --platform=node && cp -RL node_modules/node-pty dist/node-pty && rm -rf dist/shell-integration && cp -RL ../standalone/sidecar/shell-integration dist/shell-integration", + "test": "pnpm typecheck && vitest run", + "prebuild": "pnpm --filter server-lib-common build", + "build": "pnpm stage:dor-cli && node scripts/esbuild.mjs && cp -RL node_modules/node-pty dist/node-pty && rm -rf dist/shell-integration && cp -RL ../standalone/sidecar/shell-integration dist/shell-integration", "stage:dor-cli": "pnpm --filter dor build && node ../scripts/stage-dor-cli.mjs vscode-ext/dor-cli", - "watch": "pnpm build --watch", + "watch": "pnpm --filter server-lib-common build && pnpm stage:dor-cli && node scripts/esbuild.mjs --watch", "package": "vsce package --no-dependencies --out dormouse.vsix", "dogfood": "node ../scripts/dogfood-vscode.mjs", "publish:marketplace": "vsce publish --no-dependencies", "publish:openvsx": "ovsx publish --no-dependencies" }, "dependencies": { - "node-pty": "1.2.0-beta.15" + "node-pty": "1.2.0-beta.15", + "ws": "^8.18.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.0", "@types/node": "^24.0.0", "@types/vscode": "1.85.0", + "@types/ws": "^8.5.0", "@vitejs/plugin-react": "^6.0.2", "@vscode/vsce": "^3.9.1", "esbuild": "^0.28.0", "ovsx": "^1.0.0", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", - "vite": "^8.0.14" + "vite": "^8.0.14", + "vitest": "^4.1.6" } } diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs new file mode 100644 index 00000000..3df4fb7b --- /dev/null +++ b/vscode-ext/scripts/esbuild.mjs @@ -0,0 +1,62 @@ +// Bundles the extension host and the PTY host, and is the single place that +// bakes the remote Host's allowed relay origins into the build. +// +// The published extension is scoped to the SaaS origin only, so the Host will +// not enroll with, or connect to, an arbitrary server. A selfhoster whose relay +// is on their own domain or tailnet widens it for their own build: +// +// DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode +// +// This mirrors the standalone binary's build-time override +// (`standalone/scripts/build-sidecar-proxy.mjs`, which bakes the same value into +// the sidecar's Host) so both Hosts widen the same way with the same variable. +// `scripts/csp-defaults.mjs` is the one definition of the default for both. See +// docs/specs/server.md → "Host webview CSP". + +import * as esbuild from 'esbuild'; + +import { + assertConnectSrcBaked, + CONNECT_SRC_PLACEHOLDER, + resolveRemoteConnectSrc, +} from '../../scripts/csp-defaults.mjs'; + +const remoteSrc = resolveRemoteConnectSrc(process.env, 'esbuild'); + +const watch = process.argv.includes('--watch'); + +const common = { + bundle: true, + format: 'cjs', + platform: 'node', + // `bufferutil` / `utf-8-validate` are `ws`'s optional native accelerators. + // They are not installed and must not be — a `.node` addon cannot be bundled + // and would have to be shipped per platform — so they stay as runtime + // `require`s that `ws` already catches and falls back from. + external: ['vscode', 'node-pty', 'bufferutil', 'utf-8-validate'], +}; + +const builds = [ + { + ...common, + entryPoints: ['src/extension.ts'], + outdir: 'dist', + define: { [CONNECT_SRC_PLACEHOLDER]: JSON.stringify(remoteSrc) }, + }, + { + ...common, + entryPoints: ['src/pty-host.js'], + outfile: 'dist/pty-host.js', + }, +]; + +if (watch) { + for (const options of builds) { + const ctx = await esbuild.context(options); + await ctx.watch(); + } + console.error('[esbuild] watching'); +} else { + await Promise.all(builds.map((options) => esbuild.build(options))); + assertConnectSrcBaked('dist/extension.js', remoteSrc); +} diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index ee9e03e3..ed768466 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -11,6 +11,8 @@ import { readPersistedSession } from '../../lib/src/lib/session-types'; import { workspaceTitle } from './workspace-chrome'; import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection'; import type { ExtensionMessage } from './message-types'; +import { initRemoteHost } from './remote-host'; +import { disposePeerLink, initPeerLink } from './peer-link'; type NewTerminalMessage = Extract; @@ -73,6 +75,13 @@ function setupPanel( } export function activate(context: vscode.ExtensionContext) { + // Storage location only; nothing binds a socket until there is a Host to run + // (remote-host.ts). + initPeerLink(context); + context.subscriptions.push({ dispose: () => void disposePeerLink() }); + // The remote Host runs here, in the extension host that owns the PTYs — in + // whichever window wins the bind (remote-host.ts). + context.subscriptions.push(initRemoteHost(context)); log.init(); extensionContext = context; ptyManager.setExtensionPath(context.extensionPath); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index b914fd7a..f53750cf 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -21,6 +21,19 @@ import type { WebviewMessage, ExtensionMessage } from './message-types'; import type { DorControlRequest } from './pty-manager'; import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; +import { ASK_BUDGET_MS } from '../../lib/src/host/remote/service-protocol'; +import { configurePeerLink, remoteNotifyPeerChange } from './peer-link'; +import { createProcessedPtyStreams } from './processed-pty-streams'; +import { + configureRemoteHost, + deliverCommandResult, + deliverUiEvent, + dropForwardedCommands, + greetPeerWindow, + handleForwardedCommand, + handleRemoteHostCommand, + notifyDirectoryChanged, +} from './remote-host'; import { log } from './log'; import type { WebviewChannel } from './webview-messaging'; @@ -32,10 +45,112 @@ const clipboardOps = require('../../lib/clipboard-ops.cjs') as { // Global set of PTY IDs claimed by any router instance. // Prevents reconnecting routers from stealing PTYs owned by other webviews. const globalOwnedPtyIds = new Set(); + interface ActiveRouter { flushSessionSave(timeoutMs?: number): Promise; ownsPty(id: string): boolean; forwardDorControlRequest(request: DorControlRequest): void; + send(message: ExtensionMessage): void; + ask(requestId: string, op: string, params: unknown): void; +} + +let nextBrokerRequestId = 0; + +interface PendingRequest { + /** Answers still outstanding, so a miss settles as fast as a hit. */ + pending: Set; + results: unknown[]; + settle: () => void; + timer: ReturnType; +} +const peerRequests = new Map(); +const processedPtyStreams = createProcessedPtyStreams( + onProcessedPtyData, + onProcessedPtyExit, + ptyManager.getPtyStatus, +); + +// The link reaches other windows; it must never call back into a fan-out that +// would reach them again, so it only ever gets the in-window broker. +configurePeerLink({ + brokerRequest, + invalidateDirectory: notifyDirectoryChanged, + streamPty: processedPtyStreams.streamPty, + writePty: (ptyId, data) => ptyManager.write(ptyId, data), + resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), + // Peer PTYs use generated provider-local route handles. Keep those handles + // outside this window's real PTY namespace so local ids always fall through + // to the manager that owns them. + ownsPty: (ptyId) => ptyManager.hasPty(ptyId) || globalOwnedPtyIds.has(ptyId), + // The Host half: which of these fire depends on which side of the bind this + // window landed on, and the link is what knows that. + handleForwardedCommand, + dropForwardedCommands, + deliverCommandResult, + deliverUiEvent, + onClientAuthenticated: greetPeerWindow, +}); + +configureRemoteHost({ + brokerRequest, + broadcastToWebviews, + streamPty: processedPtyStreams.streamPty, + writePty: (ptyId, data) => ptyManager.write(ptyId, data), + resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), +}); + +/** + * Put one question to every webview in this window and settle with everything + * they answered. + * + * The remote Host runs in the extension host, but a window's terminals are + * spread across its webviews — each has its own xterm registry, so the Host can + * neither list nor attach to a pane without asking. See docs/specs/vscode.md → + * "Peer surfaces". + * + * `op` and `params` are opaque here on purpose: the operation map lives in + * `lib/src/remote/host/peer-surfaces.ts`, and one fan-out rule covers all of + * it — every webview answers with zero or more results, so a webview that owns + * nothing settles the request as fast as the one that does. The budget is the + * backstop for a webview with no live content, which must not hang the phone's + * picker; it is the *inner* one, deliberately shorter than the broker's + * cross-window `PEER_REPLY_BUDGET_MS`, which has to contain a whole run of this + * plus two socket hops. The asker is this window's own Host service, or the + * broker window's over the link, never a webview; that is why it is a plain + * promise rather than message plumbing. + */ +function brokerRequest(op: string, params: unknown): Promise { + const peers = [...activeRouters]; + if (peers.length === 0) return Promise.resolve([]); + + const requestId = `broker-${++nextBrokerRequestId}`; + return new Promise((resolve) => { + const settle = () => { + const request = peerRequests.get(requestId); + if (!request) return; + peerRequests.delete(requestId); + clearTimeout(request.timer); + resolve(request.results); + }; + peerRequests.set(requestId, { + pending: new Set(peers), + results: [], + settle, + timer: setTimeout(settle, ASK_BUDGET_MS), + }); + for (const peer of peers) peer.ask(requestId, op, params); + }); +} + +/** + * Post one message to every live webview in this window. + * + * The Host's results ride this rather than a reply to one webview: the service + * answers an `rhId`, and only the adapter that minted it holds a pending + * command for it (`lib/src/lib/platform/vscode-adapter.ts`). + */ +function broadcastToWebviews(message: ExtensionMessage): void { + for (const router of activeRouters) router.send(message); } const activeRouters = new Set(); @@ -61,14 +176,21 @@ const themeColorProvider: TerminalColorProvider = (target) => latestThemeColors? // the protocol parser once per chunk regardless of webview count. type ProcessedDataListener = (id: string, visibleData: string) => void; const processedDataListeners = new Set(); +type ProcessedExitListener = (id: string, exitCode: number) => void; +const processedExitListeners = new Set(); type SemanticEventsListener = (id: string, events: TerminalSemanticEvent[]) => void; const semanticEventsListeners = new Set(); -function onProcessedPtyData(listener: ProcessedDataListener): () => void { +export function onProcessedPtyData(listener: ProcessedDataListener): () => void { processedDataListeners.add(listener); return () => { processedDataListeners.delete(listener); }; } +export function onProcessedPtyExit(listener: ProcessedExitListener): () => void { + processedExitListeners.add(listener); + return () => { processedExitListeners.delete(listener); }; +} + function onTerminalSemanticEvents(listener: SemanticEventsListener): () => void { semanticEventsListeners.add(listener); return () => { semanticEventsListeners.delete(listener); }; @@ -107,6 +229,7 @@ ptyManager.addCallbacks({ log.info(`[alert-feed] ${id}: PTY exited`); alertManager.onExit(id, exitCode); alertProtocolParsers.delete(id); + for (const listener of processedExitListeners) listener(id, exitCode); }, }); @@ -290,12 +413,9 @@ export function attachRouter( if (!ownedPtyIds.has(id)) return; post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); }); - const removePtyCallbacks = ptyManager.addCallbacks({ - onData() {}, - onExit(id: string, exitCode: number) { - if (!ownedPtyIds.has(id)) return; - post({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); - }, + const removeExitListener = onProcessedPtyExit((id, exitCode) => { + if (!ownedPtyIds.has(id)) return; + post({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); }); const removeAlertListener = alertManager.onStateChange((id, state) => { @@ -315,7 +435,7 @@ export function attachRouter( return () => { removeProcessedListener(); removeSemanticListener(); - removePtyCallbacks(); + removeExitListener(); removeAlertListener(); }; } @@ -493,6 +613,36 @@ export function attachRouter( } satisfies ExtensionMessage), ); break; + case 'peer:answer': { + // Every webview answers, so "nobody owns it" settles immediately + // instead of waiting out the budget — which is the common case when + // what was asked about actually lives in another window. + const request = peerRequests.get(msg.requestId); + if (!request) { + // Late: the budget already expired and the Host rendered a snapshot + // without whatever this webview owns. Nothing can re-open a settled + // request, so mark the directory stale instead — the next collect + // asks again and repairs it. Without this an idle machine never + // re-collects and the phone's picker stays wrong indefinitely. + notifyDirectoryChanged(); + break; + } + // Deleted before the results are taken, so a duplicate answer from the + // same webview cannot contribute its panes twice. + if (!request.pending.delete(router)) break; + if (Array.isArray(msg.results)) request.results.push(...msg.results); + if (request.pending.size === 0) request.settle(); + break; + } + case 'peer:notify': + // The directory is the only thing a webview is asked to answer, so the + // message carries nothing but the fact that its snapshot may differ. + notifyDirectoryChanged(); + remoteNotifyPeerChange(); + break; + case 'remoteHost:command': + handleRemoteHostCommand(msg.payload); + break; case 'dormouse:themeColors': // Webview reports its resolved terminal theme; cache for OSC color replies. latestThemeColors = { foreground: msg.foreground, background: msg.background, cursor: msg.cursor }; @@ -657,10 +807,27 @@ export function attachRouter( flushSessionSave, ownsPty, forwardDorControlRequest, + send(message: ExtensionMessage) { + if (disposed) return; + void post(message); + }, + ask(requestId: string, op: string, params: unknown) { + if (disposed) return; + void post({ type: 'peer:ask', requestId, op, params } satisfies ExtensionMessage); + }, dispose() { if (disposed) return; disposed = true; activeRouters.delete(router); + // One fewer webview to ask means the directory's answer changed, even if + // no surface did. + notifyDirectoryChanged(); + remoteNotifyPeerChange(); + // A webview that goes away mid-fan-out must not hold the answer open. + for (const request of peerRequests.values()) { + if (!request.pending.delete(router)) continue; + if (request.pending.size === 0) request.settle(); + } removeWatchedCommandListener(); removeAlertSettingsListener(); resolveAllFlushRequests(); @@ -679,5 +846,7 @@ export function attachRouter( }; activeRouters.add(router); + notifyDirectoryChanged(); + remoteNotifyPeerChange(); return router; } diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 3c48a26a..969f00ae 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -5,6 +5,7 @@ import type { TerminalColors } from '../../lib/src/lib/terminal-protocol'; import type { DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; import type { AgentBrowserStreamStatusResult, IframeProxyResult, OpenPort } from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; +import type { RemoteHostCommand, RemoteHostResult } from '../../lib/src/host/remote/service-protocol'; // Messages from webview → extension host export type WebviewMessage = @@ -28,6 +29,15 @@ export type WebviewMessage = | { type: 'agentBrowser:popOut'; session: string; url?: string; rect?: { x: number; y: number; width: number; height: number }; binaryPath?: string; requestId: string } | { type: 'agentBrowser:popIn'; session: string; url?: string; binaryPath?: string; requestId: string } | { type: 'iframe:createProxyUrl'; url: string; requestId: string } + // Peer surfaces: the remote Host runs in the extension host, but the terminals + // live in whichever webview opened them. See docs/specs/vscode.md → "Peer + // surfaces". `op` is opaque to the router: the operation map lives in + // `lib/src/remote/host/peer-surfaces.ts`, so a new peer operation adds no + // message type here. + | { type: 'peer:answer'; requestId: string; results: unknown[] } + | { type: 'peer:notify' } + // One command for the Host service (`lib/src/host/remote/service-protocol.ts`). + | { type: 'remoteHost:command'; payload: RemoteHostCommand } | { type: 'dormouse:init' } | ({ type: 'dormouse:themeColors' } & TerminalColors) | { type: 'dormouse:saveState'; state: unknown } @@ -73,6 +83,11 @@ export type ExtensionMessage = | { type: 'agentBrowser:openResult'; requestId: string; ok: boolean; session?: string; wsPort?: number; binaryPath?: string; error?: string } | { type: 'agentBrowser:popResult'; requestId: string; ok: boolean; wsPort?: number; error?: string } | { type: 'iframe:proxyUrl'; requestId: string; result: IframeProxyResult } + | { type: 'peer:ask'; requestId: string; op: string; params: unknown } + // Broadcast to every webview: `rhId` carries a per-adapter tag, so only the + // one that asked finds a pending command to settle. + | { type: 'remoteHost:result'; payload: RemoteHostResult } + | { type: 'remoteHost:event'; payload: unknown } | { type: 'dormouse:newTerminal'; shell?: string; diff --git a/vscode-ext/src/peer-link-protocol.ts b/vscode-ext/src/peer-link-protocol.ts new file mode 100644 index 00000000..b73d7748 --- /dev/null +++ b/vscode-ext/src/peer-link-protocol.ts @@ -0,0 +1,266 @@ +/** + * The wire between VS Code windows (docs/specs/vscode.md → "Peer surfaces + * across windows"). + * + * Within a window the extension host can see every webview, so brokering is a + * function call. Across windows there is no shared process at all — one + * extension host per window — so the window holding the Host lease listens on a + * local socket and the others connect to it. This module is the part with no + * sockets in it: the frame shapes, the newline-delimited framing, the table that + * remembers which window a streaming PTY came from, and the handshake + * primitives the two ends prove the shared token with. + * + * Kept free of sockets, so the protocol's edge cases (a split frame, a peer that + * vanishes mid-attach, a proof over the wrong nonce) are testable without + * spawning processes. `peer-link.ts` is the I/O and the lifecycle on top. + */ + +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +import { + ASK_BUDGET_MS, + type RemoteHostCommand, + type RemoteHostResult, +} from '../../lib/src/host/remote/service-protocol'; + +/** + * How long the broker waits for another window to answer before giving up on it. + * + * Must exceed {@link ASK_BUDGET_MS}, the budget that window then spends fanning + * the same question out to its own webviews, or a slow sibling shows up here as + * a timeout instead of as the incomplete answer it really is — and the broker + * would discard results that were on their way. The margin also covers the two + * socket hops the inner budget knows nothing about. + */ +export const PEER_REPLY_BUDGET_MS = ASK_BUDGET_MS + 2_000; + +/** + * Broker → peer window. + * + * `request` carries one peer operation, and `op` is opaque here: what a peer + * may be asked is a property of the remote Host, not of the transport, so the + * operation map and its real types live in `lib/src/remote/host/peer-surfaces.ts` + * and this layer only moves the bytes. Adding an operation touches neither this + * file nor the socket code. + * + * `request` and `subscribe` carry frame ids because both are awaited. A stream + * is not ready until the owner acknowledges that its sink (and atomic liveness + * check) are installed; output after that remains correlated by `ptyId`. + */ +export type PeerLinkRequest = + | { kind: 'request'; id: string; op: string; params: unknown } + | { kind: 'subscribe'; id: string; ptyId: string } + | { kind: 'unsubscribe'; ptyId: string } + | { kind: 'write'; ptyId: string; data: string } + | { kind: 'resizePty'; ptyId: string; cols: number; rows: number } + /** + * What the Host service made of a {@link PeerLinkResponse} `command`, sent + * back to the one window that forwarded it and to no other. There is no frame + * id because `rhId` already is one: every adapter mints it with a random tag + * of its own, so it is unique across every window and is exactly what the + * asking webview correlates by (`lib/src/lib/platform/vscode-adapter.ts`). + */ + | { kind: 'commandResult'; payload: RemoteHostResult } + /** + * A Host UI event, broadcast to every authenticated window. Unsolicited and + * unaddressed: the pairing queue has to reach whatever webviews exist, since + * any of them may be the one in front of the user (docs/specs/vscode.md). + */ + | { kind: 'uiEvent'; payload: unknown }; + +/** Peer window → broker. */ +export type PeerLinkResponse = + /** + * Everything that window's webviews answered, concatenated. A peer that owns + * nothing the request named contributes no results, so an empty array is how + * "not mine" arrives. + */ + | { kind: 'result'; id: string; results: unknown[] } + /** The requested PTY sink and its liveness observation are installed. */ + | { kind: 'subscribed'; id: string; ptyId: string } + /** Unsolicited: bytes from a PTY the broker subscribed to. */ + | { kind: 'data'; ptyId: string; data: string } + /** Unsolicited: that PTY ended. */ + | { kind: 'exit'; ptyId: string; exitCode: number } + /** Unsolicited: future peer-query answers may differ, so re-collect. */ + | { kind: 'notify' } + /** + * Unsolicited: a webview command from a window with no Host of its own. Only + * the broker runs a service, so a losing window's console hook, pairing + * answer, and push all travel this way and come back as `commandResult`. + */ + | { kind: 'command'; payload: RemoteHostCommand }; + +export type PeerLinkFrame = PeerLinkRequest | PeerLinkResponse; + +/** + * The three-frame opening handshake, in order: `challenge` (server → client), + * `hello` (client → server), `welcome` (server → client). + * + * The shared secret never crosses the socket. Each side proves it knows the + * token by answering the *other* side's fresh nonce with an HMAC over it, so a + * co-resident process that guessed the socket path learns nothing it can replay: + * the proofs are bound to nonces it did not choose, and its own challenge buys + * it only an HMAC of a value it picked, which is not the token. + * + * The server speaks first and the client verifies the `welcome` before it sends + * or answers anything else. That direction is what makes squatting the path + * useless rather than merely expensive — a fake server never proves knowledge of + * the token, so a client hands it no directory, no PTY stream, and no commands + * (`vscode-ext/src/peer-link.ts`). + */ +export interface PeerLinkChallenge { + kind: 'challenge'; + /** Server nonce, base64url. The client's proof is over this. */ + nonce: string; +} + +export interface PeerLinkHello { + kind: 'hello'; + /** Client nonce, base64url. The server's proof is over this. */ + nonce: string; + /** `HMAC-SHA256(token, PEER_CLIENT_PROOF_DOMAIN + serverNonce)`, base64url. */ + proof: string; +} + +export interface PeerLinkWelcome { + kind: 'welcome'; + /** `HMAC-SHA256(token, PEER_SERVER_PROOF_DOMAIN + clientNonce)`, base64url. */ + proof: string; +} + +/** + * Domain separation. Without distinct prefixes the two proofs are the same + * function of the same key, so a fake server could reflect a client's own proof + * back as its welcome and pass for a broker that knows the token. + */ +export const PEER_CLIENT_PROOF_DOMAIN = 'client:'; +export const PEER_SERVER_PROOF_DOMAIN = 'server:'; + +export type PeerLinkHandshake = PeerLinkChallenge | PeerLinkHello | PeerLinkWelcome; + +/** + * One side's proof that it holds the token, computed over the *other* side's + * fresh nonce. + * + * The token never crosses the socket, so a process that guessed the path and + * captured the whole exchange has an HMAC over a nonce that will never be used + * again, and nothing it can replay. `domain` is what keeps the two directions + * from being the same function of the same key — without it a fake server could + * reflect the client's own proof back as its welcome and pass for a broker. + */ +export function proveToken(token: string, domain: string, nonce: string): string { + return createHmac('sha256', token).update(domain + nonce).digest('base64url'); +} + +/** + * Constant-time proof compare, with the same property the `dor` control socket's + * `tokenMatches` has and for the same reason: `!==` on a secret-derived value + * leaks it byte-by-byte to a co-resident local process that can time the + * response, which is precisely the attacker this handshake exists to stop. + * (That module is CommonJS and cannot import this one, so it keeps a deliberate + * second copy; this is the one copy for the peer link.) + */ +export function proofMatches(provided: unknown, expected: string): boolean { + if (typeof provided !== 'string') return false; + const a = createHash('sha256').update(provided).digest(); + const b = createHash('sha256').update(expected).digest(); + return timingSafeEqual(a, b); +} + +/** 128 bits, so no connection ever reuses another's challenge. */ +export function freshNonce(): string { + return randomBytes(16).toString('base64url'); +} + +export function encodeFrame(frame: PeerLinkFrame | PeerLinkHandshake): string { + return `${JSON.stringify(frame)}\n`; +} + +/** + * Accumulates socket chunks and yields whole frames. + * + * A socket splits writes wherever it likes, so a frame can arrive in pieces or + * several can arrive together; anything unparseable is dropped rather than + * killing the link. + */ +export class FrameDecoder { + #buffer = ''; + /** + * Set once one frame has outgrown the cap: everything up to the next newline + * belongs to that frame and is dropped, and normal accumulation resumes after + * it. Resetting the buffer without this would resync mid-frame and read the + * oversized frame's tail as frames of its own. + */ + #discarding = false; + readonly #maxFrameBytes: number; + + /** Bounds a peer that never sends a newline; the default fits a screenful. */ + constructor(maxFrameBytes = 4 * 1024 * 1024) { + this.#maxFrameBytes = maxFrameBytes; + } + + push(chunk: string): unknown[] { + this.#buffer += chunk; + const frames: unknown[] = []; + for (;;) { + const newline = this.#buffer.indexOf('\n'); + if (newline === -1) break; + const line = this.#buffer.slice(0, newline); + this.#buffer = this.#buffer.slice(newline + 1); + if (this.#discarding) { + // That was the oversized frame's terminator; the bytes after it are a + // frame boundary again. + this.#discarding = false; + continue; + } + if (!line.trim()) continue; + try { + frames.push(JSON.parse(line)); + } catch { + // Malformed frame: skip it, keep the link. + } + } + // Whatever is left is one unterminated frame. Past the cap it is a frame we + // can never read, so it goes — but the whole frames already taken out of + // the buffer above are real, and dropping them with it would lose traffic + // from a link that is otherwise healthy. + if (this.#buffer.length > this.#maxFrameBytes) this.#discarding = true; + if (this.#discarding) this.#buffer = ''; + return frames; + } +} + +/** + * The one field the transport reads out of an otherwise opaque answer. + * + * Reserved: an answer that names a `ptyId` is claiming the PTY behind it, and + * that is the only way the broker can learn which window a PTY lives in — a + * `ptyId` on its own says nothing about where it is, and every later write, + * resize, and subscribe has to reach that window. Any peer operation whose + * result carries a `ptyId` therefore gets routed by it; nothing else about the + * answer is interpreted here. + */ +export function routedPtyId(result: unknown): string | null { + const ptyId = (result as { ptyId?: unknown } | null | undefined)?.ptyId; + return typeof ptyId === 'string' ? ptyId : null; +} + +/** + * Drop every PTY routed to `peer`, and report what was dropped. + * + * The broker records where a streaming PTY lives when an attach succeeds — a + * `ptyId` alone says nothing about which window owns it, and input and resizes + * have to reach that window. When the window goes away its terminals go with + * it, and a later write must not be posted into a dead socket. The routes + * themselves are a plain `Map`; only this needs explaining. + */ +export function forgetPeerRoutes(routes: Map, peer: T): string[] { + const dropped: string[] = []; + for (const [ptyId, owner] of routes) { + if (owner !== peer) continue; + dropped.push(ptyId); + routes.delete(ptyId); + } + return dropped; +} diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts new file mode 100644 index 00000000..36c67ca5 --- /dev/null +++ b/vscode-ext/src/peer-link.ts @@ -0,0 +1,1298 @@ +/** + * Which VS Code window runs the remote Host, and how the others reach it + * (docs/specs/vscode.md → "Peer surfaces across windows"). + * + * One extension host runs per window, so left to themselves every window would + * start a Host against the same enrollment, all of them would connect + * `/ws/host`, and the server's displacement would turn into an endless + * reconnect fight. Arbitration is therefore **bind-as-lease**: the socket every + * window would connect to *is* the lease. Its path is fixed — derived from the + * extension's storage location — so the window that binds it first is the + * broker and everyone else connects to it as a client. + * + * Roles never flip downward while a process lives. A broker stays the broker + * until it exits, which is what makes the whole class of mid-transition races a + * lease with a TTL had (start serving, lose the lease, tear down, win it back + * while tearing down) unrepresentable here. A client only ever changes role + * upward, when the broker dies and its socket closes: every client then races + * to bind, and exactly one wins because `bind` is the arbiter. + * + * Traffic runs both ways over that socket, and each direction is the half its + * end alone can do: the broker asks client windows for their directory and + * their surfaces and streams their PTYs, and client windows forward their + * webviews' Host commands to the broker, which is the only process running a + * service, and take back its results and UI events. + * + * Trust: the path is derived, not secret — it has to be the same in every + * window, so anything running as any user on the machine can compute it. Two + * things stand between that and this installation's terminals. On unix the + * sockets live in a 0700 directory of this user's own, checked before every bind + * and every connect, so a co-resident user cannot create the path first (Windows + * named pipes are not filesystem objects and carry their own ACL, so they skip + * that layer). And both ends prove they hold the shared token — from a 0600 file + * in the extension's `globalStorageUri`, the same bar as the `dor` control + * socket — through the mutual handshake below, without the token itself ever + * crossing the wire. The client verifies the server *before* it sends or serves + * anything, so squatting the path buys nothing: a process that cannot prove the + * token gets no directory, no PTY stream, and no commands. + */ + +import { chmod, lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { createConnection, createServer, type Server, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type * as vscode from 'vscode'; + +import type { + RemoteHostCommand, + RemoteHostResult, +} from '../../lib/src/host/remote/service-protocol'; +import { + FrameDecoder, + PEER_CLIENT_PROOF_DOMAIN, + PEER_REPLY_BUDGET_MS, + PEER_SERVER_PROOF_DOMAIN, + encodeFrame, + forgetPeerRoutes, + freshNonce, + proofMatches, + proveToken, + routedPtyId, + type PeerLinkChallenge, + type PeerLinkHello, + type PeerLinkRequest, + type PeerLinkResponse, + type PeerLinkWelcome, +} from './peer-link-protocol'; +import type { PtySink } from './processed-pty-streams'; +import { log } from './log'; + +/** + * What this module needs from the router, injected rather than imported: the + * router calls into the link to reach other windows, so importing back would be + * a cycle. The four command members reach `remote-host.ts`, which imports this + * module for the sending half and so cannot be imported back either. + */ +export interface PeerLinkDeps { + /** Fan out to this window's own webviews — never to other windows. */ + brokerRequest(op: string, params: unknown): Promise; + /** + * Whether this window's own PTY manager holds that id. + * + * Peer PTYs are exposed through generated route handles rather than their + * owner-local ids, because "Duplicate Workspace in New Window" can + * cold-restore the same ids in several windows. This check keeps even that + * generated provider-local handle out of the local manager's namespace. + */ + ownsPty(ptyId: string): boolean; + /** A peer window's answers may have changed, so the directory is stale. */ + invalidateDirectory(): void; + /** + * Watch one PTY this window owns, through the window's shared keyed registry + * (`processed-pty-streams.ts`) rather than a listener pair of this link's own. + */ + streamPty(ptyId: string, sink: PtySink): () => void; + writePty(ptyId: string, data: string): void; + resizePty(ptyId: string, cols: number, rows: number): void; + /** + * Broker side: run a webview command from `from` on this window's service. + * The answer goes back through {@link sendCommandResult}, so the answering + * module is the one that remembers which window is owed it. + */ + handleForwardedCommand(payload: RemoteHostCommand, from: PeerLinkClient): void; + /** Broker side: that window is gone, so nothing it asked can be answered. */ + dropForwardedCommands(from: PeerLinkClient): void; + /** Client side: the broker answered a command this window forwarded. */ + deliverCommandResult(payload: RemoteHostResult): void; + /** Client side: a Host UI event, for this window's webviews to render. */ + deliverUiEvent(payload: unknown): void; + /** + * Broker side: that window just finished the handshake. Nothing about the + * Host has changed *because* it joined, so the events its webviews gate + * themselves on are never coming on their own — whoever holds the Host state + * has to hand it the current one now (`remote-host.ts`). + */ + onClientAuthenticated(client: PeerLinkClient): void; +} + +let deps: PeerLinkDeps | null = null; + +export function configurePeerLink(next: PeerLinkDeps): void { + deps = next; +} + +const TOKEN_FILE = 'remote-host.peer-token'; + +/** Floor between contention attempts, so a refused hello cannot become a spin. */ +const RETRY_MS = 1_000; + +/** + * How long a connect may spend between `accept` and a verified `welcome`. A + * process that takes the path and then says nothing would otherwise hold the + * contention loop open forever, because the loop awaits this rather than polls. + */ +const HANDSHAKE_BUDGET_MS = 5_000; + +let context: vscode.ExtensionContext | null = null; + +export function initPeerLink(ctx: vscode.ExtensionContext): void { + context = ctx; +} + +function tokenPath(): string | null { + return context ? join(context.globalStorageUri.fsPath, TOKEN_FILE) : null; +} + +/** + * The directory the peer sockets live in, one per OS user. + * + * `tmpdir()` is shared by every user on the machine and the socket path is + * derived rather than random — it has to be, since binding it *is* the + * arbitration — so left in the open a co-resident user could create the path + * first and have every Dormouse window in this installation dial them. A + * private directory they cannot write to takes that away before the handshake + * has to. + */ +function peerDirPath(): string { + return join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`); +} + +/** + * Make the per-user socket directory and report whether it is safe to use. + * + * Anything but a plain directory of ours at mode 0700 is somebody else's, + * possibly on purpose, and no amount of retrying makes it ours — so the caller + * stands the peer link down for good rather than spinning against it. + * + * Windows named pipes are not filesystem objects and carry their own ACL, so + * there is nothing here for them to check. + */ +async function peerDirIsSafe(): Promise { + if (process.platform === 'win32') return true; + const dir = peerDirPath(); + await mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => {}); + const uid = process.getuid?.(); + let info = await lstat(dir).catch(() => null); + // Ours but loose — a permissive umask, or a directory from before this check + // existed. Tightening something we already own is safe and keeps the test + // below exact rather than "0700 or better". + if (info?.isDirectory() && info.uid === uid && (info.mode & 0o777) !== 0o700) { + await chmod(dir, 0o700).catch(() => {}); + info = await lstat(dir).catch(() => null); + } + return ( + !!info && + info.isDirectory() && + // `lstat` does not follow, so a symlink reports as one rather than as + // whatever it points at — which is the whole reason it is `lstat`. + !info.isSymbolicLink() && + info.uid === uid && + (info.mode & 0o777) === 0o700 + ); +} + +/** + * The one path every window of this installation contends for. + * + * Hashed rather than joined: macOS caps a unix socket path near 104 bytes and + * the extension's globalStorage path is most of that on its own. Derived from + * that path rather than random precisely because it must be *the same* in every + * window — the bind is the arbitration. + */ +function socketPath(): string | null { + if (!context) return null; + const id = createHash('sha256') + .update(context.globalStorageUri.fsPath) + .digest('hex') + .slice(0, 12); + return process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-${id}` + : join(peerDirPath(), `${id}.sock`); +} + +/** + * The shared secret, created once per installation and reused forever. Written + * with an exclusive create rather than a rename, so two windows starting + * together end up agreeing: the loser reads the winner's token instead of + * overwriting it under a client that already read the old one. + */ +async function ensureToken(): Promise { + const path = tokenPath(); + if (!path) throw new Error('peer link has no storage location'); + try { + return (await readFile(path, 'utf8')).trim(); + } catch { + // Missing (the common first run) — fall through and create it. + } + await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); + const token = randomUUID(); + try { + // 0600: the token is the only thing between another local process and this + // installation's terminals, so it is never briefly world-readable. + await writeFile(path, token, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + return token; + } catch { + return (await readFile(path, 'utf8')).trim(); + } +} + +// ---------------------------------------------------------------- server side + +/** + * One connected window, from the broker's side. Exported because a forwarded + * command is answered by a different module — it holds this as the identity of + * the window that is owed the answer, and hands it back to + * {@link sendCommandResult}. + */ +export interface PeerLinkClient { + socket: Socket; + decoder: FrameDecoder; + authenticated: boolean; + /** The nonce this window challenged it with; its proof must be over exactly this. */ + challenge: string; + /** Bounds a connection that accepts the challenge and then says nothing. */ + handshakeTimer: ReturnType | null; +} + +/** Where bytes from another window's PTY go, once something asks for them. */ +export interface RemotePtySink { + onData(data: string): void; + onExit(exitCode: number): void; +} + +let server: Server | null = null; +/** + * Whether the bind in `server` has been *believed*. + * + * A reclaimed bind is provisional: `stillOurs` spends 250 ms watching for a + * competing window that cleared the same corpse and bound after us, and the + * loser stands down through `closeServer(false)`. Between the bind and that + * verdict `server !== null` is true while this window may be about to give the + * socket up, so every *role* answer reads this instead — otherwise an enroll or + * a secrets-change landing inside that window is told it is the broker, starts + * a service, and the stand-down never tears it down: two Hosts under one hostId, + * displacing each other on the relay forever. + */ +let brokerConfirmed = false; +/** Claimed and cleared with `server`; the two always move together. */ +let serverToken: string | null = null; +const clients = new Set(); +/** Provider-local route handle → the peer window that owns it. */ +const routes = new Map(); +/** Provider-local route handle → the id understood inside that peer window. */ +const routePtyIds = new Map(); +/** Every opaque peer handle minted in this process, including closed routes. */ +const remotePtyHandles = new Set(); +/** Stable handles for repeated resolves of one PTY on one live peer socket. */ +const peerRouteIds = new WeakMap>(); +const remoteSinks = new Map>(); + +interface PendingRemoteSubscription { + client: PeerLinkClient; + routeId: string; + promise: Promise; + settle(): void; +} + +/** Subscribe acknowledgement by frame id, plus its one in-flight id per route. */ +const pendingRemoteSubscriptions = new Map(); +const pendingRemoteSubscriptionByRoute = new Map(); + +/** + * One outstanding {@link ask}, and the window it is outstanding against — so a + * window that disconnects can settle its own without touching anyone else's. + */ +interface PendingPeerRequest { + client: PeerLinkClient; + settle(response: PeerLinkResponse | null): void; +} +const pendingRequests = new Map(); +let nextRequestId = 0; + +function send( + client: PeerLinkClient, + frame: PeerLinkRequest | PeerLinkChallenge | PeerLinkWelcome, +): void { + if (client.socket.destroyed) return; + client.socket.write(encodeFrame(frame)); +} + +/** Ask one peer and resolve when it answers, or when the budget expires. */ +function ask( + client: PeerLinkClient, + // Surface/directory requests use this response table. Stream readiness has + // its own subscribe table below; everything after readiness is one-way and + // correlated by `ptyId` or by the `rhId` already inside it. + frame: Extract, +): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingRequests.delete(frame.id); + resolve(null); + }, PEER_REPLY_BUDGET_MS); + pendingRequests.set(frame.id, { + client, + settle: (response) => { + clearTimeout(timer); + pendingRequests.delete(frame.id); + resolve(response); + }, + }); + send(client, frame); + }); +} + +function authenticatedClients(): PeerLinkClient[] { + return [...clients].filter((client) => client.authenticated); +} + +/** + * Bind an owner-local PTY id to the socket that answered for it. + * + * The returned string is deliberately opaque to the provider. Two windows can + * restore the same PTY id, so returning either owner's raw id would let a later + * answer overwrite the selected surface's stream/write route. One stable token + * per `(socket, ptyId)` makes the selected answer carry its owner with it. + */ +function bindRemotePty(client: PeerLinkClient, ptyId: string): string { + let byPty = peerRouteIds.get(client); + if (!byPty) { + byPty = new Map(); + peerRouteIds.set(client, byPty); + } + let routeId = byPty.get(ptyId); + if (!routeId) { + do { + routeId = `peer:${randomUUID()}`; + } while (routes.has(routeId) || deps?.ownsPty(routeId)); + byPty.set(ptyId, routeId); + } + remotePtyHandles.add(routeId); + routes.set(routeId, client); + routePtyIds.set(routeId, ptyId); + return routeId; +} + +/** Every provider-local handle matching one owner-local PTY on this socket. */ +function matchingRoutes(client: PeerLinkClient, ptyId: string): string[] { + const matches: string[] = []; + for (const [routeId, owner] of routes) { + if (owner === client && routePtyIds.get(routeId) === ptyId) matches.push(routeId); + } + return matches; +} + +function settleRemoteSubscription(routeId: string): void { + const id = pendingRemoteSubscriptionByRoute.get(routeId); + if (!id) return; + pendingRemoteSubscriptions.get(id)?.settle(); +} + +/** + * Wait until the owner has installed its sink and checked durable PTY liveness. + * A silent peer is treated like a closed PTY, keeping an attach bounded and + * fail-closed instead of acknowledging a stream that may not exist. + */ +function beginRemoteSubscription( + client: PeerLinkClient, + routeId: string, + ownerPtyId: string, +): Promise { + const id = `s${++nextRequestId}`; + let resolveReady!: () => void; + const promise = new Promise((resolve) => { + resolveReady = resolve; + }); + const timer = setTimeout(() => { + const pending = pendingRemoteSubscriptions.get(id); + if (!pending) return; + // The owner may have installed the sink even though its acknowledgement was + // lost or delayed. Stop it while its route is still known, or timeout would + // leave that window forwarding an orphaned stream indefinitely. + send(client, { kind: 'unsubscribe', ptyId: ownerPtyId }); + routes.delete(routeId); + routePtyIds.delete(routeId); + for (const sink of [...(remoteSinks.get(routeId) ?? [])]) sink.onExit(0); + remoteSinks.delete(routeId); + pending.settle(); + }, PEER_REPLY_BUDGET_MS); + (timer as unknown as { unref?: () => void }).unref?.(); + + const settle = () => { + if (pendingRemoteSubscriptions.get(id)?.routeId !== routeId) return; + clearTimeout(timer); + pendingRemoteSubscriptions.delete(id); + if (pendingRemoteSubscriptionByRoute.get(routeId) === id) { + pendingRemoteSubscriptionByRoute.delete(routeId); + } + resolveReady(); + }; + pendingRemoteSubscriptions.set(id, { client, routeId, promise, settle }); + pendingRemoteSubscriptionByRoute.set(routeId, id); + send(client, { kind: 'subscribe', id, ptyId: ownerPtyId }); + return promise; +} + +/** JSON primitives and arrays are parseable, but no peer frame can be one. */ +function isFrameObject(frame: unknown): frame is Record { + return typeof frame === 'object' && frame !== null && !Array.isArray(frame); +} + +/** + * Put one peer request to every other window and collect what they answer, or + * address one follow-up to the peer retained by `ownerPtyId`. Empty when + * nothing is connected, and when nobody owned what was asked about. + * + * All windows at once, not one after another: a window that has gone + * unresponsive would otherwise make every request behind it wait out its own + * budget before the window that actually owns the thing is even asked. + * + * `op` is opaque — the operation map lives in + * `lib/src/remote/host/peer-surfaces.ts`. The single exception is + * {@link routedPtyId}: an answer that names a PTY is how this window learns + * where that PTY lives, and every later write, resize, and subscribe depends on + * knowing. + */ +export async function remoteRequest( + op: string, + params: unknown, + ownerPtyId?: string, +): Promise { + const selectedPeer = ownerPtyId ? routes.get(ownerPtyId) : undefined; + const peers = ownerPtyId + ? selectedPeer?.authenticated + ? [selectedPeer] + : [] + : authenticatedClients(); + if (peers.length === 0) return []; + const replies = await Promise.all( + peers.map(async (client) => + [client, await ask(client, { kind: 'request', id: `r${++nextRequestId}`, op, params })] as const, + ), + ); + + const results: unknown[] = []; + for (const [client, reply] of replies) { + if (reply?.kind !== 'result') continue; + for (const result of reply.results) { + const ptyId = routedPtyId(result); + if (!ptyId) { + results.push(result); + continue; + } + // The PTY id in a peer's answer is only meaningful inside that window. + // Replace it before the result reaches `createAskSurfaceProvider`, so the + // selected SurfaceHandle retains the responding socket even when another + // peer answered with the exact same restored surface and PTY ids. + results.push({ + ...(result as Record), + ptyId: bindRemotePty(client, ptyId), + }); + } + } + return results; +} + +/** Whether this provider-local PTY key routes to another window. */ +export function isRemotePty(ptyId: string): boolean { + return routes.get(ptyId) !== undefined; +} + +/** Whether this key originated from a peer, even if that route has since closed. */ +export function isRemotePtyHandle(ptyId: string): boolean { + return remotePtyHandles.has(ptyId); +} + +/** Resolve once the owning window has installed the sink and checked liveness. */ +export function remoteSubscribe(ptyId: string, sink: RemotePtySink): Promise { + const client = routes.get(ptyId); + const ownerPtyId = routePtyIds.get(ptyId); + if (!client || !ownerPtyId) { + sink.onExit(0); + return Promise.resolve(); + } + // Reference-counted per PTY: two attachments to the same foreign surface + // share one stream over the link, and only zero-to-one starts the owner + // forwarding — so a second viewer never restarts a stream that is already + // flowing, and one viewer detaching cannot silence the other. + let sinks = remoteSinks.get(ptyId); + if (!sinks) { + sinks = new Set(); + remoteSinks.set(ptyId, sinks); + sinks.add(sink); + return beginRemoteSubscription(client, ptyId, ownerPtyId); + } + sinks.add(sink); + const pendingId = pendingRemoteSubscriptionByRoute.get(ptyId); + return pendingId + ? pendingRemoteSubscriptions.get(pendingId)?.promise ?? Promise.resolve() + : Promise.resolve(); +} + +export function remoteUnsubscribe(ptyId: string, sink: RemotePtySink): void { + const sinks = remoteSinks.get(ptyId); + if (!sinks?.delete(sink) || sinks.size > 0) return; + // Last viewer gone: stop the owner forwarding. The route stays — "nobody is + // watching it" is not "it moved". Re-attaching an already-attached surface + // resolves the new route first and only then tears the old attachment down, + // so dropping the route here would delete the fresh one and strand every + // later write. Routes are refreshed by every resolve and dropped by the two + // things that really mean the terminal is gone: an `exit` frame, and the + // owning window disconnecting (`forgetPeerRoutes`). + remoteSinks.delete(ptyId); + const client = routes.get(ptyId); + const ownerPtyId = routePtyIds.get(ptyId); + if (client && ownerPtyId) send(client, { kind: 'unsubscribe', ptyId: ownerPtyId }); + settleRemoteSubscription(ptyId); +} + +export function remoteWrite(ptyId: string, data: string): boolean { + const client = routes.get(ptyId); + const ownerPtyId = routePtyIds.get(ptyId); + if (!client || !ownerPtyId) return false; + send(client, { kind: 'write', ptyId: ownerPtyId, data }); + return true; +} + +export function remoteResize(ptyId: string, cols: number, rows: number): boolean { + const client = routes.get(ptyId); + const ownerPtyId = routePtyIds.get(ptyId); + if (!client || !ownerPtyId) return false; + send(client, { kind: 'resizePty', ptyId: ownerPtyId, cols, rows }); + return true; +} + +/** + * Answer one forwarded command, to the window that forwarded it and to nobody + * else. A result posted to every window would settle nothing anywhere else — + * only the adapter that minted the `rhId` holds a pending command for it — and + * would put one window's enrollment secrets in front of another's webviews. + */ +export function sendCommandResult(client: PeerLinkClient, payload: RemoteHostResult): void { + send(client, { kind: 'commandResult', payload }); +} + +/** + * Put a Host UI event in front of every window's webviews. The pairing modal + * may be answered from any of them, so the queue cannot be addressed. + */ +export function broadcastUiEvent(payload: unknown): void { + for (const peer of authenticatedClients()) sendUiEvent(peer, payload); +} + +/** + * Put a Host UI event in front of one window's webviews — the joining window's + * catch-up, which nobody else needs and which carries no state another window + * has not already been told (`remote-host.ts`). + */ +export function sendUiEvent(client: PeerLinkClient, payload: unknown): void { + send(client, { kind: 'uiEvent', payload }); +} + +function dropClient(client: PeerLinkClient): void { + if (client.handshakeTimer) clearTimeout(client.handshakeTimer); + client.handshakeTimer = null; + const wasAuthenticated = clients.delete(client) && client.authenticated; + // A window that went away takes its terminals with it; a later write must not + // be routed into a dead socket. + for (const routeId of forgetPeerRoutes(routes, client)) { + routePtyIds.delete(routeId); + for (const sink of remoteSinks.get(routeId) ?? []) sink.onExit(0); + remoteSinks.delete(routeId); + settleRemoteSubscription(routeId); + } + // Anything this window was still being asked can never be answered either, + // and holding it open to its full reply budget stalls the whole collection it + // belongs to — a directory or an attach that every surviving window already + // answered. Settle them empty now: "gone" and "owns nothing" look the same to + // the caller, which is exactly right. + for (const [id, pending] of [...pendingRequests]) { + if (pending.client !== client) continue; + pendingRequests.delete(id); + pending.settle(null); + } + // Its in-flight commands can never be answered: the socket that would carry + // the answer is the one that closed. The asking webview's own timeout is the + // backstop, and that window is on its way to becoming a broker anyway. + deps?.dropForwardedCommands(client); + if (wasAuthenticated) deps?.invalidateDirectory(); + client.socket.destroy(); +} + +function onServerFrame(client: PeerLinkClient, frame: unknown): void { + if (!isFrameObject(frame)) { + log.error('[peer-link] rejected a client frame that is not an object'); + dropClient(client); + return; + } + const message = frame as (PeerLinkResponse | PeerLinkHello) & { kind: string }; + if (!client.authenticated) { + // First frame must be the hello, answering the challenge this window sent + // on accept; anything else is not a peer of ours. + const hello = message as Partial; + if ( + hello.kind !== 'hello' || + typeof hello.nonce !== 'string' || + !hello.nonce || + !serverToken || + !proofMatches( + hello.proof, + proveToken(serverToken, PEER_CLIENT_PROOF_DOMAIN, client.challenge), + ) + ) { + log.error('[peer-link] rejected a client with a bad hello'); + dropClient(client); + return; + } + if (client.handshakeTimer) clearTimeout(client.handshakeTimer); + client.handshakeTimer = null; + client.authenticated = true; + // Our half, over the nonce *it* chose: a client has no other way to tell + // this window's broker from something that merely bound the path first, and + // it serves nothing until it has this. + send(client, { + kind: 'welcome', + proof: proveToken(serverToken, PEER_SERVER_PROOF_DOMAIN, hello.nonce), + }); + // Joining changes the answer set even if no surface changed while the + // socket was down, so every peer-backed snapshot must be reconsidered. + deps?.invalidateDirectory(); + // And nothing about the Host changed *because* it joined, so the state its + // webviews gate on has to be handed to it rather than waited for. + deps?.onClientAuthenticated(client); + return; + } + + const response = message as PeerLinkResponse; + if (response.kind === 'data') { + for (const routeId of matchingRoutes(client, response.ptyId)) { + for (const sink of remoteSinks.get(routeId) ?? []) sink.onData(response.data); + } + return; + } + if (response.kind === 'exit') { + for (const routeId of matchingRoutes(client, response.ptyId)) { + routes.delete(routeId); + routePtyIds.delete(routeId); + for (const sink of [...(remoteSinks.get(routeId) ?? [])]) sink.onExit(response.exitCode); + remoteSinks.delete(routeId); + settleRemoteSubscription(routeId); + } + return; + } + if (response.kind === 'subscribed') { + const pending = pendingRemoteSubscriptions.get(response.id); + if (pending?.client === client) pending.settle(); + return; + } + if (response.kind === 'notify') { + deps?.invalidateDirectory(); + return; + } + if (response.kind === 'command') { + // Only this window runs a service, so a losing window's webview commands + // are run here on its behalf and answered back over this same socket. + deps?.handleForwardedCommand(response.payload, client); + return; + } + if ('id' in response) { + // Only from the window it was put to: request ids are minted per broker, so + // a window answering another's id would settle a collection it was never + // asked to contribute to. + const pending = pendingRequests.get(response.id); + if (pending?.client === client) pending.settle(response); + } +} + +/** Turn Server.listen's event-based bind failure into a rejecting promise. */ +export function listenServer(nextServer: Server, path: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + nextServer.once('error', onError); + try { + nextServer.listen(path, () => { + nextServer.off('error', onError); + // Past `listen` the only `'error'` this server can emit is an + // accept-time one — EMFILE, a pipe error on Windows — and an + // EventEmitter with no listener for `'error'` *throws*, which would + // take the whole extension host down over one refused connection. So + // there is always one from here on. Logging is all it does: the + // connections already accepted are unaffected, and a listener that has + // genuinely died is noticed by the windows that can no longer reach it, + // which re-contend. Deeper recovery is the contention loop's job. + nextServer.on('error', (error: Error) => { + log.error(`[peer-link] peer server error: ${String(error)}`); + }); + resolve(); + }); + } catch (error) { + nextServer.off('error', onError); + reject(error); + } + }); +} + +/** Take the socket path, or report that somebody else holds it. */ +async function tryBind(path: string, token: string): Promise { + const nextServer = createServer((socket) => { + const client: PeerLinkClient = { + socket, + decoder: new FrameDecoder(), + authenticated: false, + challenge: freshNonce(), + handshakeTimer: null, + }; + clients.add(client); + client.handshakeTimer = setTimeout(() => { + log.error('[peer-link] dropped a client that did not finish the handshake'); + dropClient(client); + }, HANDSHAKE_BUDGET_MS); + client.handshakeTimer.unref(); + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + for (const frame of client.decoder.push(chunk)) onServerFrame(client, frame); + }); + socket.on('error', () => dropClient(client)); + socket.on('close', () => dropClient(client)); + // The server speaks first, on purpose: a client that has not yet seen proof + // of the token must not volunteer one into whatever bound this path. + send(client, { kind: 'challenge', nonce: client.challenge }); + }); + try { + await listenServer(nextServer, path); + } catch { + // Callback form deliberately: closing a server that never listened emits an + // `'error'` nobody is listening for, which an EventEmitter rethrows and + // would take the extension host down over a lost race. + nextServer.close(() => {}); + return false; + } + server = nextServer; + // Provisional until the caller settles it: a reclaimed bind may still be + // displaced (see {@link brokerConfirmed}). + brokerConfirmed = false; + serverToken = token; + return true; +} + +// ---------------------------------------------------------------- client side + +let client: Socket | null = null; +/** A change this window made while it had no broker to tell; sent on connect. */ +let pendingNotify = false; +/** PTYs this window is streaming to the broker, and how to stop. */ +const forwarding = new Map void>(); + +function respond(frame: PeerLinkResponse): void { + if (client) respondTo(client, frame); +} + +/** + * Answer only the broker socket that issued the work. + * + * A webview fan-out is asynchronous. If its broker disconnects while that work + * is in flight, this window can connect to a replacement before the old answer + * lands. Writing through the module-level `client` then sends broker A's answer + * to broker B, whose request ids start over and may correlate it to unrelated + * work. Socket identity is the generation fence. + */ +function respondTo(socket: Socket, frame: PeerLinkResponse): void { + if (client !== socket || socket.destroyed) return; + socket.write(encodeFrame(frame)); +} + +export function remoteNotifyPeerChange(): void { + // The broker is the destination; its own window was notified directly. An + // unverified bind is not that yet, so the change is held as pending and sent + // if this window turns out to be a client ({@link brokerConfirmed}). + if (isPeerBroker()) return; + if (!client || client.destroyed) { + pendingNotify = true; + return; + } + respond({ kind: 'notify' }); +} + +/** + * Hand one of this window's webview commands to the broker, reporting whether + * there was a broker to hand it to. + * + * Not queued when there is none: a command is a user action with a timeout + * behind it, and holding it until some window binds would answer it long after + * the console call or the dialog that asked gave up. + */ +export function forwardCommand(payload: RemoteHostCommand): boolean { + if (!client || client.destroyed) return false; + respond({ kind: 'command', payload }); + return true; +} + +async function onClientFrame(socket: Socket, frame: unknown): Promise { + if (!isFrameObject(frame)) { + log.error('[peer-link] ignored a broker frame that is not an object'); + return; + } + const request = frame as PeerLinkRequest; + switch (request.kind) { + case 'request': { + let results: unknown[] = []; + try { + results = (await deps?.brokerRequest(request.op, request.params)) ?? []; + } catch (error) { + // One failed webview fan-out contributes no answer. Contain it here: + // this handler is event-driven, so a rejection allowed to escape would + // otherwise be unhandled in the extension host. + log.error(`[peer-link] peer request ${request.op} failed: ${String(error)}`); + } + respondTo(socket, { + kind: 'result', + id: request.id, + results, + }); + break; + } + case 'subscribe': { + if (forwarding.has(request.ptyId)) { + respondTo(socket, { kind: 'subscribed', id: request.id, ptyId: request.ptyId }); + break; + } + if (!deps) break; + const { ptyId } = request; + let exitedWhileSubscribing = false; + const stop = deps.streamPty(ptyId, { + onData: (data) => respondTo(socket, { kind: 'data', ptyId, data }), + onExit: (exitCode) => { + exitedWhileSubscribing = true; + respondTo(socket, { kind: 'exit', ptyId, exitCode }); + // The registry has already dropped this attachment, so the stored + // unsubscribe is spent; what is left is to stop claiming the PTY. + forwarding.delete(ptyId); + }, + }); + // `streamPty` synchronously replays an exit that predates this request. + // Do not install its already-spent unsubscribe after the callback removed + // the forwarding entry: a later resolve must be able to replay again. + if (!exitedWhileSubscribing) forwarding.set(ptyId, stop); + // Ordered after a synchronous exit replay on this same socket. The broker + // cannot settle stream readiness until it has observed that close. + respondTo(socket, { kind: 'subscribed', id: request.id, ptyId }); + break; + } + case 'unsubscribe': + forwarding.get(request.ptyId)?.(); + forwarding.delete(request.ptyId); + break; + case 'write': + deps?.writePty(request.ptyId, request.data); + break; + case 'resizePty': + deps?.resizePty(request.ptyId, request.cols, request.rows); + break; + case 'commandResult': + deps?.deliverCommandResult(request.payload); + break; + case 'uiEvent': + deps?.deliverUiEvent(request.payload); + break; + } +} + +function stopForwarding(): void { + for (const stop of forwarding.values()) stop(); + forwarding.clear(); +} + +/** + * Connect to whoever holds the socket and finish the mutual handshake. + * `'refused'` means the path exists but nothing is listening on it — a broker + * that died without unlinking — which is the caller's cue to clear it and bind. + * + * Between `connect` and a verified `welcome` this window sends exactly one + * frame, its `hello`, and answers nothing: no directory, no PTY stream, no + * command. Until the far end has proved it holds the token it is only a process + * that guessed the path, and the whole point of the ordering is that guessing + * the path is not enough to be served. + */ +function tryConnect(path: string, token: string): Promise<'connected' | 'refused' | 'failed'> { + return new Promise((resolve) => { + const socket = createConnection({ path }); + const decoder = new FrameDecoder(); + /** Ours, so the server's proof is over something it could not choose. */ + const nonce = freshNonce(); + let helloSent = false; + let settled = false; + + const finish = (outcome: 'connected' | 'refused' | 'failed'): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (outcome !== 'connected') socket.destroy(); + resolve(outcome); + }; + const timer = setTimeout(() => finish('failed'), HANDSHAKE_BUDGET_MS); + + const drop = () => { + if (client !== socket) return; + client = null; + stopForwarding(); + // The broker is gone. Every client races for the bind; one wins. + if (!disposed) void contend(); + }; + + const onFrame = (frame: unknown): void => { + // Past the handshake — `client` is only ever assigned below — so this is + // ordinary traffic from a broker that has proved itself. + if (client === socket) { + void onClientFrame(socket, frame).catch((error: unknown) => { + log.error(`[peer-link] broker frame failed: ${String(error)}`); + }); + return; + } + // The two handshake frames, read loosely: nothing here is trusted enough + // yet to be typed as one of them. + if (!isFrameObject(frame)) { + log.error('[peer-link] the process holding the socket sent a non-object handshake frame'); + finish('failed'); + return; + } + const message = frame as { kind?: string; nonce?: unknown; proof?: unknown }; + if (!helloSent) { + if (message.kind !== 'challenge' || typeof message.nonce !== 'string' || !message.nonce) { + log.error('[peer-link] the process holding the socket did not open with a challenge'); + finish('failed'); + return; + } + helloSent = true; + // Answering a challenge proves nothing about the challenger, which is + // why this is all that is sent until the welcome comes back. + socket.write( + encodeFrame({ + kind: 'hello', + nonce, + proof: proveToken(token, PEER_CLIENT_PROOF_DOMAIN, message.nonce), + }), + ); + return; + } + if ( + message.kind !== 'welcome' || + !proofMatches(message.proof, proveToken(token, PEER_SERVER_PROOF_DOMAIN, nonce)) + ) { + // Whatever holds the path cannot prove it holds the token, so it is not + // this installation's broker. Disconnect rather than serve it this + // window's terminals; the contention loop retries and one of the real + // windows ends up binding. + log.error('[peer-link] the process holding the socket could not prove it is our broker'); + finish('failed'); + return; + } + // Proved in both directions: from here it is the broker. + client = socket; + if (pendingNotify) socket.write(encodeFrame({ kind: 'notify' })); + pendingNotify = false; + socket.removeAllListeners('error'); + socket.on('error', drop); + socket.on('close', drop); + log.info('[peer-link] connected to the broker window'); + finish('connected'); + }; + + socket.setEncoding('utf8'); + socket.once('error', (error: NodeJS.ErrnoException) => { + finish(error.code === 'ECONNREFUSED' || error.code === 'ENOENT' ? 'refused' : 'failed'); + }); + // A server that drops us mid-handshake (a bad hello) must settle the attempt + // now rather than wait out the budget. + socket.once('close', () => finish('failed')); + socket.once('connect', () => { + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) onFrame(frame); + }); + }); + }); +} + +function disconnectClient(): void { + stopForwarding(); + client?.destroy(); + client = null; +} + +// ------------------------------------------------------------ the contend loop + +let disposed = false; +let contending = false; +/** + * Latched when there is no safe place to put the socket. Unlike every other + * failure here that is not transient — another user owns the only directory + * these sockets may live in — so the link stands down for good instead of + * spinning against it. + */ +let refused = false; +let nextAttemptAt = 0; +let announceRole: ((broker: boolean) => void) | null = null; +const settleListeners = new Set<() => void>(); + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Join the contention for the Host, reporting `true` exactly once if this + * window wins it. Idempotent; the returned promise resolves as soon as a role + * is settled, so a caller that must know whether to route locally can wait. + * + * There is deliberately no `onRole(false)` after a `true`: a broker is the + * broker for the rest of the process's life. + */ +export function ensurePeerNet(onRole: (broker: boolean) => void): Promise { + announceRole = onRole; + // `isPeerBroker()` rather than `server !== null`: a bind that has not been + // verified yet may still be stood down, and answering `true` for it starts a + // service the stand-down cannot reach ({@link brokerConfirmed}). Unverified + // falls through and waits for the settle, which answers either way. + if (isPeerBroker()) { + onRole(true); + return Promise.resolve(); + } + // No storage location means no socket to contend for, and no amount of + // retrying would produce one; neither would an unsafe socket directory. + if (!context || disposed) return Promise.resolve(); + if (isPeerLinkSettled()) return Promise.resolve(); + const settled = new Promise((resolve) => { + const stop = onPeerLinkSettled(() => { + stop(); + resolve(); + }); + }); + void contend(); + return settled; +} + +/** Whether this window holds the Host — verified, not merely bound. */ +export function isPeerBroker(): boolean { + return server !== null && brokerConfirmed; +} + +/** + * Whether this window has a role right now: it brokers, it is connected to a + * broker, or the link stood down for good. Not a latch — a broker dying takes + * its clients back to unsettled while they race for the bind, which is exactly + * the window in which a command has something to wait for rather than nothing + * to reach (`remote-host.ts`). + */ +export function isPeerLinkSettled(): boolean { + // A destroyed socket is not a role: `close` is a later tick, and until it + // lands and re-contends there is nothing to forward to — which is exactly + // what {@link forwardCommand} reports, so the two must agree. Nor is an + // unverified bind, for the same reason ({@link brokerConfirmed}). + return isPeerBroker() || (client !== null && !client.destroyed) || refused; +} + +/** + * Be told whenever a role settles — the first one and every one after a + * re-contention. Returns the unsubscribe. + */ +export function onPeerLinkSettled(listener: () => void): () => void { + settleListeners.add(listener); + return () => { + settleListeners.delete(listener); + }; +} + +function settle(broker: boolean): void { + // Before the listeners: whoever is waiting on a settle is waiting to route + // somewhere, and a broker has to be serving by the time they do. + if (broker) announceRole?.(true); + for (const listener of [...settleListeners]) listener(); +} + +/** + * One round of arbitration: bind, or connect to whoever bound, or clear the + * corpse a dead broker left behind and bind. Returns whether a role was + * settled — anything else is transient (an unwritable storage dir, a broker + * mid-startup) and the loop retries. + */ +async function attempt(): Promise { + const path = socketPath(); + if (!path) return false; + if (!(await peerDirIsSafe())) { + log.error( + `[peer-link] ${peerDirPath()} is not a private directory of this user; the peer link is off`, + ); + refused = true; + // A role of sorts: this window will never broker and will never reach one, + // so callers waiting on the contention are released rather than left + // hanging on a loop that has stopped. + settle(false); + return true; + } + let token: string; + try { + token = await ensureToken(); + } catch (error) { + // The same shape as the unsafe-directory branch above, for the same reason: + // an unwritable `globalStorageUri` is not a transient failure, and retrying + // at 1 Hz forever leaves every command waiting out its whole queue budget + // on every attempt rather than being told there is nothing to reach. + log.error( + `[peer-link] could not read or create the shared token; the peer link is off: ${String(error)}`, + ); + refused = true; + settle(false); + return true; + } + + if (await tryBind(path, token)) { + // Disposal can land inside any of the awaits above; a socket bound after it + // would outlive the window that owns it. + if (disposed) { + await closeServer(true); + return true; + } + log.info('[peer-link] serving peers'); + // Nothing can displace an uncontested bind, so it is believed immediately. + brokerConfirmed = true; + settle(true); + return true; + } + + let outcome = await tryConnect(path, token); + if (outcome === 'refused') { + // The path exists but nothing answers: a broker that died without running + // its disposables. + // + // Every client of that broker reaches this line at the same instant, so the + // unlink is jittered — otherwise they clear the corpse in lockstep, several + // bind, and all but one end up serving a socket nobody can reach. + await delay(Math.floor(Math.random() * RECLAIM_JITTER_MS)); + // And one of them may have rebound it while we waited. Unlinking a live + // broker's socket would strand every window dialing it, so ask again: a + // second refusal is what makes the unlink below safe. + outcome = await tryConnect(path, token); + if (outcome === 'refused') { + await rm(path, { force: true }).catch(() => {}); + if (await tryBind(path, token)) { + if (disposed) { + await closeServer(true); + return true; + } + if (await stillOurs(path)) { + log.info('[peer-link] took over a socket its broker left behind'); + // Only now: until the verification returns, this window may still be + // the one that stands down ({@link brokerConfirmed}). + brokerConfirmed = true; + settle(true); + return true; + } + // Another window cleared the same corpse and bound after us, so the + // path now names its socket and ours is unreachable. Stand down rather + // than run a second Host: `bind` is only the arbiter when nobody + // unlinks. The loop's next round finds that window and connects. + await closeServer(false); + } + return false; + } + } + if (outcome === 'connected') { + // Same as the bind above: a connection opened after disposal has nobody + // left to close it. + if (disposed) disconnectClient(); + else settle(false); + return true; + } + return false; +} + +/** How long to let a competing reclaim land before believing we won it. */ +const RECLAIM_VERIFY_MS = 250; +/** Spread over which a stampede of orphaned clients clears one corpse. */ +const RECLAIM_JITTER_MS = 250; + +/** + * Whether the socket path still names the filesystem object we just bound. + * + * Two windows can find the same corpse and both unlink it, and the second bind + * silently displaces the first — the loser keeps serving a socket no client can + * reach. Nothing on the bind path detects that, so it is checked afterwards. + * Inode alone is not an identity: Linux may immediately recycle the corpse's + * inode for a replacement socket. The inode's change timestamp distinguishes + * those generations, while the device keeps the tuple complete. + * + * A path that has *gone* is the same failure on unix: somebody unlinked it after + * our bind, so every window dialing it will miss us. Only Windows may read that + * as ours — named pipes are not filesystem objects, cannot be stat-ed, and die + * with the process that made them, so nothing there can displace us. + */ +interface SocketFileIdentity { + dev: bigint; + ino: bigint; + ctimeNs: bigint; +} + +async function socketFileIdentity(path: string): Promise { + const value = await stat(path, { bigint: true }).catch(() => null); + return value + ? { dev: value.dev, ino: value.ino, ctimeNs: value.ctimeNs } + : null; +} + +function sameSocketFile(left: SocketFileIdentity, right: SocketFileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.ctimeNs === right.ctimeNs; +} + +async function stillOurs(path: string): Promise { + const unstattable = process.platform === 'win32'; + const mine = await socketFileIdentity(path); + if (!mine) return unstattable; + await delay(RECLAIM_VERIFY_MS); + const now = await socketFileIdentity(path); + if (!now) return unstattable; + return sameSocketFile(now, mine); +} + +async function contend(): Promise { + if (contending || disposed) return; + contending = true; + try { + while (!disposed && !refused && !server && !client) { + const wait = nextAttemptAt - Date.now(); + if (wait > 0) await delay(wait); + // Spaced rather than immediate on repeat: a broker that refuses this + // window's hello would otherwise turn reconnection into a spin. + nextAttemptAt = Date.now() + RETRY_MS; + try { + if (await attempt()) return; + } catch (err) { + // Started fire-and-forget, so a rejection here would surface as an + // unhandled one rather than as a link that keeps trying. + log.error(`[peer-link] contention attempt failed: ${String(err)}`); + } + } + } finally { + contending = false; + } +} + +/** + * Give up this window's server. Unlink only when the path still names our + * socket: removing a winner's would strand every client dialing it. + */ +async function closeServer(unlink: boolean): Promise { + const closing = server; + server = null; + brokerConfirmed = false; + serverToken = null; + for (const peer of [...clients]) dropClient(peer); + if (!closing) return; + if (closing.listening) closing.close(); + if (!unlink) return; + const path = socketPath(); + if (path) await rm(path, { force: true }).catch(() => {}); +} + +export async function disposePeerLink(): Promise { + disposed = true; + disconnectClient(); + await closeServer(true); +} diff --git a/vscode-ext/src/processed-pty-streams.ts b/vscode-ext/src/processed-pty-streams.ts new file mode 100644 index 00000000..84c124f0 --- /dev/null +++ b/vscode-ext/src/processed-pty-streams.ts @@ -0,0 +1,119 @@ +/** + * One keyed registry of this window's own PTY streams, shared by everything that + * wants one: the Host provider serving a phone (`remote-host.ts`) and the peer + * link forwarding a terminal to the broker window (`peer-link.ts`). + * + * Keyed rather than one listener pair per subscriber, because these run on every + * chunk of every terminal in the window: a pair per attachment would tax every + * keystroke of every PTY once per attached surface, and the two callers would + * each pay it separately. One pair goes in at the first subscription and comes + * out when the last one goes, so a window with nothing attached pays nothing. + * + * No strip parser here, unlike the sidecar: this process already runs the + * terminal-protocol parser once per chunk and answers its queries, and + * `onProcessedPtyData` is what comes out the other side. A second parser would + * answer every query twice and corrupt the PTY. + */ + +import type { PtySink } from '../../lib/src/remote/host/host-surface-provider'; + +export type { PtySink }; + +export interface ProcessedPtyStreams { + /** + * Watch one PTY of this window's; returns the unsubscribe. An `exit` tears + * every sink on that id down on its own, so the unsubscribe afterwards is a + * no-op rather than an error. + */ + streamPty(ptyId: string, sink: PtySink): () => void; +} + +export interface PtyStatus { + alive: boolean; + exitCode?: number; +} + +export function createProcessedPtyStreams( + onProcessedPtyData: (listener: (id: string, data: string) => void) => () => void, + onProcessedPtyExit: (listener: (id: string, exitCode: number) => void) => () => void, + getPtyStatus: (id: string) => PtyStatus | undefined, +): ProcessedPtyStreams { + const streams = new Map>(); + let stopListeners: (() => void) | null = null; + + /** Back to costing this window's terminals nothing once nothing is attached. */ + const uninstallIfIdle = (): void => { + if (streams.size > 0 || !stopListeners) return; + stopListeners(); + stopListeners = null; + }; + + const install = (): void => { + if (stopListeners) return; + const offData = onProcessedPtyData((id, data) => { + const targets = streams.get(id); + if (!targets) return; + // Iterated live rather than copied: a sink can only unsubscribe itself + // from here, which a Set tolerates mid-iteration. + for (const target of targets) target.onData(data); + }); + const offExit = onProcessedPtyExit((id, exitCode) => { + const targets = streams.get(id); + if (!targets) return; + // Dropped before the fan-out, so a sink that unsubscribes from inside its + // own `onExit` finds nothing left to take out — and so a re-subscribe + // during the fan-out keeps the listener pair rather than losing it below. + streams.delete(id); + for (const target of targets) target.onExit(exitCode); + uninstallIfIdle(); + }); + stopListeners = () => { + offData(); + offExit(); + }; + }; + + return { + streamPty(ptyId, sink) { + let sinks = streams.get(ptyId); + if (!sinks) { + sinks = new Set(); + streams.set(ptyId, sinks); + } + const subscribed = sinks; + subscribed.add(sink); + install(); + + const unsubscribe = () => { + // Only if the map still holds the very set this subscription joined: an + // exit replaces nothing but does remove it, and a later attachment to + // the same id gets a fresh one that this unsubscribe has no claim on. + if (streams.get(ptyId) !== subscribed) return; + subscribed.delete(sink); + if (subscribed.size > 0) return; + streams.delete(ptyId); + uninstallIfIdle(); + }; + + // Install first, then inspect the host's durable liveness record. If the + // exit happened before installation the record closes the gap; if it + // happens after the inspection, the listener above receives it. These + // synchronous steps cannot interleave on the extension-host event loop. + // Missing means the manager has no live generation under this id, which + // is also dead from a resolved pane's point of view. + let status: PtyStatus | undefined; + try { + status = getPtyStatus(ptyId); + } catch (error) { + unsubscribe(); + throw error; + } + if (status?.alive !== true) { + unsubscribe(); + sink.onExit(status?.exitCode ?? 0); + } + + return unsubscribe; + }, + }; +} diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index c7f6bb8b..85a759e8 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -99,6 +99,27 @@ export function getBufferedPtys(): Map | null = null; + #watch: vscode.Disposable | undefined; + /** + * Preserve call order across asynchronous keychain/Memento writes. The Host + * updates its ACL from snapshots, so letting two approvals write together + * could allow the older snapshot to finish last and silently de-pair the + * newer Client after a restart. + */ + readonly #mutate = createSerialQueue(); + + /** + * @param onEnrollmentChanged Some window of this extension wrote or cleared + * the enrollment. Fires after the memo is dropped, so a reader called from it + * sees the new value. + */ + constructor(context: vscode.ExtensionContext, onEnrollmentChanged?: () => void) { + this.#context = context; + // Cross-window invalidation. `SecretStorage` is shared by every window of + // an extension and `onDidChange` fires in all of them, so without this a + // window that read the enrollment once could keep serving a Host another + // window cleared — or miss one another window created. + this.#watch = context.secrets.onDidChange?.((event) => { + if (event.key !== ENROLLMENT_KEY) return; + this.#enrollment = null; + onEnrollmentChanged?.(); + }); + } + + /** Stop listening; the store is otherwise stateless and can be dropped. */ + dispose(): void { + this.#watch?.dispose(); + this.#watch = undefined; + } + + async loadEnrollment(): Promise { + // Read once and keep it, like `FileHostStateStore`: `SecretStorage` is a + // keychain round trip, and the activation probe and the service both want + // the same answer. The memo is only safe because a write from any window + // invalidates it — see the constructor. + // + // A read that *failed* says nothing about what the keychain holds, so it is + // forgotten rather than memoized (the same fail-closed guard as + // `FileHostStateStore#read`). A locked or keyring-less keychain rejects + // here, and a memoized rejection would leave an enrolled window silently + // Host-less for its whole life — `onDidChange` only fires on a write, so + // nothing else would ever retry the read. + this.#enrollment ??= this.#readEnrollment().catch((error: unknown) => { + this.#enrollment = null; + throw error; + }); + return this.#enrollment; + } + + saveEnrollment(enrollment: HostEnrollment): Promise { + return this.#mutate(async () => { + await this.#context.secrets.store(ENROLLMENT_KEY, JSON.stringify(enrollment)); + this.#enrollment = Promise.resolve(enrollment); + }); + } + + clearEnrollment(): Promise { + return this.#mutate(async () => { + await this.#context.secrets.delete(ENROLLMENT_KEY); + this.#enrollment = Promise.resolve(null); + }); + } + + async #readEnrollment(): Promise { + const raw = await this.#context.secrets.get(ENROLLMENT_KEY); + if (raw === undefined) return null; + try { + const parsed: unknown = JSON.parse(raw); + return isEnrollment(parsed) ? parsed : null; + } catch { + // A keychain entry we cannot parse is the same as none: the Host idles + // rather than connecting with half an enrollment. + return null; + } + } + + async loadAcl(hostId: string): Promise { + const raw = this.#context.globalState.get(aclKey(hostId)); + if (typeof raw !== 'string') return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return filterAclRecords(hostId, parsed); + } + + saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { + return this.#mutate(() => + this.#context.globalState.update(aclKey(hostId), JSON.stringify(records)), + ); + } +} + +/** Keyed per host so a re-enrollment cannot inherit a stale ACL. */ +function aclKey(hostId: string): string { + return `${ACL_KEY_PREFIX}${hostId}`; +} diff --git a/vscode-ext/src/remote-host.ts b/vscode-ext/src/remote-host.ts new file mode 100644 index 00000000..1f56f2c6 --- /dev/null +++ b/vscode-ext/src/remote-host.ts @@ -0,0 +1,543 @@ +/** + * The VS Code extension host's binding of {@link RemoteHostService}. + * + * The extension host owns the PTYs, so the Host lives here rather than in a + * webview: the relay socket, the enrollment, the ACL, and the pairing ceremony + * are all outside any webview realm, and a webview can only answer what its own + * panes are called and how big they are (docs/specs/remote-security-model.md). + * + * One extension host runs per window, so exactly one window may hold it. That + * arbitration is `peer-link.ts`'s bind-as-lease; this module starts the service + * only in the window that won. A losing window runs no service at all: its + * webviews' commands are forwarded over the link and the broker's answers come + * back the same way, so the Host behaves identically in every window while + * existing in exactly one. + * + * Nothing here runs until there is a Host to run: contention starts when an + * enrollment already exists, or on the first `enroll` command. A user who never + * enrolls never sees a socket. + */ + +import type * as vscode from 'vscode'; + +import { + createAskSurfaceProvider, + type AskSurfaceProvider, +} from '../../lib/src/host/remote/ask-surface-provider'; +import { bakedConnectSrc } from '../../lib/src/host/remote/connect-src'; +import { REMOTE_HOST_COMMAND_TIMEOUT_MS } from '../../lib/src/host/remote/link-client'; +import { RemoteHostService } from '../../lib/src/host/remote/service'; +import { + REMOTE_HOST_EVENT_EVENT, + REMOTE_HOST_RESULT_EVENT, + isRemoteHostCommand, + type PairingQueueItem, + type PushDevicesResult, + type RemoteHostCommand, + type RemoteHostConsoleStatus, + type RemoteHostResult, +} from '../../lib/src/host/remote/service-protocol'; +import type { HostSurfaceProvider } from '../../lib/src/remote/host/host-surface-provider'; +import type { + PeerSurfaceParams, + PeerSurfaceResult, +} from '../../lib/src/remote/host/peer-surfaces'; +import type { WebSocketLike } from '../../lib/src/remote/host/remote-host'; +import type { ExtensionMessage } from './message-types'; +import { + broadcastUiEvent, + ensurePeerNet, + forwardCommand, + isPeerLinkSettled, + isRemotePtyHandle, + onPeerLinkSettled, + remoteRequest, + remoteResize, + remoteSubscribe, + remoteUnsubscribe, + remoteWrite, + sendCommandResult, + sendUiEvent, + type PeerLinkClient, +} from './peer-link'; +import type { PtySink } from './processed-pty-streams'; +import { VsCodeHostStateStore } from './remote-host-store'; +import { log } from './log'; + +/** + * What this module needs from the router, injected rather than imported: the + * router routes commands here, so importing back would be a cycle. + */ +export interface RemoteHostDeps { + /** Fan one question out to this window's webviews and collect the answers. */ + brokerRequest(op: string, params: unknown): Promise; + /** Post to every live webview in this window. */ + broadcastToWebviews(message: ExtensionMessage): void; + writePty(ptyId: string, data: string): void; + resizePty(ptyId: string, cols: number, rows: number): void; + /** + * Watch one PTY this window owns, through the window's shared keyed registry + * (`processed-pty-streams.ts`) rather than a listener pair per attachment. + */ + streamPty(ptyId: string, sink: PtySink): () => void; +} + +let deps: RemoteHostDeps | null = null; + +export function configureRemoteHost(next: RemoteHostDeps): void { + deps = next; +} + +let context: vscode.ExtensionContext | null = null; +/** + * One store for the window: `SecretStorage` is a keychain round trip, and the + * activation probe and the service would otherwise each pay for their own. + */ +let store: VsCodeHostStateStore | null = null; +let service: RemoteHostService | null = null; +let askProvider: AskSurfaceProvider | null = null; + +/** + * Ask both tiers at once and concatenate what they answer, this window's + * webviews first. A follow-up carrying an owner key goes only to the tier (and, + * for a peer handle, the exact window) selected during resolution. + * + * Both at once rather than the near tier first: whatever is asked about lives + * in exactly one webview of one window, so asking in series would spend a whole + * tier's budget before the window that actually owns it is even asked. The + * results carry no tier marker because nothing downstream needs one — a + * directory is a concatenation, and the first surface owner is retained by its + * provider-local PTY key. + */ +async function askBothTiers( + bound: RemoteHostDeps, + op: string, + params: unknown, + ownerPtyId?: string, +): Promise { + // A mutating attach cannot itself discover its owner: duplicated cold-restored + // windows may both answer the same surface id, which would resize both xterms + // before the first result was selected. Probe identity read-only, then send + // the attach only to the tier/window carried by that provider-local PTY key. + const surfaceParams = params as Partial | null; + if (!ownerPtyId && op === 'surfaceOp' && surfaceParams?.op === 'attach') { + const [owner] = (await askBothTiers(bound, op, { + ...surfaceParams, + op: 'resolve', + })) as PeerSurfaceResult[]; + return owner ? askBothTiers(bound, op, params, owner.ptyId) : []; + } + if (ownerPtyId) { + return isRemotePtyHandle(ownerPtyId) + ? remoteRequest(op, params, ownerPtyId) + : bound.brokerRequest(op, params); + } + const [local, remote] = await Promise.all([ + bound.brokerRequest(op, params), + remoteRequest(op, params), + ]); + return [...local, ...remote]; +} + +/** + * Build the provider the service serves remote-api v1 through. + * + * PTYs owned by this window are answered locally — this process owns them — + * while everything about the *view* of them is asked of the webviews, because a + * window's terminals are spread across however many Dormouse views are open and + * only they hold an xterm registry. Every one of those questions also goes to + * the other windows over the link, so the phone sees one directory of every + * terminal on the machine rather than the broker window's alone. + */ +export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProvider { + askProvider = createAskSurfaceProvider( + (op, params, ownerPtyId) => askBothTiers(bound, op, params, ownerPtyId), + { + // A peer-returned provider handle stays in the link's namespace even + // after its route closes; it must never fall through to a local PTY that + // later happens to claim the same string. + writePty: (ptyId, data) => { + if (isRemotePtyHandle(ptyId)) { + remoteWrite(ptyId, data); + return; + } + bound.writePty(ptyId, data); + }, + resizePty: (ptyId, cols, rows) => { + if (isRemotePtyHandle(ptyId)) { + remoteResize(ptyId, cols, rows); + return; + } + bound.resizePty(ptyId, cols, rows); + }, + + streamPty(ptyId, sink) { + if (isRemotePtyHandle(ptyId)) { + // Another window's terminal: it has already stripped the protocol out + // on its side, so what arrives over the link is what its own xterm + // renders — the same stream shape as the local branch below. + const ready = remoteSubscribe(ptyId, sink); + return { + stop: () => remoteUnsubscribe(ptyId, sink), + ready, + }; + } + // One of this window's own, through the keyed registry every consumer of + // the processed stream shares (`processed-pty-streams.ts`). + return { + stop: bound.streamPty(ptyId, sink), + ready: Promise.resolve(), + }; + }, + }, + ); + return askProvider.provider; +} + +/** + * Something a future directory answer could depend on changed: a pane, an + * alert, a webview, a peer window. + */ +export function notifyDirectoryChanged(): void { + askProvider?.notifyDirectoryChanged(); +} + +/** + * The relay socket, preferring whatever this extension host already provides. + * + * `globalThis.WebSocket` only landed in Node 22, and `engines.vscode` here is + * `^1.85.0` — VS Code 1.85 shipped Electron 25 / Node 18, and the supported + * range spans the boundary — so on an older host there is no global to use and + * the bundled `ws` is the only implementation. Its socket satisfies the same + * surface `RemoteHost` reads and nothing more: `send`, `close`, `readyState`, + * `addEventListener`, with `message` events carrying `.data` and `close` events + * carrying `.code`. + * + * `ws`'s optional native accelerators (`bufferutil`, `utf-8-validate`) are + * deliberately left unbundled and unshipped; `ws` falls back to its JS paths. + */ +export function createRelaySocket(url: string): WebSocketLike { + const Impl = globalThis.WebSocket ?? (require('ws') as typeof import('ws')).WebSocket; + return new Impl(url) as unknown as WebSocketLike; +} + +function startService(): void { + if (service || !context || !deps) return; + const bound = deps; + service = new RemoteHostService({ + store: hostStateStore(context), + provider: createRemoteHostProvider(bound), + createWebSocket: createRelaySocket, + sendToUi: (event, data) => { + if (event === REMOTE_HOST_RESULT_EVENT) { + answer(data as RemoteHostResult); + } else if (event === REMOTE_HOST_EVENT_EVENT) { + // Every window, not just this one: the pairing modal can be answered + // from whichever webview the user happens to be looking at, and only + // the windows that see the queue can show one. + bound.broadcastToWebviews({ type: 'remoteHost:event', payload: data }); + broadcastUiEvent(data); + } + }, + connectSrc: bakedConnectSrc(), + }); + void service.start().catch((error: unknown) => { + log.error(`[remote-host] failed to start: ${String(error)}`); + }); +} + +/** + * Whether this window has joined the contention at all. Until it has there is + * no role coming and nothing for a command to wait for, so + * {@link handleRemoteHostCommand} refuses rather than holds — which is the + * honest answer on a machine that never enrolled. + */ +let contending = false; + +/** + * Join the contention for the Host and start serving if this window wins it. + * Idempotent. + */ +function contendForHost(): void { + contending = true; + // Drained on the settle *and* on a contention that can never settle — no + // storage location, or a link already disposed — because neither of those + // sends a settle notification. A held command (an `enroll` included) would + // otherwise wait out its whole budget for a role that is not coming. + void ensurePeerNet((broker) => { + if (broker) startService(); + }).then(drainQueuedCommands, drainQueuedCommands); +} + +/** + * Every settle drains, not just the first: a broker window closing sends every + * survivor back into the contention, and the second or third role this window + * takes has to pick up whatever arrived during that race. + */ +onPeerLinkSettled(() => drainQueuedCommands()); + +/** + * Which window is owed each in-flight answer, for the commands that came over + * the link. An `rhId` is minted with a per-adapter random tag, so it is unique + * across every window and needs no second correlation id of its own. + * + * Only the broker ever has entries: a client window forwards rather than runs. + */ +const commandRoutes = new Map(); + +const NO_HOST = 'no remote Host is reachable'; + +/** + * Deliver one result to whoever is owed it — the one window that forwarded the + * command, or this window's webviews when nothing forwarded it. + * + * A result is never sent both ways. `rhId`s are globally unique, so a broadcast + * of another window's answer would settle nothing anywhere and would put that + * window's Host state in front of webviews that never asked. + */ +function answer(payload: RemoteHostResult): void { + const from = commandRoutes.get(payload.rhId); + if (from) { + commandRoutes.delete(payload.rhId); + sendCommandResult(from, payload); + return; + } + deps?.broadcastToWebviews({ type: 'remoteHost:result', payload }); +} + +/** + * Commands that arrived while the contention was still running, oldest first. + * + * Bounded, because a console hook or a dialog can keep asking and a contention + * that never settles must not grow this without limit. Each carries its own + * deadline, derived from the asking adapter's rather than picked: a command the + * settle never drains has to be refused *before* that adapter gives up, or the + * webview sees a bare timeout where it could have seen a reason. + */ +const queued: Array<{ payload: RemoteHostCommand; timer: ReturnType }> = []; +const QUEUE_LIMIT = 12; +const QUEUE_BUDGET_MS = REMOTE_HOST_COMMAND_TIMEOUT_MS - 1_000; + +function enqueueCommand(payload: RemoteHostCommand): void { + // At the limit the oldest goes: its asker has waited longest and is nearest + // to timing out anyway, so a reason reaches it while it can still be read. + if (queued.length >= QUEUE_LIMIT) dropQueued(queued[0]!.payload.rhId); + const timer = setTimeout(() => dropQueued(payload.rhId), QUEUE_BUDGET_MS); + queued.push({ payload, timer }); +} + +/** Take one command out of the queue and refuse it. */ +function dropQueued(rhId: string): void { + const index = queued.findIndex((entry) => entry.payload.rhId === rhId); + if (index === -1) return; + clearTimeout(queued[index]!.timer); + queued.splice(index, 1); + refuse(rhId); +} + +/** A role settled: every held command now has somewhere to go. */ +function drainQueuedCommands(): void { + const pending = queued.splice(0); + for (const { payload, timer } of pending) { + clearTimeout(timer); + if (service) void service.handleCommand(payload); + else if (!forwardCommand(payload)) refuseCommand(payload); + } +} + +/** + * Hand one of this window's webview commands to the Host. + * + * The broker runs it; every other window forwards it over the link and gets the + * broker's answer back as a `remoteHost:result` like any other. + * + * One rule covers the rest: while this window is contending and unsettled it + * has neither, so the command is held and drained on the next settle rather + * than refused. That is the state at activation, when the contention costs a + * bind and a handshake, *and* the second or two after a broker window closes + * and every survivor races for the socket — and refusing in either would tell + * an enrolled machine's webview it has no Host moments before it gets one, + * leaving the gates that arm on that answer down. + * + * `enroll` is the one command that may start the contention: it is how an + * installation with no Host at all bootstraps. Everything else refuses only + * where there is genuinely nothing to reach — never contending, or settled with + * no service and no broker. + */ +export function handleRemoteHostCommand(payload: RemoteHostCommand | undefined): void { + if (!isRemoteHostCommand(payload)) return; + if (service) { + void service.handleCommand(payload); + return; + } + if (forwardCommand(payload)) return; + if (payload.cmd === 'enroll') { + // Held rather than run inline once the contention settles: if some other + // window enrolled first, this window is a client and the command belongs + // on the link, which is exactly what the drain does. + enqueueCommand(payload); + contendForHost(); + return; + } + if (contending && !isPeerLinkSettled()) { + enqueueCommand(payload); + return; + } + refuseCommand(payload); +} + +/** + * Run a command another window forwarded, and remember to answer it there. + * + * The route is dropped by {@link dropForwardedCommands} if that window + * disconnects first, which leaves the command unanswered on purpose: the socket + * that would carry the answer is gone, and the asking adapter's own timeout is + * the backstop. + */ +export function handleForwardedCommand( + payload: RemoteHostCommand | undefined, + from: PeerLinkClient, +): void { + if (!isRemoteHostCommand(payload)) return; + commandRoutes.set(payload.rhId, from); + // Only a window that bound the socket is sent one of these, and binding is + // what starts the service — but if there is somehow none, say so rather than + // leave the asking webview to wait out its timeout. + if (service) void service.handleCommand(payload); + else answer({ rhId: payload.rhId, error: NO_HOST }); +} + +/** That window is gone; its outstanding commands can never be answered. */ +export function dropForwardedCommands(from: PeerLinkClient): void { + for (const [rhId, owner] of commandRoutes) { + if (owner === from) commandRoutes.delete(rhId); + } +} + +/** The broker answered a command this window forwarded. */ +export function deliverCommandResult(payload: RemoteHostResult): void { + deps?.broadcastToWebviews({ type: 'remoteHost:result', payload }); +} + +/** A Host UI event from the broker, for this window's webviews. */ +export function deliverUiEvent(payload: unknown): void { + deps?.broadcastToWebviews({ type: 'remoteHost:event', payload }); +} + +/** + * A window just joined this broker. Hand it the Host state its webviews gate + * themselves on. + * + * Without this a window that opened after the enrollment is told nothing: + * `status` events are emitted on change, and nothing about the Host changes + * because a window connected. Its webviews would sit disarmed — announcing no + * directory changes, watching for no rings — until the user reloaded the whole + * window (`lib/src/remote/host/enrolled-gate.ts`). + */ +export function greetPeerWindow(client: PeerLinkClient): void { + if (!service) return; + sendUiEvent(client, service.statusEvent()); +} + +function refuse(rhId: string): void { + deps?.broadcastToWebviews({ type: 'remoteHost:result', payload: { rhId, error: NO_HOST } }); +} + +/** + * What an idle service answers, for the read-only commands a window with no + * Host at all is still asked. + * + * Reaching the refusal below means this window sees no enrollment — it contends + * at activation when there is one, and again the moment another window writes + * one (`hostStateStore`) — so "there is no Host" is the ordinary un-enrolled + * state, not a failure. Erroring for it broke the contract each caller reads: + * `pushDevices` answers `null` for "nowhere to push" and a rejection for "the + * server could not be asked", so the Settings dialog was reporting an + * unreachable server on a machine that had simply never enrolled + * (`lib/src/lib/push-devices.ts`), and `enrolled-gate.ts` seeds from `status`. + * The sidecar has no such path — it always has a service — so these are exactly + * what one with no enrollment returns (`lib/src/host/remote/service.ts`). + */ +function idleAnswer(cmd: string): { result: unknown } | null { + switch (cmd) { + case 'status': + return { + result: { + enrolled: false, + serverUrl: null, + hostId: null, + connection: 'stopped', + pairedClients: 0, + } satisfies RemoteHostConsoleStatus, + }; + case 'pushDevices': + return { result: null satisfies PushDevicesResult }; + case 'pairingQueue': + return { result: [] satisfies PairingQueueItem[] }; + default: + return null; + } +} + +/** Refuse one command — or answer it as an idle service would ({@link idleAnswer}). */ +function refuseCommand(payload: RemoteHostCommand): void { + const idle = idleAnswer(payload.cmd); + if (!idle) { + refuse(payload.rhId); + return; + } + deps?.broadcastToWebviews({ + type: 'remoteHost:result', + payload: { rhId: payload.rhId, result: idle.result }, + }); +} + +/** + * Give the Host its storage and start it if this installation is already + * enrolled. Nothing contends for the socket otherwise — see the module header. + */ +export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable { + context = ctx; + void contendIfEnrolled(ctx); + + return { + dispose() { + service?.dispose(); + service = null; + askProvider = null; + contending = false; + commandRoutes.clear(); + for (const { timer } of queued.splice(0)) clearTimeout(timer); + store?.dispose(); + store = null; + context = null; + }, + }; +} + +function contendIfEnrolled(ctx: vscode.ExtensionContext): Promise { + return hostStateStore(ctx) + .loadEnrollment() + .then((enrollment) => { + if (enrollment) contendForHost(); + }) + .catch((error: unknown) => { + log.error(`[remote-host] could not read the enrollment: ${String(error)}`); + }); +} + +/** + * The window's one store, made on first use. + * + * It reports enrollment writes from *any* window of this extension, which is + * the only signal a window that was un-enrolled at activation ever gets: it + * never contended, so it has no socket and no broker to hear from. Re-checking + * here is what lets a second window join the Host a first one just enrolled, + * without a reload. + */ +function hostStateStore(ctx: vscode.ExtensionContext): VsCodeHostStateStore { + store ??= new VsCodeHostStateStore(ctx, () => { + void contendIfEnrolled(ctx); + }); + return store; +} diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index 6134cabd..844c7b3d 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; + import * as path from 'path'; import * as fs from 'fs'; + import { randomBytes } from 'crypto'; import { HOST_MESSAGE_TOKEN_GLOBAL } from '../../lib/src/lib/vscode-message-token'; import { RECOVERY_COMMANDS_GLOBAL } from '../../lib/src/lib/vscode-recovery-global'; @@ -50,7 +52,9 @@ export function getWebviewHtml( `font-src ${webview.cspSource}`, `img-src ${webview.cspSource} data: blob:`, // ws: entries cover the agent-browser stream relay (frames + input for - // browser surfaces; see docs/specs/dor-browser.md). + // browser surfaces; see docs/specs/dor-browser.md). No relay origin here: + // the remote Host holds its `/ws/host` socket from the extension host, so + // the origin allowlist is enforced there instead (remote-host.ts). `connect-src ${webview.cspSource} ws://127.0.0.1:* ws://localhost:*`, // `dor iframe` frames its target through a loopback transparent proxy that // the extension host stands up (iframe-proxy-host.ts), so the only origin we diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts new file mode 100644 index 00000000..b4f28563 --- /dev/null +++ b/vscode-ext/test/helpers.ts @@ -0,0 +1,173 @@ +/** + * Shared scaffolding for the extension-host suites. Both of them need a + * throwaway `globalStorageUri`, a poll-with-deadline, and a way to make one + * process behave like two VS Code windows. + */ + +import { vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { + RemoteHostCommand, + RemoteHostResult, +} from '../../lib/src/host/remote/service-protocol'; +import type { PeerLinkClient, PeerLinkDeps } from '../src/peer-link'; +import { createProcessedPtyStreams } from '../src/processed-pty-streams'; + +export async function tempStorageDir(): Promise { + return mkdtemp(join(tmpdir(), 'dormouse-ext-')); +} + +/** + * The one path every window of an installation contends for, mirroring + * `socketPath()` and `peerDirPath()`. Duplicated on purpose: a derivation that + * drifted would silently give each window its own lease and its own Host, and + * the per-user directory is the layer that keeps another OS user from creating + * the path first. + */ +export function derivedSocketPath(storageDir: string): string { + const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); + return join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); +} + +export async function removeDir(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); +} + +export async function waitFor( + predicate: () => boolean | Promise, + budgetMs = 5_000, +): Promise { + const deadline = Date.now() + budgetMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('timed out waiting for a condition'); +} + +export function waitForFile(path: string, budgetMs?: number): Promise { + return waitFor(async () => { + try { + await access(path); + return true; + } catch { + return false; + } + }, budgetMs); +} + +/** A pause long enough for an in-process socket round trip to land. */ +export function tick(ms = 50): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * A fresh copy of a module, so one process can play several windows: the + * extension-host modules hold their state at module scope, exactly as a real + * extension host does. + */ +export async function freshModule(loader: () => Promise): Promise { + vi.resetModules(); + return loader(); +} + +/** The context shape these modules read: a storage location and disposables. */ +export function fakeContext(dir: string): never { + return { globalStorageUri: { fsPath: dir }, subscriptions: [] } as never; +} + +/** + * One window as the link sees it: what its webviews would answer, what it was + * asked to do to its own terminals, and what the broker sent it. Shared by both + * suites, which each need a window on the far end of a real socket. + */ +export function fakeWindow( + options: { + entries?: unknown[]; + surfaces?: Record; + /** PTY ids this window's own manager holds — what `ownsPty` answers. */ + ownPtyIds?: string[]; + } = {}, +) { + const dataListeners = new Set<(id: string, data: string) => void>(); + const exitListeners = new Set<(id: string, exitCode: number) => void>(); + const ptyStatuses = new Map(); + const streams = createProcessedPtyStreams( + (listener) => { + dataListeners.add(listener); + return () => void dataListeners.delete(listener); + }, + (listener) => { + exitListeners.add(listener); + return () => void exitListeners.delete(listener); + }, + (id) => ptyStatuses.get(id) ?? { alive: true }, + ); + return { + entries: options.entries ?? [], + surfaces: options.surfaces ?? {}, + ownPtyIds: new Set(options.ownPtyIds ?? []), + writes: [] as Array<{ ptyId: string; data: string }>, + resizes: [] as Array<{ ptyId: string; cols: number; rows: number }>, + invalidations: 0, + /** Commands this window was asked to run for another one, and who asked. */ + forwarded: [] as Array<{ payload: RemoteHostCommand; from: PeerLinkClient }>, + /** Windows whose sockets closed with commands still outstanding. */ + dropped: [] as PeerLinkClient[], + /** What came back for commands this window forwarded to its broker. */ + results: [] as RemoteHostResult[], + uiEvents: [] as unknown[], + /** Windows that finished the handshake with this one as the broker. */ + joined: [] as PeerLinkClient[], + emitData(id: string, data: string) { + ptyStatuses.set(id, { alive: true }); + for (const listener of dataListeners) listener(id, data); + }, + emitExit(id: string, exitCode: number) { + ptyStatuses.set(id, { alive: false, exitCode }); + for (const listener of exitListeners) listener(id, exitCode); + }, + deps(): PeerLinkDeps { + return { + // One generic fan-out covers every peer operation; `op` is opaque to + // the link, so the window answers zero or more results per request. + brokerRequest: async (op, params) => { + if (op === 'directory') return this.entries; + const { surfaceId } = params as { surfaceId: string }; + const surface = this.surfaces[surfaceId]; + return surface ? [surface] : []; + }, + invalidateDirectory: () => { + this.invalidations += 1; + }, + ownsPty: (ptyId) => this.ownPtyIds.has(ptyId), + streamPty: streams.streamPty, + writePty: (ptyId, data) => void this.writes.push({ ptyId, data }), + resizePty: (ptyId, cols, rows) => void this.resizes.push({ ptyId, cols, rows }), + handleForwardedCommand: (payload, from) => void this.forwarded.push({ payload, from }), + dropForwardedCommands: (from) => void this.dropped.push(from), + deliverCommandResult: (payload) => void this.results.push(payload), + deliverUiEvent: (payload) => void this.uiEvents.push(payload), + onClientAuthenticated: (client) => void this.joined.push(client), + }; + }, + }; +} + +/** A sink standing in for whatever a routed PTY is streamed into. */ +export function fakeSink() { + return { + data: [] as string[], + exits: [] as number[], + onData(chunk: string) { + this.data.push(chunk); + }, + onExit(code: number) { + this.exits.push(code); + }, + }; +} diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts new file mode 100644 index 00000000..23b69da3 --- /dev/null +++ b/vscode-ext/test/message-router.test.ts @@ -0,0 +1,127 @@ +/** + * The in-window fan-out: one question to every webview of this window, settled + * as soon as they have all answered. The cross-window tier is `peer-link`'s; the + * Host service that asks is `remote-host`'s. Both are stubbed here so what is + * left is the accounting — who has answered, and what a late or duplicate answer + * does to a snapshot that was already handed to the phone. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExtensionMessage, WebviewMessage } from '../src/message-types'; +import type { PeerLinkDeps } from '../src/peer-link'; +import type { WebviewChannel } from '../src/webview-messaging'; + +/** What `message-router.ts` hands the two modules it configures at load. */ +const wiring = vi.hoisted(() => ({ + peer: null as PeerLinkDeps | null, + /** Every `notifyDirectoryChanged()` the router made. */ + invalidations: 0, +})); + +vi.mock('../src/peer-link', () => ({ + configurePeerLink: (deps: PeerLinkDeps) => { + wiring.peer = deps; + }, + remoteNotifyPeerChange: () => {}, +})); + +vi.mock('../src/remote-host', () => ({ + configureRemoteHost: () => {}, + deliverCommandResult: () => {}, + deliverUiEvent: () => {}, + dropForwardedCommands: () => {}, + greetPeerWindow: () => {}, + handleForwardedCommand: () => {}, + handleRemoteHostCommand: () => {}, + notifyDirectoryChanged: () => { + wiring.invalidations += 1; + }, +})); + +type RouterModule = typeof import('../src/message-router'); + +/** One webview: what it was sent, and a way to make it say something back. */ +function fakeWebview() { + const posted: ExtensionMessage[] = []; + let receive: (message: WebviewMessage) => void = () => {}; + const channel: WebviewChannel = { + post: (message) => { + posted.push(message); + return Promise.resolve(true) as never; + }, + onDidReceiveMessage: ((listener: (message: WebviewMessage) => void) => { + receive = listener; + return { dispose: () => {} }; + }) as never, + }; + return { + channel, + posted, + send: (message: WebviewMessage) => receive(message), + /** The id of the fan-out this webview was last asked to answer. */ + lastAskId(): string { + const ask = [...posted].reverse().find((message) => message.type === 'peer:ask'); + if (!ask) throw new Error('this webview was never asked anything'); + return (ask as { requestId: string }).requestId; + }, + }; +} + +let router: RouterModule; + +beforeEach(async () => { + vi.resetModules(); + wiring.peer = null; + wiring.invalidations = 0; + router = (await import('../src/message-router')) as RouterModule; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('webview fan-out', () => { + it('counts one answer per webview, however many times it answers', async () => { + // A duplicate post, or a webview answering after the budget already + // settled the request under an id that later repeated, would otherwise + // contribute its panes to the directory twice over. + const first = fakeWebview(); + const second = fakeWebview(); + const disposeFirst = router.attachRouter(first.channel); + const disposeSecond = router.attachRouter(second.channel); + try { + const collecting = wiring.peer!.brokerRequest('directory', {}); + const requestId = first.lastAskId(); + + first.send({ type: 'peer:answer', requestId, results: [{ surfaceId: 'a' }] } as never); + first.send({ type: 'peer:answer', requestId, results: [{ surfaceId: 'a' }] } as never); + second.send({ type: 'peer:answer', requestId, results: [{ surfaceId: 'b' }] } as never); + + expect(await collecting).toEqual([{ surfaceId: 'a' }, { surfaceId: 'b' }]); + } finally { + disposeFirst.dispose(); + disposeSecond.dispose(); + } + }); + + it('marks the directory stale when an answer arrives after its request settled', async () => { + // The budget expired and the Host already rendered a snapshot without this + // webview's panes. Nothing re-opens a settled request, so the repair has to + // be the next collect — and an idle machine has no other reason to run one. + const webview = fakeWebview(); + const disposable = router.attachRouter(webview.channel); + try { + const collecting = wiring.peer!.brokerRequest('directory', {}); + const requestId = webview.lastAskId(); + webview.send({ type: 'peer:answer', requestId, results: [] } as never); + expect(await collecting).toEqual([]); + + const before = wiring.invalidations; + webview.send({ type: 'peer:answer', requestId, results: [{ surfaceId: 'late' }] } as never); + expect(wiring.invalidations).toBe(before + 1); + } finally { + disposable.dispose(); + } + }); +}); diff --git a/vscode-ext/test/peer-link-protocol.test.ts b/vscode-ext/test/peer-link-protocol.test.ts new file mode 100644 index 00000000..be8685c6 --- /dev/null +++ b/vscode-ext/test/peer-link-protocol.test.ts @@ -0,0 +1,219 @@ +/** + * The socket-free half of the peer link: frame shapes, framing, the PTY routing + * table, and the handshake primitives. `peer-link.test.ts` covers what only + * exists once there is a real socket. + */ + +import { describe, expect, it } from 'vitest'; +import { ASK_BUDGET_MS } from '../../lib/src/host/remote/service-protocol'; +import { + FrameDecoder, + PEER_CLIENT_PROOF_DOMAIN, + PEER_REPLY_BUDGET_MS, + PEER_SERVER_PROOF_DOMAIN, + encodeFrame, + forgetPeerRoutes, + freshNonce, + proofMatches, + proveToken, + routedPtyId, +} from '../src/peer-link-protocol'; + +describe('reply budgets', () => { + it('gives the cross-window wait more room than the fan-out it contains', () => { + // Not a tidiness assertion: the broker's wait for a peer window strictly + // contains that window's own full-budget fan-out to its webviews plus two + // socket hops. Equal budgets make a slow sibling look like a timeout on the + // broker's side and throw away results that were on their way, so unifying + // these two constants is a regression, not a simplification. + expect(PEER_REPLY_BUDGET_MS).toBeGreaterThan(ASK_BUDGET_MS); + }); +}); + +describe('FrameDecoder', () => { + it('reads one frame per line', () => { + const decoder = new FrameDecoder(); + const frames = decoder.push( + encodeFrame({ kind: 'request', id: 'a', op: 'directory', params: {} }) + + encodeFrame({ kind: 'result', id: 'b', results: [] }), + ); + expect(frames).toEqual([ + { kind: 'request', id: 'a', op: 'directory', params: {} }, + { kind: 'result', id: 'b', results: [] }, + ]); + }); + + it('reassembles a frame split across chunks', () => { + const decoder = new FrameDecoder(); + const encoded = encodeFrame({ kind: 'data', ptyId: 'pty-1', data: 'hello' }); + const cut = Math.floor(encoded.length / 2); + + expect(decoder.push(encoded.slice(0, cut))).toEqual([]); + expect(decoder.push(encoded.slice(cut))).toEqual([ + { kind: 'data', ptyId: 'pty-1', data: 'hello' }, + ]); + }); + + it('holds a trailing partial frame until its newline arrives', () => { + const decoder = new FrameDecoder(); + const whole = encodeFrame({ kind: 'result', id: 'a', results: [] }); + expect(decoder.push(`${whole}{"kind":"result","id":`)).toEqual([ + { kind: 'result', id: 'a', results: [] }, + ]); + expect(decoder.push('"b","results":[]}\n')).toEqual([{ kind: 'result', id: 'b', results: [] }]); + }); + + it('skips a malformed frame without dropping the ones around it', () => { + const decoder = new FrameDecoder(); + const frames = decoder.push( + `{not json}\n${encodeFrame({ kind: 'result', id: 'a', results: [] })}`, + ); + expect(frames).toEqual([{ kind: 'result', id: 'a', results: [] }]); + }); + + it('ignores blank lines', () => { + const decoder = new FrameDecoder(); + expect(decoder.push('\n\n')).toEqual([]); + }); + + it('correlates stream readiness while leaving later PTY frames one-way', () => { + const decoder = new FrameDecoder(); + expect( + decoder.push( + encodeFrame({ kind: 'subscribe', id: 'sub-1', ptyId: 'pty-1' }) + + encodeFrame({ kind: 'subscribed', id: 'sub-1', ptyId: 'pty-1' }) + + encodeFrame({ kind: 'write', ptyId: 'pty-1', data: 'ls\r' }) + + encodeFrame({ kind: 'resizePty', ptyId: 'pty-1', cols: 120, rows: 40 }) + + encodeFrame({ kind: 'unsubscribe', ptyId: 'pty-1' }), + ), + ).toEqual([ + { kind: 'subscribe', id: 'sub-1', ptyId: 'pty-1' }, + { kind: 'subscribed', id: 'sub-1', ptyId: 'pty-1' }, + { kind: 'write', ptyId: 'pty-1', data: 'ls\r' }, + { kind: 'resizePty', ptyId: 'pty-1', cols: 120, rows: 40 }, + { kind: 'unsubscribe', ptyId: 'pty-1' }, + ]); + }); + + it('carries a forwarded command and its answer', () => { + // The Host command bridge rides the same framing, so a window with no + // service of its own reaches the one that has it. + const decoder = new FrameDecoder(); + expect( + decoder.push( + encodeFrame({ kind: 'command', payload: { rhId: 'rh-1', cmd: 'status' } }) + + encodeFrame({ kind: 'commandResult', payload: { rhId: 'rh-1', result: { enrolled: true } } }) + + encodeFrame({ kind: 'commandResult', payload: { rhId: 'rh-2', error: 'nope' } }), + ), + ).toEqual([ + { kind: 'command', payload: { rhId: 'rh-1', cmd: 'status' } }, + { kind: 'commandResult', payload: { rhId: 'rh-1', result: { enrolled: true } } }, + { kind: 'commandResult', payload: { rhId: 'rh-2', error: 'nope' } }, + ]); + }); + + it('carries a UI event with no correlation of its own', () => { + // Broadcast, not addressed: any window's webview may answer the pairing. + const decoder = new FrameDecoder(); + const event = { name: 'pairing-queue', queue: [{ clientId: 'c1' }] }; + expect(decoder.push(encodeFrame({ kind: 'uiEvent', payload: event }))).toEqual([ + { kind: 'uiEvent', payload: event }, + ]); + }); + + it('drops an oversized frame without losing the ones it arrived with', () => { + const decoder = new FrameDecoder(64); + const small = encodeFrame({ kind: 'result', id: 'a', results: [] }); + + // One chunk carrying a whole frame and the start of a frame past the cap. + // Clearing the buffer wholesale would swallow the small frame too, and a + // dropped `commandResult` or `exit` is a webview waiting out its timeout. + expect(decoder.push(small + 'x'.repeat(100))).toEqual([ + { kind: 'result', id: 'a', results: [] }, + ]); + + // The rest of the oversized frame is still arriving; none of it is a frame. + expect(decoder.push('y'.repeat(100))).toEqual([]); + // Its tail must not be resynced as frames of its own — only its terminating + // newline puts the stream back on a frame boundary. + expect(decoder.push(`{"kind":"junk"}\n${small}`)).toEqual([ + { kind: 'result', id: 'a', results: [] }, + ]); + }); + + it('resumes on the newline that ends the oversized frame', () => { + const decoder = new FrameDecoder(64); + expect(decoder.push('x'.repeat(100))).toEqual([]); + expect(decoder.push(`${'x'.repeat(100)}\n`)).toEqual([]); + expect(decoder.push(encodeFrame({ kind: 'result', id: 'a', results: [] }))).toEqual([ + { kind: 'result', id: 'a', results: [] }, + ]); + }); +}); + +describe('routedPtyId', () => { + it('reads the routing hint out of an otherwise opaque answer', () => { + expect(routedPtyId({ ptyId: 'pty-1', cols: 80, rows: 24 })).toBe('pty-1'); + }); + + it('routes nothing for an answer that names no PTY', () => { + // Directory entries travel the same generic path and must not enter the + // routing table. + expect(routedPtyId({ surfaceId: 'pane-1', title: 'zsh' })).toBeNull(); + expect(routedPtyId({ ptyId: 42 })).toBeNull(); + expect(routedPtyId(null)).toBeNull(); + expect(routedPtyId('pty-1')).toBeNull(); + }); +}); + +describe('forgetPeerRoutes', () => { + it('drops every pty behind a peer that disconnected, and reports them', () => { + const routes = new Map([ + ['pty-1', 'window-a'], + ['pty-2', 'window-a'], + ['pty-3', 'window-b'], + ]); + + // Otherwise a later write would be routed into a dead socket. + expect(forgetPeerRoutes(routes, 'window-a').sort()).toEqual(['pty-1', 'pty-2']); + expect(routes.get('pty-1')).toBeUndefined(); + expect(routes.get('pty-3')).toBe('window-b'); + expect(routes.size).toBe(1); + }); + + it('reports nothing for a peer that owns no routes', () => { + const routes = new Map([['pty-1', 'window-a']]); + expect(forgetPeerRoutes(routes, 'window-b')).toEqual([]); + expect(routes.size).toBe(1); + }); +}); + +describe('handshake proofs', () => { + it('binds a proof to the token, the domain, and the nonce', () => { + const proof = proveToken('token', PEER_CLIENT_PROOF_DOMAIN, 'nonce-1'); + expect(proofMatches(proof, proveToken('token', PEER_CLIENT_PROOF_DOMAIN, 'nonce-1'))).toBe(true); + // A different token, a different nonce, or the other direction's domain all + // produce something that cannot pass for this one. + expect(proofMatches(proof, proveToken('other', PEER_CLIENT_PROOF_DOMAIN, 'nonce-1'))).toBe(false); + expect(proofMatches(proof, proveToken('token', PEER_CLIENT_PROOF_DOMAIN, 'nonce-2'))).toBe(false); + expect(proofMatches(proof, proveToken('token', PEER_SERVER_PROOF_DOMAIN, 'nonce-1'))).toBe(false); + }); + + it('never leaks the token into the proof', () => { + expect(proveToken('sup3r-s3cret', PEER_SERVER_PROOF_DOMAIN, 'n')).not.toContain('sup3r-s3cret'); + }); + + it('refuses anything that is not a string, rather than throwing', () => { + // The compare runs on a frame a squatter wrote, so every shape has to be a + // plain `false` — including one whose length would otherwise throw. + const expected = proveToken('token', PEER_SERVER_PROOF_DOMAIN, 'n'); + expect(proofMatches(undefined, expected)).toBe(false); + expect(proofMatches({ length: 43 }, expected)).toBe(false); + expect(proofMatches('short', expected)).toBe(false); + }); + + it('mints a fresh nonce every time', () => { + const nonces = new Set(Array.from({ length: 32 }, () => freshNonce())); + expect(nonces.size).toBe(32); + }); +}); diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts new file mode 100644 index 00000000..6eea4b2e --- /dev/null +++ b/vscode-ext/test/peer-link.test.ts @@ -0,0 +1,1153 @@ +/** + * Bind-as-lease, driven end to end: two independent module instances standing + * in for two VS Code windows, contending for one socket in a temp directory. + * The frames and the routing table are unit-tested in + * `peer-link-protocol.test.ts`; this covers the parts that + * only exist once there is a socket — who wins the bind, what a loser does when + * the winner dies, PTY routing, and the token. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; +import { access, chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createConnection, createServer, Socket, type Server } from 'node:net'; +import { dirname, join } from 'node:path'; +import { + FrameDecoder, + PEER_CLIENT_PROOF_DOMAIN, + PEER_SERVER_PROOF_DOMAIN, + encodeFrame, +} from '../src/peer-link-protocol'; +import { + derivedSocketPath as socketPathFor, + fakeContext, + fakeSink, + fakeWindow, + freshModule, + removeDir, + tempStorageDir, + tick, + waitFor, + waitForFile, +} from './helpers'; + +type LinkModule = typeof import('../src/peer-link'); + +let dir: string; +/** Peer sockets live in the temp dir; point that at this test's own storage. */ +let realTmp: string | undefined; +const opened: LinkModule[] = []; + +const derivedSocketPath = (): string => socketPathFor(dir); + +interface SocketFileIdentity { + dev: bigint; + ino: bigint; + ctimeNs: bigint; +} + +async function socketFileIdentity(path: string): Promise { + const value = await stat(path, { bigint: true }); + return { dev: value.dev, ino: value.ino, ctimeNs: value.ctimeNs }; +} + +function sameSocketFile(left: SocketFileIdentity, right: SocketFileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.ctimeNs === right.ctimeNs; +} + +/** The token the whole installation shares, as it sits on disk. */ +const readToken = async (): Promise => + (await readFile(join(dir, 'remote-host.peer-token'), 'utf8')).trim(); + +const proof = (token: string, domain: string, nonce: string): string => + createHmac('sha256', token).update(domain + nonce).digest('base64url'); + +async function openWindow(deps: ReturnType): Promise { + const mod = await freshModule(() => import('../src/peer-link')); + mod.initPeerLink(fakeContext(dir)); + mod.configurePeerLink(deps.deps()); + opened.push(mod); + return mod; +} + +/** Attach to the terminal {@link farWindow} owns, which is what places its route. */ +const attachFar = async (broker: LinkModule) => { + const [result] = (await broker.remoteRequest('surfaceOp', { + surfaceId: 'far-1', + op: 'attach', + cols: 80, + rows: 24, + })) as Array<{ ptyId: string; cols: number; rows: number }>; + if (!result) throw new Error('far surface did not answer'); + return result; +}; + +/** A window owning one terminal, which is what most of these tests need. */ +const farWindow = () => + fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + +/** + * Start two windows in order and wait until they can actually talk. The second + * always reports at least one entry, because an answered directory request is + * how we detect the handshake landed. + */ +async function linkedPair( + brokerSide = fakeWindow(), + peerSide = fakeWindow({ entries: [{ surfaceId: 'far-default' }] }), +) { + const brokerRoles: boolean[] = []; + const broker = await openWindow(brokerSide); + await broker.ensurePeerNet((held) => brokerRoles.push(held)); + expect(brokerRoles).toEqual([true]); + + const peerRoles: boolean[] = []; + const peer = await openWindow(peerSide); + await peer.ensurePeerNet((held) => peerRoles.push(held)); + // The loser is told nothing: a role only ever changes upward. + expect(peerRoles).toEqual([]); + + await waitFor(async () => (await broker.remoteRequest('directory', {})).length > 0); + return { broker, brokerSide, peer, peerSide, peerRoles }; +} + +beforeEach(async () => { + dir = await tempStorageDir(); + realTmp = process.env.TMPDIR; + process.env.TMPDIR = dir; +}); + +afterEach(async () => { + // Clients first: disposing the broker while one is still live sends it back + // into the contention, which would recreate files under `dir` as it is + // removed. + for (const mod of [...opened].reverse()) await mod.disposePeerLink(); + opened.length = 0; + // Assigning `undefined` would set the literal string, and a Linux runner has + // no TMPDIR to put back — which the *next* test's mkdtemp would wear. + if (realTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = realTmp; + await removeDir(dir); +}); + +describe('bind-as-lease', () => { + it('rejects when the peer socket cannot be bound', async () => { + const mod = await openWindow(fakeWindow()); + const failingServer = createServer(); + + await expect(mod.listenServer(failingServer, join(dir, 'missing', 'peer.sock'))) + .rejects.toHaveProperty('code'); + }); + + it('logs an accept-time server error instead of taking the extension host down', async () => { + // Past `listen`, an EMFILE or a pipe error arrives as an `'error'` event on + // the server. An EventEmitter with no listener for that *throws*, out of a + // libuv callback with nothing to catch it. + const mod = await openWindow(fakeWindow()); + const server = createServer(); + const path = join(dir, 'accept-error.sock'); + await mod.listenServer(server, path); + try { + expect(() => server.emit('error', Object.assign(new Error('EMFILE'), { code: 'EMFILE' }))) + .not.toThrow(); + // And it is still listening: connections already accepted, and the ones + // still to come, are unaffected. + const socket = createConnection({ path }); + await new Promise((resolve, reject) => { + socket.once('connect', () => resolve()); + socket.once('error', reject); + }); + socket.destroy(); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it('keeps a peer PTY distinct when its owner-local id collides with this window', async () => { + // Pane ids are unique within a window and nothing coordinates them across + // windows — "Duplicate Workspace in New Window" cold-restores the same ids + // — so a peer can answer an attach naming a terminal *this* window owns. + // The provider-local handle must still target the peer surface the caller + // selected; its raw owner-local id must not make it fall through to this + // window's different shell. + const brokerSide = fakeWindow({ ownPtyIds: ['pty-far'] }); + const peerSide = farWindow(); + const { broker } = await linkedPair(brokerSide, peerSide); + + const handle = await attachFar(broker); + expect(handle).toMatchObject({ cols: 80, rows: 24 }); + expect(handle.ptyId).not.toBe('pty-far'); + expect(broker.isRemotePty(handle.ptyId)).toBe(true); + expect(broker.remoteWrite(handle.ptyId, 'ls\r')).toBe(true); + await waitFor(() => peerSide.writes.length > 0); + expect(peerSide.writes).toEqual([{ ptyId: 'pty-far', data: 'ls\r' }]); + expect(brokerSide.writes).toEqual([]); + }); + + it('settles what a dropping window was asked rather than holding the whole collection', async () => { + // A directory or an attach every surviving window already answered must not + // wait out `PEER_REPLY_BUDGET_MS` behind a window that is already gone. + const stuckSide = fakeWindow({ entries: [{ surfaceId: 'stuck-1' }] }); + let releaseStuck: () => void = () => {}; + const stuck = new Promise((resolve) => { + releaseStuck = resolve; + }); + const stuckDeps = stuckSide.deps(); + stuckDeps.brokerRequest = async () => { + await stuck; + return stuckSide.entries; + }; + + const broker = await openWindow(fakeWindow()); + await broker.ensurePeerNet(() => {}); + const answering = await openWindow(fakeWindow({ entries: [{ surfaceId: 'live-1' }] })); + await answering.ensurePeerNet(() => {}); + const stuckLink = await freshModule(() => import('../src/peer-link')); + stuckLink.initPeerLink(fakeContext(dir)); + stuckLink.configurePeerLink(stuckDeps); + opened.push(stuckLink); + await stuckLink.ensurePeerNet(() => {}); + + const started = Date.now(); + const collecting = broker.remoteRequest('directory', {}); + await tick(); + await stuckLink.disposePeerLink(); + + expect(await collecting).toEqual([{ surfaceId: 'live-1' }]); + // Well inside `PEER_REPLY_BUDGET_MS`, which is what it used to spend. + expect(Date.now() - started).toBeLessThan(2_000); + releaseStuck(); + }, 15_000); + + it('does not answer broker while a reclaimed bind is still unverified', async () => { + // `stillOurs` spends 250 ms watching for a window that cleared the same + // corpse and bound after us. An enroll landing inside that window used to + // see a bound socket, start a service, and the stand-down path + // (`closeServer(false)`) never tears one down — two Hosts under one hostId. + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const corpse = spawn(process.execPath, [ + '-e', + `require('node:net').createServer().listen(${JSON.stringify(path)})`, + ]); + await waitForFile(path); + corpse.kill('SIGKILL'); + await new Promise((resolve) => corpse.on('exit', resolve)); + const dead = await socketFileIdentity(path); + + const mod = await openWindow(fakeWindow()); + const roles: boolean[] = []; + const settled = mod.ensurePeerNet((held) => roles.push(held)); + + // The instant the path names a new socket generation this window has bound + // it — and is still deciding whether it may keep it. Linux can immediately + // recycle the corpse's inode, so inode alone cannot identify that change. + const during: boolean[] = []; + let brokerDuring = true; + let settledDuring = true; + await waitFor(async () => { + const now = await socketFileIdentity(path).catch(() => null); + if (!now || sameSocketFile(now, dead)) return false; + void mod.ensurePeerNet((held) => during.push(held)); + brokerDuring = mod.isPeerBroker(); + settledDuring = mod.isPeerLinkSettled(); + return true; + }, 15_000); + + expect(during).toEqual([]); + expect(brokerDuring).toBe(false); + // So a command arriving now is held for the verdict rather than refused. + expect(settledDuring).toBe(false); + + await settled; + expect(mod.isPeerBroker()).toBe(true); + // Announced exactly once, to whoever asked last. + expect(roles.concat(during)).toEqual([true]); + }, 30_000); + + it('stands down for good when the shared token can be neither read nor written', async () => { + // An unwritable `globalStorageUri` is not transient. Retrying at 1 Hz + // forever leaves every command waiting out its whole queue budget on every + // attempt instead of being told there is nothing to reach. + await mkdir(join(dir, 'remote-host.peer-token'), { recursive: true }); + + const mod = await openWindow(fakeWindow()); + const roles: boolean[] = []; + await mod.ensurePeerNet((held) => roles.push(held)); + + expect(roles).toEqual([]); + expect(mod.isPeerBroker()).toBe(false); + // Latched: a later caller is answered immediately rather than restarting it. + expect(mod.isPeerLinkSettled()).toBe(true); + await mod.ensurePeerNet(() => {}); + expect(mod.isPeerBroker()).toBe(false); + }); + + it('makes the first window to bind the broker and the second a client', async () => { + const { broker, peer } = await linkedPair(); + expect(broker.isPeerBroker()).toBe(true); + expect(peer.isPeerBroker()).toBe(false); + }); + + it('is idempotent — a second call re-announces the role it already holds', async () => { + const broker = await openWindow(fakeWindow()); + const roles: boolean[] = []; + await broker.ensurePeerNet((held) => roles.push(held)); + await broker.ensurePeerNet((held) => roles.push(held)); + expect(roles).toEqual([true, true]); + }); + + it('takes over a socket whose broker died without unlinking it', async () => { + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + // A killed process leaves the inode behind — `close()` would unlink it, so + // the only way to produce this state is to not let the owner close. + const corpse = spawn(process.execPath, [ + '-e', + `require('node:net').createServer().listen(${JSON.stringify(path)})`, + ]); + await waitForFile(path); + corpse.kill('SIGKILL'); + await new Promise((resolve) => corpse.on('exit', resolve)); + // Still there, and nothing is listening on it. + await expect(access(path)).resolves.toBeUndefined(); + + const mod = await openWindow(fakeWindow()); + const roles: boolean[] = []; + await mod.ensurePeerNet((held) => roles.push(held)); + + expect(roles).toEqual([true]); + expect(mod.isPeerBroker()).toBe(true); + }); + + it('re-binds when the socket it reclaimed is unlinked out from under it', async () => { + // Two windows can clear the same corpse and the second bind displaces the + // first without any error — the loser keeps serving an inode no client can + // reach. On unix a path that has *gone* after our bind is the same failure, + // and reading it as "still ours" leaves a broker nobody can dial. + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const corpse = spawn(process.execPath, [ + '-e', + `require('node:net').createServer().listen(${JSON.stringify(path)})`, + ]); + await waitForFile(path); + corpse.kill('SIGKILL'); + await new Promise((resolve) => corpse.on('exit', resolve)); + const dead = await socketFileIdentity(path); + + const mod = await openWindow(fakeWindow()); + const settled = mod.ensurePeerNet(() => {}); + // Stand in for the competing reclaim: take the path away the moment this + // window has bound it, inside its own verification window. + void (async () => { + for (let i = 0; i < 2000; i++) { + const now = await socketFileIdentity(path).catch(() => null); + if (now && !sameSocketFile(now, dead)) { + await rm(path, { force: true }); + return; + } + await tick(5); + } + })(); + await settled; + + // It bound again rather than settling on a path that no longer names it. + expect(mod.isPeerBroker()).toBe(true); + await expect(access(path)).resolves.toBeUndefined(); + // Which is the property that matters: another window can actually reach it. + const peer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); + await peer.ensurePeerNet(() => {}); + expect(peer.isPeerBroker()).toBe(false); + }, 30_000); + + it('settles two windows racing for one corpse into a broker and a client', async () => { + // Both find the same dead socket, both may unlink it, and the second bind + // silently displaces the first. Whoever loses that has to notice and stand + // down rather than serve an inode nobody can reach — and must then end up a + // client, not wedged. + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const corpse = spawn(process.execPath, [ + '-e', + `require('node:net').createServer().listen(${JSON.stringify(path)})`, + ]); + await waitForFile(path); + corpse.kill('SIGKILL'); + await new Promise((resolve) => corpse.on('exit', resolve)); + + const first = await openWindow(fakeWindow()); + const second = await openWindow(fakeWindow()); + const firstRoles: boolean[] = []; + const secondRoles: boolean[] = []; + await Promise.all([ + first.ensurePeerNet((held) => firstRoles.push(held)), + second.ensurePeerNet((held) => secondRoles.push(held)), + ]); + + const brokers = [first, second].filter((mod) => mod.isPeerBroker()); + expect(brokers).toHaveLength(1); + // And the loser reached the broker rather than giving up: it can forward. + const loser = [first, second].find((mod) => !mod.isPeerBroker())!; + await waitFor(() => loser.forwardCommand({ rhId: 'rh-1', cmd: 'status' }), 15_000); + expect(firstRoles.concat(secondRoles)).toEqual([true]); + }, 30_000); + + it('collects directory entries from the other window', async () => { + const peerSide = fakeWindow({ entries: [{ surfaceId: 'far-1' }, { surfaceId: 'far-2' }] }); + const { broker } = await linkedPair(fakeWindow(), peerSide); + + expect(await broker.remoteRequest('directory', {})).toEqual([ + { surfaceId: 'far-1' }, + { surfaceId: 'far-2' }, + ]); + }); + + it('invalidates the broker directory when a peer announces a change', async () => { + const { brokerSide, peer } = await linkedPair(); + const before = brokerSide.invalidations; + + peer.remoteNotifyPeerChange(); + + await waitFor(() => brokerSide.invalidations > before); + }); + + it('returns nothing when no other window is connected', async () => { + const broker = await openWindow(fakeWindow()); + await broker.ensurePeerNet(() => {}); + expect(await broker.remoteRequest('directory', {})).toEqual([]); + }); + + it('drives a surface owned by the other window and remembers where it lives', async () => { + const peerSide = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 100, rows: 30 } }, + }); + const { broker } = await linkedPair(fakeWindow(), peerSide); + + const [result] = (await broker.remoteRequest('surfaceOp', { + surfaceId: 'far-1', op: 'attach', cols: 100, rows: 30, + })) as Array<{ ptyId: string; cols: number; rows: number }>; + expect(result).toMatchObject({ cols: 100, rows: 30 }); + expect(result!.ptyId).not.toBe('pty-far'); + // Input and resizes have to reach that window afterwards. + expect(broker.isRemotePty(result!.ptyId)).toBe(true); + }); + + it('reports a surface nobody owns', async () => { + const { broker } = await linkedPair(fakeWindow(), fakeWindow({ entries: [{ s: 1 }] })); + // Nothing answered, which is the only "not mine" signal there is. + expect(await broker.remoteRequest('surfaceOp', { + surfaceId: 'nobody', op: 'attach', cols: 80, rows: 24, + })).toEqual([]); + expect(broker.isRemotePty('nobody')).toBe(false); + }); + + it('streams a subscribed PTY from the owning window', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + + const sink = fakeSink(); + broker.remoteSubscribe(handle.ptyId, sink); + await tick(); + peerSide.emitData('pty-far', 'output from the other window'); + + await waitFor(() => sink.data.length > 0); + expect(sink.data).toEqual(['output from the other window']); + }); + + it('does not stream PTYs it never subscribed to', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + + const sink = fakeSink(); + broker.remoteSubscribe(handle.ptyId, sink); + await tick(); + peerSide.emitData('pty-other', 'not subscribed'); + await tick(100); + expect(sink.data).toEqual([]); + }); + + it('forwards a subscribed PTY exit and forgets its route', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + const sink = fakeSink(); + broker.remoteSubscribe(handle.ptyId, sink); + await tick(); + + peerSide.emitExit('pty-far', 17); + + await waitFor(() => sink.exits.length > 0); + expect(sink.exits).toEqual([17]); + expect(broker.isRemotePty(handle.ptyId)).toBe(false); + }); + + it('fails closed when the owner route disappears before subscription', async () => { + const peerSide = farWindow(); + const { broker, peer } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + + await peer.disposePeerLink(); + await waitFor(() => !broker.isRemotePty(handle.ptyId)); + + const sink = fakeSink(); + await broker.remoteSubscribe(handle.ptyId, sink); + expect(sink.exits).toEqual([0]); + }); + + it('replays an exit that preceded the cross-window subscription', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + + // The pane remains resolvable after its process exits. No forwarding sink + // exists yet, so the owner's durable liveness record must bridge the gap. + peerSide.emitExit('pty-far', 23); + const firstHandle = await attachFar(broker); + const first = fakeSink(); + const firstOrder: string[] = []; + const firstReady = broker + .remoteSubscribe(firstHandle.ptyId, { + ...first, + onExit: (code) => { + first.exits.push(code); + firstOrder.push('exit'); + }, + }) + .then(() => void firstOrder.push('ready')); + + await firstReady; + expect(first.exits).toEqual([23]); + // The owner writes replay before acknowledgement on one ordered socket. + // RemoteApiSession waits on readiness, so this order prevents an attach-ok + // from overtaking the already-recorded close. + expect(firstOrder).toEqual(['exit', 'ready']); + expect(broker.isRemotePty(firstHandle.ptyId)).toBe(false); + + // A synchronous replay must not leave a spent forwarding entry on the + // owner, or the next resolve would be routed but its subscribe ignored. + const secondHandle = await attachFar(broker); + const second = fakeSink(); + await broker.remoteSubscribe(secondHandle.ptyId, second); + expect(second.exits).toEqual([23]); + }); + + it('stops the stream on unsubscribe but keeps the route', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + const sink = fakeSink(); + broker.remoteSubscribe(handle.ptyId, sink); + await tick(); + + broker.remoteUnsubscribe(handle.ptyId, sink); + await tick(); + peerSide.emitData('pty-far', 'after unsubscribe'); + await tick(100); + expect(sink.data).toEqual([]); + + // The route stays: "nobody is watching it" is not "it moved". Re-attaching + // an already-attached surface places the new route *before* the old + // attachment is torn down, so dropping it here would delete the fresh one. + expect(broker.isRemotePty(handle.ptyId)).toBe(true); + + // And a second attach streams again over the route that was never lost. + const again = fakeSink(); + broker.remoteSubscribe(handle.ptyId, again); + await tick(); + peerSide.emitData('pty-far', 'flowing again'); + await waitFor(() => again.data.length > 0); + expect(again.data).toEqual(['flowing again']); + }); + + it('keeps serving a surface that is re-attached while still attached', async () => { + // Attach-over-attach: the new route is placed by the resolve, and only then + // does the old attachment's teardown unsubscribe. The route must survive + // that teardown or every later write goes nowhere. + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const firstHandle = await attachFar(broker); + const first = fakeSink(); + broker.remoteSubscribe(firstHandle.ptyId, first); + await tick(); + + // The order `RemoteApiSession` actually uses: the resolve re-places the + // route, then the *old* attachment is torn down, then the new one + // subscribes. A teardown that dropped the route would leave that last + // subscribe with nowhere to send. + const secondHandle = await attachFar(broker); + const second = fakeSink(); + broker.remoteUnsubscribe(firstHandle.ptyId, first); + broker.remoteSubscribe(secondHandle.ptyId, second); + await tick(); + + expect(secondHandle.ptyId).toBe(firstHandle.ptyId); + expect(broker.isRemotePty(secondHandle.ptyId)).toBe(true); + expect(broker.remoteWrite(secondHandle.ptyId, 'ls\r')).toBe(true); + peerSide.emitData('pty-far', 'still here'); + await waitFor(() => second.data.length > 0); + expect(second.data).toEqual(['still here']); + }); + + it('keeps a second viewer streaming when the first detaches', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + const first = fakeSink(); + const second = fakeSink(); + broker.remoteSubscribe(handle.ptyId, first); + broker.remoteSubscribe(handle.ptyId, second); + await tick(); + + // One detach must not stop the shared stream or drop the route. + broker.remoteUnsubscribe(handle.ptyId, first); + await tick(); + peerSide.emitData('pty-far', 'still flowing'); + + await waitFor(() => second.data.length > 0); + expect(second.data).toEqual(['still flowing']); + expect(first.data).toEqual([]); + expect(broker.isRemotePty(handle.ptyId)).toBe(true); + }); + + it('routes input and resize to the owning window', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + + expect(broker.remoteWrite(handle.ptyId, 'ls\r')).toBe(true); + expect(broker.remoteResize(handle.ptyId, 120, 40)).toBe(true); + + await waitFor(() => peerSide.writes.length > 0 && peerSide.resizes.length > 0); + expect(peerSide.writes).toEqual([{ ptyId: 'pty-far', data: 'ls\r' }]); + expect(peerSide.resizes).toEqual([{ ptyId: 'pty-far', cols: 120, rows: 40 }]); + }); + + it('refuses to route a PTY it has never placed', async () => { + const { broker } = await linkedPair(); + // False tells the caller to fall back to the local pty manager. + expect(broker.remoteWrite('pty-local', 'x')).toBe(false); + expect(broker.remoteResize('pty-local', 80, 24)).toBe(false); + }); + + it('reports terminals as exited when their window disconnects', async () => { + const peerSide = farWindow(); + const { broker, peer } = await linkedPair(fakeWindow(), peerSide); + const handle = await attachFar(broker); + const sink = fakeSink(); + broker.remoteSubscribe(handle.ptyId, sink); + await tick(); + + // The window was closed: its terminals are gone, and a later write must not + // be posted into a dead socket. + await peer.disposePeerLink(); + + await waitFor(() => sink.exits.length > 0); + expect(sink.exits).toEqual([0]); + expect(broker.isRemotePty(handle.ptyId)).toBe(false); + expect(broker.remoteWrite(handle.ptyId, 'x')).toBe(false); + }); + + it('hands the Host to a surviving window when the broker dies', async () => { + const { broker, peer, peerRoles } = await linkedPair(); + + // The broker window closed. Its socket closes with it, and every client + // races to bind; there is exactly one, so it wins. + await broker.disposePeerLink(); + + await waitFor(() => peerRoles.length > 0, 10_000); + expect(peerRoles).toEqual([true]); + expect(peer.isPeerBroker()).toBe(true); + }); + + it('is unsettled the instant its broker socket dies, before the close lands', async () => { + // `close` is a later tick. In between, this window has a socket it cannot + // write to and no contention running — so reporting a role would make + // `remote-host.ts` refuse a command it should have held for the re-bind. + const connected: Socket[] = []; + const connect = Socket.prototype.connect; + const spy = vi + .spyOn(Socket.prototype, 'connect') + .mockImplementation(function (this: Socket, ...args: Parameters) { + connected.push(this); + return connect.apply(this, args); + }); + let peer: LinkModule; + try { + ({ peer } = await linkedPair()); + } finally { + spy.mockRestore(); + } + + expect(peer.isPeerLinkSettled()).toBe(true); + connected.at(-1)!.destroy(); + + expect(peer.forwardCommand({ rhId: 'rh-1', cmd: 'status' })).toBe(false); + expect(peer.isPeerLinkSettled()).toBe(false); + }); + + it('does not send an old broker’s delayed answer to its replacement', async () => { + const token = 'test-peer-token'; + await writeFile(join(dir, 'remote-host.peer-token'), token, { mode: 0o600 }); + + async function rawBroker(challenge: string) { + let activeSocket: import('node:net').Socket | null = null; + let accept!: (socket: import('node:net').Socket) => void; + const connected = new Promise((resolve) => { + accept = resolve; + }); + const frames: Array> = []; + const server = createServer((socket) => { + const decoder = new FrameDecoder(); + let authenticated = false; + activeSocket = socket; + socket.setEncoding('utf8'); + socket.write(encodeFrame({ kind: 'challenge', nonce: challenge })); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const message = frame as Record; + if (!authenticated) { + expect(message.kind).toBe('hello'); + authenticated = true; + socket.write( + encodeFrame({ + kind: 'welcome', + proof: proof(token, PEER_SERVER_PROOF_DOMAIN, String(message.nonce)), + }), + ); + accept(socket); + } else { + frames.push(message); + } + } + }); + }); + await mkdir(dirname(derivedSocketPath()), { recursive: true, mode: 0o700 }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(derivedSocketPath(), () => { + server.off('error', reject); + resolve(); + }); + }); + return { + connected, + frames, + async close() { + activeSocket?.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; + } + + const firstBroker = await rawBroker('broker-a'); + const peerSide = fakeWindow(); + const peerDeps = peerSide.deps(); + let releaseOld: () => void = () => {}; + let enteredOld: () => void = () => {}; + const oldGate = new Promise((resolve) => { + releaseOld = resolve; + }); + const oldEntered = new Promise((resolve) => { + enteredOld = resolve; + }); + let requestCount = 0; + peerDeps.brokerRequest = async () => { + requestCount += 1; + if (requestCount === 1) { + enteredOld(); + await oldGate; + return [{ surfaceId: 'from-broker-a' }]; + } + return [{ surfaceId: 'from-broker-b' }]; + }; + const peer = await freshModule(() => import('../src/peer-link')); + peer.initPeerLink(fakeContext(dir)); + peer.configurePeerLink(peerDeps); + opened.push(peer); + await peer.ensurePeerNet(() => {}); + + const firstSocket = await firstBroker.connected; + firstSocket.write( + encodeFrame({ kind: 'request', id: 'same-id', op: 'directory', params: {} }), + ); + await oldEntered; + + // Broker A dies while its webview fan-out is still pending. The replacement + // binds before this client retries, and deliberately reuses the same id. + await firstBroker.close(); + const secondBroker = await rawBroker('broker-b'); + try { + const secondSocket = await secondBroker.connected; + secondSocket.write( + encodeFrame({ kind: 'request', id: 'same-id', op: 'directory', params: {} }), + ); + await waitFor(() => secondBroker.frames.length === 1); + expect(secondBroker.frames[0]).toEqual({ + kind: 'result', + id: 'same-id', + results: [{ surfaceId: 'from-broker-b' }], + }); + + releaseOld(); + await tick(100); + // The late answer belongs to the destroyed first socket. Sending it to + // the current socket would let it satisfy an unrelated replacement id. + expect(secondBroker.frames).toHaveLength(1); + } finally { + releaseOld(); + await secondBroker.close(); + } + }); + + it('runs a losing window\'s webview command in the broker and answers it there', async () => { + const { broker, brokerSide, peer, peerSide } = await linkedPair(); + + expect(peer.forwardCommand({ rhId: 'rh-1', cmd: 'status' })).toBe(true); + + await waitFor(() => brokerSide.forwarded.length > 0); + expect(brokerSide.forwarded[0].payload).toEqual({ rhId: 'rh-1', cmd: 'status' }); + + broker.sendCommandResult(brokerSide.forwarded[0].from, { + rhId: 'rh-1', + result: { enrolled: true }, + }); + + await waitFor(() => peerSide.results.length > 0); + expect(peerSide.results).toEqual([{ rhId: 'rh-1', result: { enrolled: true } }]); + }); + + it('answers only the window that asked', async () => { + const { broker, brokerSide, peer, peerSide } = await linkedPair(); + const thirdSide = fakeWindow(); + const third = await openWindow(thirdSide); + await third.ensurePeerNet(() => {}); + + peer.forwardCommand({ rhId: 'rh-1', cmd: 'status' }); + await waitFor(() => brokerSide.forwarded.length > 0); + broker.sendCommandResult(brokerSide.forwarded[0].from, { rhId: 'rh-1', result: {} }); + + await waitFor(() => peerSide.results.length > 0); + // An `rhId` is unique across every window, so a result sent to the wrong one + // would settle nothing there and leak this one's Host state. + await tick(100); + expect(thirdSide.results).toEqual([]); + }); + + it('has nothing to forward to when this window is the broker', async () => { + const { broker } = await linkedPair(); + // False is the caller's cue to run it locally, or to refuse it. + expect(broker.forwardCommand({ rhId: 'rh-1', cmd: 'status' })).toBe(false); + }); + + it('fans a Host UI event out to every window', async () => { + const { broker, peerSide } = await linkedPair(); + const secondSide = fakeWindow(); + const second = await openWindow(secondSide); + await second.ensurePeerNet(() => {}); + await waitFor(async () => (await broker.remoteRequest('directory', {})).length > 0); + + const event = { name: 'pairing-queue', queue: [{ clientId: 'client-1' }] }; + broker.broadcastUiEvent(event); + + // Unaddressed on purpose: the pairing modal can be answered from whichever + // window the user is looking at. + await waitFor(() => peerSide.uiEvents.length > 0 && secondSide.uiEvents.length > 0); + expect(peerSide.uiEvents).toEqual([event]); + expect(secondSide.uiEvents).toEqual([event]); + }); + + it('drops a window\'s outstanding commands when its socket closes', async () => { + const { brokerSide, peer } = await linkedPair(); + peer.forwardCommand({ rhId: 'rh-1', cmd: 'status' }); + await waitFor(() => brokerSide.forwarded.length > 0); + + await peer.disposePeerLink(); + + // Nothing is answered: the socket that would carry the answer is the one + // that closed, and the asking adapter's own timeout is the backstop. + await waitFor(() => brokerSide.dropped.length > 0); + expect(brokerSide.dropped[0]).toBe(brokerSide.forwarded[0].from); + }); + + it('reports a joining window so the broker can hand it the Host state', async () => { + // Nothing about the Host changes because a window connected, so the events + // its webviews arm on are never coming on their own — the broker has to + // volunteer them, and this is the only signal it gets. + const brokerSide = fakeWindow(); + const { broker, peerSide } = await linkedPair(brokerSide); + + expect(brokerSide.joined).toHaveLength(1); + const event = { name: 'status', enrolled: true }; + broker.sendUiEvent(brokerSide.joined[0]!, event); + + await waitFor(() => peerSide.uiEvents.length > 0); + expect(peerSide.uiEvents).toEqual([event]); + }); +}); + +/** + * The opening handshake. The socket path is derived from the storage location, + * so anything on the machine can compute it; these are the properties that make + * knowing it worthless. + */ +describe('peer handshake', () => { + /** Read one frame at a time off a raw socket. */ + function frameReader(socket: import('node:net').Socket) { + const decoder = new FrameDecoder(); + const queue: Array> = []; + const waiters: Array<(frame: Record) => void> = []; + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const typed = frame as Record; + const waiter = waiters.shift(); + if (waiter) waiter(typed); + else queue.push(typed); + } + }); + return { + frames: queue, + next(): Promise> { + const ready = queue.shift(); + if (ready) return Promise.resolve(ready); + return new Promise((resolve) => waiters.push(resolve)); + }, + }; + } + + it('runs challenge → hello → welcome over a real socket, without sending the token', async () => { + const broker = await openWindow(fakeWindow({ entries: [{ surfaceId: 'near-1' }] })); + await broker.ensurePeerNet(() => {}); + const token = await readToken(); + expect(token).toBeTruthy(); + + const socket = createConnection({ path: derivedSocketPath() }); + const reader = frameReader(socket); + await new Promise((resolve) => socket.on('connect', resolve)); + + // The server speaks first: a client must never volunteer a proof into + // whatever bound the path. + const challenge = await reader.next(); + expect(challenge.kind).toBe('challenge'); + expect(typeof challenge.nonce).toBe('string'); + + const nonce = 'client-nonce-1'; + socket.write( + encodeFrame({ + kind: 'hello', + nonce, + proof: proof(token, PEER_CLIENT_PROOF_DOMAIN, String(challenge.nonce)), + }), + ); + + const welcome = await reader.next(); + expect(welcome).toEqual({ + kind: 'welcome', + proof: proof(token, PEER_SERVER_PROOF_DOMAIN, nonce), + }); + // The raw token never crossed in either direction. + expect(JSON.stringify([challenge, welcome])).not.toContain(token); + + // And the socket is a working peer afterwards. + socket.write(encodeFrame({ kind: 'notify' })); + socket.destroy(); + }); + + it('drops a client whose proof is over the wrong token', async () => { + const brokerSide = fakeWindow(); + const broker = await openWindow(brokerSide); + await broker.ensurePeerNet(() => {}); + + const socket = createConnection({ path: derivedSocketPath() }); + const reader = frameReader(socket); + await new Promise((resolve) => socket.on('connect', resolve)); + const challenge = await reader.next(); + socket.write( + encodeFrame({ + kind: 'hello', + nonce: 'n', + proof: proof('not the token', PEER_CLIENT_PROOF_DOMAIN, String(challenge.nonce)), + }), + ); + + // Dropped rather than answered — no welcome, and it never joins. + await new Promise((resolve) => socket.on('close', resolve)); + expect(reader.frames).toEqual([]); + expect(brokerSide.joined).toEqual([]); + expect(await broker.remoteRequest('directory', {})).toEqual([]); + socket.destroy(); + }); + + it('drops a parseable JSON value that is not a client frame', async () => { + const brokerSide = fakeWindow(); + const broker = await openWindow(brokerSide); + await broker.ensurePeerNet(() => {}); + + const socket = createConnection({ path: derivedSocketPath() }); + const reader = frameReader(socket); + await new Promise((resolve) => socket.on('connect', resolve)); + expect((await reader.next()).kind).toBe('challenge'); + + const closed = new Promise((resolve) => socket.on('close', resolve)); + socket.write('null\n'); + await closed; + + expect(reader.frames).toEqual([]); + expect(brokerSide.joined).toEqual([]); + }); + + it('rejects a proof replayed from another connection', async () => { + const broker = await openWindow(fakeWindow()); + await broker.ensurePeerNet(() => {}); + const token = await readToken(); + + const first = createConnection({ path: derivedSocketPath() }); + const firstReader = frameReader(first); + await new Promise((resolve) => first.on('connect', resolve)); + const captured = await firstReader.next(); + const stolen = proof(token, PEER_CLIENT_PROOF_DOMAIN, String(captured.nonce)); + first.destroy(); + + // A second connection gets a fresh challenge, so the captured proof is + // worth nothing — which is the whole point of the nonce. + const second = createConnection({ path: derivedSocketPath() }); + const secondReader = frameReader(second); + await new Promise((resolve) => second.on('connect', resolve)); + const fresh = await secondReader.next(); + expect(fresh.nonce).not.toBe(captured.nonce); + second.write(encodeFrame({ kind: 'hello', nonce: 'n', proof: stolen })); + + await new Promise((resolve) => second.on('close', resolve)); + expect(secondReader.frames).toEqual([]); + second.destroy(); + }); + + it('serves nothing to a squatter that took the path but not the token', async () => { + // The co-resident-user attack: bind the path first, then wait to be handed + // this installation's terminals. The client must send its hello and nothing + // else, and must disconnect on a welcome it cannot verify. + const received: Array> = []; + let squatterSocket: import('node:net').Socket | null = null; + let closed = false; + const squatter: Server = createServer((socket) => { + squatterSocket = socket; + const reader = frameReader(socket); + socket.on('close', () => { + closed = true; + }); + void (async () => { + socket.write(encodeFrame({ kind: 'challenge', nonce: 'squatter-nonce' })); + for (;;) { + const frame = await reader.next(); + received.push(frame); + if (frame.kind === 'hello') { + // It cannot compute the real proof, so it guesses. + socket.write(encodeFrame({ kind: 'welcome', proof: 'made up' })); + } + } + })(); + }); + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await new Promise((resolve) => squatter.listen(path, resolve)); + + try { + const side = fakeWindow({ entries: [{ surfaceId: 'secret-1' }] }); + const window = await openWindow(side); + const roles: boolean[] = []; + void window.ensurePeerNet((held) => roles.push(held)); + + await waitFor(() => received.length > 0); + await tick(200); + + // Exactly one frame, the hello, and it carries no token — only an HMAC + // over a nonce the squatter chose, which is not the token. + expect(received.map((frame) => frame.kind)).toEqual(['hello']); + expect(JSON.stringify(received)).not.toContain(await readToken()); + // No directory, no surfaces, no PTY: it never became this window's broker. + expect(closed).toBe(true); + expect(window.isPeerBroker()).toBe(false); + expect(window.forwardCommand({ rhId: 'rh-1', cmd: 'status' })).toBe(false); + expect(roles).toEqual([]); + expect(side.writes).toEqual([]); + } finally { + squatterSocket?.destroy(); + await new Promise((resolve) => squatter.close(resolve)); + } + }); + + it('rejects a non-object frame from the process holding the socket', async () => { + let squatterSocket: import('node:net').Socket | null = null; + let window: LinkModule | null = null; + let closed = false; + const squatter: Server = createServer((socket) => { + squatterSocket = socket; + socket.on('close', () => { + closed = true; + }); + socket.write('null\n'); + }); + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await new Promise((resolve) => squatter.listen(path, resolve)); + + try { + const side = fakeWindow({ entries: [{ surfaceId: 'secret-1' }] }); + window = await openWindow(side); + const roles: boolean[] = []; + void window.ensurePeerNet((held) => roles.push(held)); + + await waitFor(() => closed); + expect(window.isPeerBroker()).toBe(false); + expect(window.forwardCommand({ rhId: 'rh-1', cmd: 'status' })).toBe(false); + expect(roles).toEqual([]); + expect(side.writes).toEqual([]); + } finally { + await window?.disposePeerLink(); + squatterSocket?.destroy(); + await new Promise((resolve) => squatter.close(resolve)); + } + }); + + it('keeps the socket directory private to this user', async () => { + // The layer below the handshake: in a shared tmpdir, a directory anyone can + // write to is one where a co-resident user can create the path first. + const peerDir = dirname(derivedSocketPath()); + await mkdir(peerDir, { recursive: true, mode: 0o700 }); + await chmod(peerDir, 0o777); + + const mod = await openWindow(fakeWindow()); + await mod.ensurePeerNet(() => {}); + + // Ours, so it is tightened rather than refused. + expect(mod.isPeerBroker()).toBe(true); + expect((await stat(peerDir)).mode & 0o777).toBe(0o700); + }); + + it('stands down for good when the socket directory is not one', async () => { + // Something else holds the only place these sockets may live. No amount of + // retrying changes that, so the link stops rather than spinning — and the + // waiting caller is released rather than left hanging. + const peerDir = dirname(derivedSocketPath()); + await writeFile(peerDir, 'not a directory'); + + const mod = await openWindow(fakeWindow()); + const roles: boolean[] = []; + await mod.ensurePeerNet((held) => roles.push(held)); + + expect(roles).toEqual([]); + expect(mod.isPeerBroker()).toBe(false); + // No retry loop: what was there is untouched a second later. + await tick(150); + expect((await stat(peerDir)).isFile()).toBe(true); + // And a later caller is answered immediately rather than restarting it. + await mod.ensurePeerNet(() => {}); + expect(mod.isPeerBroker()).toBe(false); + }); +}); diff --git a/vscode-ext/test/processed-pty-streams.test.ts b/vscode-ext/test/processed-pty-streams.test.ts new file mode 100644 index 00000000..f71b742d --- /dev/null +++ b/vscode-ext/test/processed-pty-streams.test.ts @@ -0,0 +1,204 @@ +/** + * The keyed registry both consumers of this window's own PTY output share. What + * matters here is the tax: these listeners run on every chunk of every terminal, + * so there must be exactly one pair no matter how many attachments exist, and + * none at all when there are none. + */ + +import { describe, expect, it } from 'vitest'; +import { createProcessedPtyStreams } from '../src/processed-pty-streams'; + +/** Stands in for `message-router`'s processed-data fan-out, counting listeners. */ +function fakeSource() { + const data = new Set<(id: string, chunk: string) => void>(); + const exit = new Set<(id: string, exitCode: number) => void>(); + const statuses = new Map(); + return { + /** How many listener pairs are installed right now. */ + get installed(): number { + return data.size + exit.size; + }, + emitData(id: string, chunk: string): void { + statuses.set(id, { alive: true }); + for (const listener of [...data]) listener(id, chunk); + }, + emitExit(id: string, exitCode: number): void { + // The real manager records liveness before it fans out the processed + // exit, so a listener installed later sees the same ordering. + statuses.set(id, { alive: false, exitCode }); + for (const listener of [...exit]) listener(id, exitCode); + }, + spawn(id: string): void { + statuses.set(id, { alive: true }); + }, + streams: () => + createProcessedPtyStreams( + (listener) => { + data.add(listener); + return () => void data.delete(listener); + }, + (listener) => { + exit.add(listener); + return () => void exit.delete(listener); + }, + (id) => statuses.get(id) ?? { alive: true }, + ), + }; +} + +function sink() { + return { + data: [] as string[], + exits: [] as number[], + onData(chunk: string) { + this.data.push(chunk); + }, + onExit(code: number) { + this.exits.push(code); + }, + }; +} + +describe('processed pty streams', () => { + it('costs nothing until something attaches, and nothing again after', () => { + const source = fakeSource(); + const streams = source.streams(); + expect(source.installed).toBe(0); + + const stop = streams.streamPty('pty-1', sink()); + expect(source.installed).toBe(2); + + stop(); + expect(source.installed).toBe(0); + }); + + it('installs one listener pair for every attachment there is', () => { + // The whole point: a pair per attachment would tax every keystroke of every + // terminal in the window once per attached surface. + const source = fakeSource(); + const streams = source.streams(); + const stops = [ + streams.streamPty('pty-1', sink()), + streams.streamPty('pty-1', sink()), + streams.streamPty('pty-2', sink()), + ]; + + expect(source.installed).toBe(2); + // And the pair stays until the *last* attachment goes. + stops[0]!(); + stops[1]!(); + expect(source.installed).toBe(2); + stops[2]!(); + expect(source.installed).toBe(0); + }); + + it('fans one PTY to every sink watching it, and to no others', () => { + const source = fakeSource(); + const streams = source.streams(); + const first = sink(); + const second = sink(); + const elsewhere = sink(); + streams.streamPty('pty-1', first); + streams.streamPty('pty-1', second); + streams.streamPty('pty-2', elsewhere); + + source.emitData('pty-1', 'hello'); + source.emitData('pty-3', 'nobody is watching this'); + + expect(first.data).toEqual(['hello']); + expect(second.data).toEqual(['hello']); + expect(elsewhere.data).toEqual([]); + }); + + it('stops one sink without silencing the other', () => { + const source = fakeSource(); + const streams = source.streams(); + const first = sink(); + const second = sink(); + const stopFirst = streams.streamPty('pty-1', first); + streams.streamPty('pty-1', second); + + stopFirst(); + source.emitData('pty-1', 'still flowing'); + + expect(first.data).toEqual([]); + expect(second.data).toEqual(['still flowing']); + }); + + it('tears every sink on a PTY down when it exits', () => { + const source = fakeSource(); + const streams = source.streams(); + const first = sink(); + const second = sink(); + const other = sink(); + const stopFirst = streams.streamPty('pty-1', first); + streams.streamPty('pty-1', second); + streams.streamPty('pty-2', other); + + source.emitExit('pty-2', 3); + source.emitExit('pty-1', 17); + + expect(first.exits).toEqual([17]); + expect(second.exits).toEqual([17]); + expect(other.exits).toEqual([3]); + + // Nothing is attached any more, so the terminals go back to costing nothing + // — without anyone having to call the unsubscribe. + expect(source.installed).toBe(0); + // And an unsubscribe afterwards is a no-op rather than an error. + expect(() => stopFirst()).not.toThrow(); + source.emitData('pty-1', 'after the exit'); + expect(first.data).toEqual([]); + }); + + it('replays an exit that landed before the stream was installed', () => { + const source = fakeSource(); + const streams = source.streams(); + source.emitExit('pty-1', 23); + + const late = sink(); + const stop = streams.streamPty('pty-1', late); + + expect(late.exits).toEqual([23]); + expect(source.installed).toBe(0); + expect(() => stop()).not.toThrow(); + }); + + it('survives a sink that unsubscribes itself from inside its own exit', () => { + // Which is exactly what an attachment does: the exit is what tells it to + // let go, and it lets go by calling the unsubscribe it is holding. + const source = fakeSource(); + const streams = source.streams(); + const seen: number[] = []; + const attachment: { stop?: () => void } = {}; + attachment.stop = streams.streamPty('pty-1', { + onData: () => {}, + onExit: (code) => { + seen.push(code); + attachment.stop?.(); + }, + }); + + expect(() => source.emitExit('pty-1', 9)).not.toThrow(); + expect(seen).toEqual([9]); + expect(source.installed).toBe(0); + }); + + it('gives a re-attach after an exit a stream of its own', () => { + const source = fakeSource(); + const streams = source.streams(); + const before = sink(); + const stopBefore = streams.streamPty('pty-1', before); + source.emitExit('pty-1', 0); + + source.spawn('pty-1'); + const after = sink(); + streams.streamPty('pty-1', after); + // The dead attachment's unsubscribe must not reach into the live one. + stopBefore(); + source.emitData('pty-1', 'a new terminal on the same id'); + + expect(after.data).toEqual(['a new terminal on the same id']); + expect(source.installed).toBe(2); + }); +}); diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts new file mode 100644 index 00000000..ff3b3fe4 --- /dev/null +++ b/vscode-ext/test/remote-host.test.ts @@ -0,0 +1,1037 @@ +/** + * The extension host's binding of the Host service: where its enrollment and + * ACL live, which window is allowed to run it, and the provider it serves + * remote-api v1 through. The service itself is covered in + * `lib/src/host/remote/service.test.ts`; this is the glue that only exists here. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type Server, type Socket } from 'node:net'; +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ENROLLMENT_KEY } from '../../lib/src/remote/host/store'; +import type { ExtensionMessage } from '../src/message-types'; +import { FrameDecoder, encodeFrame } from '../src/peer-link-protocol'; +import { createProcessedPtyStreams } from '../src/processed-pty-streams'; +import { + derivedSocketPath as socketPathFor, + fakeSink, + fakeWindow, + freshModule, + removeDir, + tempStorageDir, + tick, + waitFor, +} from './helpers'; + +type HostModule = typeof import('../src/remote-host'); +type LinkModule = typeof import('../src/peer-link'); + +let dir: string; +let realTmp: string | undefined; +/** Every link this test opened; the last one belongs to the module under test. */ +const links: LinkModule[] = []; +let opened: LinkModule | null = null; +let squatter: Server | null = null; +const squatted: Socket[] = []; + +const derivedSocketPath = (): string => socketPathFor(dir); + +/** One `secrets.onDidChange` subscriber, as VS Code hands them out. */ +interface SecretWatcher { + (event: { key: string }): void; +} + +interface PendingGlobalWrite { + key: string; + value: unknown; + finish(): void; +} + +/** The slice of `ExtensionContext` the store reads, in memory. */ +function fakeContext(options: { deferGlobalWrites?: PendingGlobalWrite[] } = {}) { + const secrets = new Map(); + const global = new Map(); + const watchers = new Set(); + /** Every keychain round trip, so a test can see the memo working. */ + const reads: string[] = []; + /** What `SecretStorage` does across every window of one extension. */ + const announce = (key: string) => { + for (const watcher of [...watchers]) watcher({ key }); + }; + return { + store: { secrets, global, announce, watchers, reads }, + context: { + globalStorageUri: { fsPath: dir }, + subscriptions: [] as unknown[], + secrets: { + get: async (key: string) => { + reads.push(key); + return secrets.get(key); + }, + store: async (key: string, value: string) => { + secrets.set(key, value); + announce(key); + }, + delete: async (key: string) => { + secrets.delete(key); + announce(key); + }, + onDidChange: (watcher: SecretWatcher) => { + watchers.add(watcher); + return { dispose: () => void watchers.delete(watcher) }; + }, + }, + globalState: { + get: (key: string) => global.get(key), + update: (key: string, value: unknown) => { + const apply = () => { + if (value === undefined) global.delete(key); + else global.set(key, value as string); + }; + if (!options.deferGlobalWrites) { + apply(); + return Promise.resolve(); + } + return new Promise((resolve) => { + options.deferGlobalWrites!.push({ + key, + value, + finish: () => { + apply(); + resolve(); + }, + }); + }); + }, + keys: () => [...global.keys()], + }, + } as never, + }; +} + +function fakeDeps() { + const posted: ExtensionMessage[] = []; + const asked: Array<{ op: string; params: unknown }> = []; + const dataListeners = new Set<(id: string, data: string) => void>(); + const exitListeners = new Set<(id: string, exitCode: number) => void>(); + const ptyStatuses = new Map(); + const streams = createProcessedPtyStreams( + (listener) => { + dataListeners.add(listener); + return () => void dataListeners.delete(listener); + }, + (listener) => { + exitListeners.add(listener); + return () => void exitListeners.delete(listener); + }, + (id) => ptyStatuses.get(id) ?? { alive: true }, + ); + return { + posted, + asked, + emitData: (id: string, data: string) => { + ptyStatuses.set(id, { alive: true }); + for (const listener of dataListeners) listener(id, data); + }, + emitExit: (id: string, exitCode: number) => { + ptyStatuses.set(id, { alive: false, exitCode }); + for (const listener of exitListeners) listener(id, exitCode); + }, + answers: new Map(), + deps(): Parameters[0] { + return { + brokerRequest: async (op, params) => { + asked.push({ op, params }); + return this.answers.get(op) ?? []; + }, + broadcastToWebviews: (message) => void posted.push(message), + writePty: () => {}, + resizePty: () => {}, + streamPty: streams.streamPty, + }; + }, + }; +} + +/** A fresh copy of the module pair, so one process can play several windows. */ +async function freshHost() { + vi.resetModules(); + const mod = (await import('../src/remote-host')) as HostModule; + opened = (await import('../src/peer-link')) as LinkModule; + opened.initPeerLink(fakeContext().context); + links.push(opened); + return mod; +} + +/** + * The wiring `message-router.ts` does at module load. Without it a forwarded + * command reaches the link and stops there, which is the bug this shape exists + * to make visible. + */ +function bridgeLinkToHost( + mod: HostModule, + link: LinkModule, + bound: ReturnType, +): void { + const local = bound.deps(); + link.configurePeerLink({ + brokerRequest: local.brokerRequest, + invalidateDirectory: mod.notifyDirectoryChanged, + ownsPty: () => false, + streamPty: local.streamPty, + writePty: local.writePty, + resizePty: local.resizePty, + handleForwardedCommand: mod.handleForwardedCommand, + dropForwardedCommands: mod.dropForwardedCommands, + deliverCommandResult: mod.deliverCommandResult, + deliverUiEvent: mod.deliverUiEvent, + onClientAuthenticated: mod.greetPeerWindow, + }); +} + +/** Another window on the same socket — the link half of one, which is all the far tier is. */ +async function openFarWindow(side: ReturnType): Promise { + const link = await freshModule(() => import('../src/peer-link')); + link.initPeerLink(fakeContext().context); + link.configurePeerLink(side.deps()); + links.push(link); + await link.ensurePeerNet(() => {}); + return link; +} + +/** + * Occupy the socket, so the module under test can only ever be a client. + * + * It has to complete the real handshake — the token file is right there in the + * storage dir, which is what a *legitimate* second window has too — because a + * client that cannot verify the welcome disconnects and forwards nothing. + */ +async function otherWindowHoldsTheHost(): Promise<{ frames: Array<{ kind: string }> }> { + const frames: Array<{ kind: string }> = []; + const { createHmac } = await import('node:crypto'); + const { readFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const path = derivedSocketPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + // Sockets are kept so cleanup can drop them: `close()` waits for every live + // connection, and this stand-in has no lifecycle of its own to end them. + const server = createServer((socket) => { + squatted.push(socket); + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.write(encodeFrame({ kind: 'challenge', nonce: 'broker-nonce' })); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const typed = frame as { kind: string; nonce?: string }; + frames.push(typed); + if (typed.kind !== 'hello') continue; + void (async () => { + const token = (await readFile(join(dir, 'remote-host.peer-token'), 'utf8')).trim(); + socket.write( + encodeFrame({ + kind: 'welcome', + proof: createHmac('sha256', token) + .update(`server:${typed.nonce ?? ''}`) + .digest('base64url'), + }), + ); + })(); + } + }); + }); + await new Promise((resolve) => server.listen(path, resolve)); + squatter = server; + return { frames }; +} + +function results(posted: ExtensionMessage[]) { + return posted + .filter((message) => message.type === 'remoteHost:result') + .map((message) => (message as { payload: { rhId: string; error?: string } }).payload); +} + +beforeEach(async () => { + dir = await tempStorageDir(); + realTmp = process.env.TMPDIR; + process.env.TMPDIR = dir; +}); + +afterEach(async () => { + // Clients before the broker: disposing the broker first sends every client + // back into the contention, which recreates files under `dir` as it is + // removed. + for (const link of [...links].reverse()) await link.disposePeerLink(); + links.length = 0; + opened = null; + for (const socket of squatted) socket.destroy(); + squatted.length = 0; + if (squatter) await new Promise((resolve) => squatter!.close(resolve)); + squatter = null; + if (realTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = realTmp; + vi.unstubAllGlobals(); + await removeDir(dir); +}); + +describe('host state store', () => { + it('round-trips the enrollment through SecretStorage', async () => { + const { VsCodeHostStateStore } = await import('../src/remote-host-store'); + const { context, store } = fakeContext(); + const target = new VsCodeHostStateStore(context); + const enrollment = { + serverUrl: 'https://relay.dormouse.sh', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.dormouse.sh', + rpId: 'relay.dormouse.sh', + }; + + await target.saveEnrollment(enrollment); + // The bearer credential belongs in the keychain, never in globalState. + expect(store.global.size).toBe(0); + expect(await target.loadEnrollment()).toEqual(enrollment); + + await target.clearEnrollment(); + expect(await target.loadEnrollment()).toBeNull(); + }); + + it('reads an enrollment the webview-resident Host left behind', async () => { + // The legacy path wrote the same JSON string under the same key through + // `store:write`, so an already-enrolled installation needs no migration. + const { VsCodeHostStateStore } = await import('../src/remote-host-store'); + const { context, store } = fakeContext(); + const enrollment = { + serverUrl: 'https://relay.dormouse.sh', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.dormouse.sh', + rpId: 'relay.dormouse.sh', + }; + store.secrets.set('dormouse.remote-host.enrollment', JSON.stringify(enrollment)); + store.global.set( + 'dormouse.remote-host.acl.host-1', + JSON.stringify([{ hostId: 'host-1', devicePublicKey: 'device-1' }]), + ); + + const target = new VsCodeHostStateStore(context); + expect(await target.loadEnrollment()).toEqual(enrollment); + expect(await target.loadAcl('host-1')).toEqual([ + { hostId: 'host-1', devicePublicKey: 'device-1' }, + ]); + }); + + it('forgets a failed keychain read instead of memoizing it', async () => { + // A locked or keyring-less keychain rejects `secrets.get`; that says + // nothing about what the store holds. A memoized rejection would leave an + // enrolled window silently Host-less until reload — `onDidChange` only + // fires on a write, so nothing else ever retries the read. + const { VsCodeHostStateStore } = await import('../src/remote-host-store'); + const { context, store } = fakeContext(); + const enrollment = { + serverUrl: 'https://relay.dormouse.sh', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.dormouse.sh', + rpId: 'relay.dormouse.sh', + }; + store.secrets.set('dormouse.remote-host.enrollment', JSON.stringify(enrollment)); + const workingGet = context.secrets.get; + context.secrets.get = async () => { + throw new Error('keychain is locked'); + }; + + const target = new VsCodeHostStateStore(context); + await expect(target.loadEnrollment()).rejects.toThrow('keychain is locked'); + + context.secrets.get = workingGet; + expect(await target.loadEnrollment()).toEqual(enrollment); + }); + + it('re-reads the enrollment after another window changed it', async () => { + // The memo is a keychain round trip saved, but `SecretStorage` is shared by + // every window of the extension: without invalidation a window that read it + // once could keep serving a Host another window cleared, or never see one + // another window created. + const { VsCodeHostStateStore } = await import('../src/remote-host-store'); + const { context, store } = fakeContext(); + const changes: number[] = []; + const target = new VsCodeHostStateStore(context, () => changes.push(1)); + const enrollment = { + serverUrl: 'https://relay.dormouse.sh', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.dormouse.sh', + rpId: 'relay.dormouse.sh', + }; + store.secrets.set(ENROLLMENT_KEY, JSON.stringify(enrollment)); + + expect(await target.loadEnrollment()).toEqual(enrollment); + // Memoized: a second read costs no round trip. + await target.loadEnrollment(); + expect(store.reads).toHaveLength(1); + + // Another window cleared it. + store.secrets.delete(ENROLLMENT_KEY); + store.announce(ENROLLMENT_KEY); + expect(await target.loadEnrollment()).toBeNull(); + expect(store.reads).toHaveLength(2); + expect(changes).toHaveLength(1); + + // Some other secret changing is none of its business. + store.secrets.set(ENROLLMENT_KEY, JSON.stringify(enrollment)); + store.announce('some.other.secret'); + expect(await target.loadEnrollment()).toBeNull(); + expect(store.reads).toHaveLength(2); + + target.dispose(); + store.announce(ENROLLMENT_KEY); + expect(changes).toHaveLength(1); + }); + + it('drops records that name a different host, and unreadable values', async () => { + const { VsCodeHostStateStore } = await import('../src/remote-host-store'); + const { context, store } = fakeContext(); + const target = new VsCodeHostStateStore(context); + + await target.saveAcl('host-1', [{ hostId: 'host-2' } as never, { hostId: 'host-1' } as never]); + expect(await target.loadAcl('host-1')).toEqual([{ hostId: 'host-1' }]); + + store.secrets.set('dormouse.remote-host.enrollment', 'not json'); + expect(await target.loadEnrollment()).toBeNull(); + store.global.set('dormouse.remote-host.acl.host-9', 'not json'); + expect(await target.loadAcl('host-9')).toEqual([]); + }); + + it('serializes ACL snapshots so an older approval cannot land last', async () => { + const { VsCodeHostStateStore } = await import('../src/remote-host-store'); + const pending: PendingGlobalWrite[] = []; + const { context } = fakeContext({ deferGlobalWrites: pending }); + const target = new VsCodeHostStateStore(context); + const first = [{ hostId: 'host-1', devicePublicKey: 'device-1' }] as never; + const second = [ + { hostId: 'host-1', devicePublicKey: 'device-1' }, + { hostId: 'host-1', devicePublicKey: 'device-2' }, + ] as never; + + const firstSave = target.saveAcl('host-1', first); + const secondSave = target.saveAcl('host-1', second); + await tick(); + expect(pending).toHaveLength(1); + + pending[0]!.finish(); + await firstSave; + await tick(); + expect(pending).toHaveLength(2); + pending[1]!.finish(); + await secondSave; + + expect(await target.loadAcl('host-1')).toEqual(second); + }); +}); + +describe('remote host service glue', () => { + it('bootstraps the contention on the first enroll, then runs the command', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + // No enrollment yet, so activation binds nothing. + mod.initRemoteHost(fakeContext().context); + expect(opened!.isPeerBroker()).toBe(false); + + mod.handleRemoteHostCommand({ + rhId: 'rh-1', + cmd: 'enroll', + params: { serverUrl: 'https://evil.example', password: 'p', label: 'Laptop' }, + }); + + await waitFor(() => results(bound.posted).length > 0); + // The service ran it (and refused the origin), rather than the interim + // "another window" answer — which is what proves this window took the Host. + expect(opened!.isPeerBroker()).toBe(true); + expect(results(bound.posted)[0]).toMatchObject({ + rhId: 'rh-1', + error: expect.stringContaining('allowed remote sources'), + }); + }); + + it('forwards a command to the window that holds the Host', async () => { + const squat = await otherWindowHoldsTheHost(); + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + mod.initRemoteHost(fakeContext().context); + + // Nothing has contended yet, so there is no Host here and no socket to + // reach one through. `status` is answered as an idle service would rather + // than refused: this window sees no enrollment, which is what "not enrolled" + // *is*, and an error there tells `enrolled-gate.ts` nothing it can act on. + mod.handleRemoteHostCommand({ rhId: 'rh-1', cmd: 'status' }); + expect(results(bound.posted)).toEqual([ + { + rhId: 'rh-1', + result: { + enrolled: false, + serverUrl: null, + hostId: null, + connection: 'stopped', + pairedClients: 0, + }, + }, + ]); + + // `enroll` bootstraps the contention, which this window loses — so even the + // bootstrap ends up forwarded rather than starting a second Host. + const params = { serverUrl: 'https://relay.dormouse.sh', password: 'p', label: 'Laptop' }; + mod.handleRemoteHostCommand({ rhId: 'rh-2', cmd: 'enroll', params }); + + await waitFor(() => squat.frames.some((frame) => frame.kind === 'command')); + expect(squat.frames.find((frame) => frame.kind === 'command')).toEqual({ + kind: 'command', + payload: { rhId: 'rh-2', cmd: 'enroll', params }, + }); + expect(opened!.isPeerBroker()).toBe(false); + // The broker answers it; this window must not answer it too. + expect(results(bound.posted)).toHaveLength(1); + }); + + it('hands the broker\'s answers and events to its own webviews', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + + mod.deliverCommandResult({ rhId: 'rh-1', result: { enrolled: true } }); + mod.deliverUiEvent({ name: 'pairing-queue', queue: [] }); + + // Broadcast, like a local result: only the adapter that minted the `rhId` + // holds a pending command for it, and any webview may show the modal. + expect(bound.posted).toEqual([ + { type: 'remoteHost:result', payload: { rhId: 'rh-1', result: { enrolled: true } } }, + { type: 'remoteHost:event', payload: { name: 'pairing-queue', queue: [] } }, + ]); + }); + + it('ignores a malformed command rather than answering one', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + mod.handleRemoteHostCommand(undefined); + mod.handleRemoteHostCommand({ rhId: 'rh-1' } as never); + expect(bound.posted).toEqual([]); + }); + + it('holds a command that arrives while the contention is still settling', async () => { + // An enrolled machine contends at activation, and that takes a bind and a + // handshake. Refusing in that window tells the webview it has no Host + // seconds before it gets one, and the gates that arm on that answer stay + // down until the whole window reloads. + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + const { context, store } = fakeContext(); + // Outside this build's allowed sources, so the service starts and idles + // rather than opening a real relay socket. + store.secrets.set( + ENROLLMENT_KEY, + JSON.stringify({ + serverUrl: 'https://relay.example.com', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.example.com', + rpId: 'relay.example.com', + }), + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mod.initRemoteHost(context); + // Enough microtasks for the enrollment probe to land and the contention to + // start, and far too few for any of its filesystem work to finish. + for (let i = 0; i < 8; i++) await Promise.resolve(); + + mod.handleRemoteHostCommand({ rhId: 'rh-1', cmd: 'status' }); + expect(results(bound.posted)).toEqual([]); + + await waitFor(() => results(bound.posted).length > 0); + // Answered by the Host this window went on to run, not refused. + expect(results(bound.posted)[0]).toMatchObject({ rhId: 'rh-1' }); + expect(results(bound.posted)[0]!.error).toBeUndefined(); + expect(opened!.isPeerBroker()).toBe(true); + warn.mockRestore(); + }); + + it('refuses at once when the contention can never settle', async () => { + // Deactivation, or a window with no storage location: `ensurePeerNet` + // returns without a role and no settle notification is coming. A held + // command would sit out its whole queue budget before anyone told the + // webview there is nothing to reach. + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + mod.initRemoteHost(fakeContext().context); + await opened!.disposePeerLink(); + + mod.handleRemoteHostCommand({ + rhId: 'rh-1', + cmd: 'enroll', + params: { serverUrl: 'https://relay.dormouse.sh', password: 'p', label: 'Laptop' }, + }); + + await tick(0); + expect(results(bound.posted)).toEqual([{ rhId: 'rh-1', error: 'no remote Host is reachable' }]); + }); + + it('answers the read-only commands like an idle service when there is no Host at all', async () => { + // A window that never enrolled is the ordinary state, not a failure — it + // contends the moment an enrollment exists anywhere, so reaching the + // refusal means there genuinely is none. Erroring there broke each + // caller's contract: `pushDevices` answers `null` for "nowhere to push" + // and rejects only when the server could not be asked, so the Settings + // dialog was reporting an unreachable server on an un-enrolled machine. + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + mod.initRemoteHost(fakeContext().context); + await tick(); + + mod.handleRemoteHostCommand({ rhId: 'rh-status', cmd: 'status' }); + mod.handleRemoteHostCommand({ rhId: 'rh-pushDevices', cmd: 'pushDevices' }); + mod.handleRemoteHostCommand({ rhId: 'rh-pairingQueue', cmd: 'pairingQueue' }); + // Everything else still says there is nothing to reach. + mod.handleRemoteHostCommand({ rhId: 'rh-clear', cmd: 'clearEnrollment' }); + expect(results(bound.posted).find((r) => r.rhId === 'rh-clear')).toEqual({ + rhId: 'rh-clear', + error: 'no remote Host is reachable', + }); + + // And they are exactly what a real service with nothing in its store says, + // which is the answer the sidecar's webviews get for the same commands. + const { RemoteHostService } = await import('../../lib/src/host/remote/service'); + const { createEphemeralHostStateStore } = await import( + '../../lib/src/host/remote/host-state-store' + ); + const sent: Array<{ event: string; data: { rhId: string; result?: unknown } }> = []; + const idle = new RemoteHostService({ + store: createEphemeralHostStateStore(() => {}), + provider: { + collectDirectory: async () => [], + watchDirectory: () => () => {}, + resolveSurface: async () => null, + writePty: () => {}, + resizePty: () => {}, + streamPty: () => () => {}, + }, + sendToUi: (event, data) => void sent.push({ event, data: data as never }), + connectSrc: 'https://*.dormouse.sh wss://*.dormouse.sh', + }); + await idle.start(); + for (const cmd of ['status', 'pushDevices', 'pairingQueue']) { + await idle.handleCommand({ rhId: `rh-${cmd}`, cmd }); + } + idle.dispose(); + + const byId = (entries: Array<{ rhId: string; result?: unknown }>) => + Object.fromEntries( + entries.filter((entry) => entry.rhId !== 'rh-clear').map((entry) => [entry.rhId, entry.result]), + ); + expect(byId(results(bound.posted))).toEqual( + byId(sent.filter((message) => message.event === 'remoteHost:result').map((m) => m.data)), + ); + }); + + it('contends when another window enrolls, without a reload', async () => { + // This window was un-enrolled at activation, so it never contended and has + // no socket and no broker to hear from. The shared `SecretStorage` is the + // only signal it gets that a Host now exists. + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + const { context, store } = fakeContext(); + mod.initRemoteHost(context); + await tick(); + expect(opened!.isPeerBroker()).toBe(false); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + store.secrets.set( + ENROLLMENT_KEY, + JSON.stringify({ + serverUrl: 'https://relay.example.com', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.example.com', + rpId: 'relay.example.com', + }), + ); + store.announce(ENROLLMENT_KEY); + + await waitFor(() => opened!.isPeerBroker()); + warn.mockRestore(); + }); +}); + +describe('the relay socket', () => { + /** A `ws` server that greets, echoes one frame, and closes with a code. */ + async function wsServer() { + const { WebSocketServer } = await import('ws'); + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await new Promise((resolve) => server.on('listening', resolve)); + server.on('connection', (socket) => { + socket.send('hello from the relay'); + socket.on('message', () => socket.close(4001, 'done')); + }); + const { port } = server.address() as { port: number }; + return { url: `ws://127.0.0.1:${port}`, close: () => server.close() }; + } + + it('uses the bundled ws where the extension host has no global WebSocket', async () => { + // `globalThis.WebSocket` arrived in Node 22, and `engines.vscode` is + // `^1.85.0` — VS Code 1.85 shipped Node 18, so the supported range spans + // the boundary and the fallback is the only implementation on the old side. + const mod = await freshHost(); + const relay = await wsServer(); + vi.stubGlobal('WebSocket', undefined); + try { + const socket = mod.createRelaySocket(relay.url); + const received: string[] = []; + const closes: number[] = []; + // Exactly the surface `RemoteHost` reads, and nothing more. + socket.addEventListener('message', (ev) => { + received.push(String((ev as { data?: unknown }).data)); + }); + socket.addEventListener('close', (ev) => { + closes.push(Number((ev as { code?: unknown }).code)); + }); + await new Promise((resolve) => socket.addEventListener('open', () => resolve())); + expect(socket.readyState).toBe(1); + + await waitFor(() => received.length > 0); + expect(received).toEqual(['hello from the relay']); + + socket.send(JSON.stringify({ t: 'hello' })); + await waitFor(() => closes.length > 0); + expect(closes).toEqual([4001]); + } finally { + relay.close(); + } + }); + + it('prefers whatever the extension host already provides', async () => { + const mod = await freshHost(); + const built: string[] = []; + class PlatformSocket { + constructor(url: string) { + built.push(url); + } + } + vi.stubGlobal('WebSocket', PlatformSocket); + expect(mod.createRelaySocket('wss://relay.dormouse.sh/ws/host')).toBeInstanceOf(PlatformSocket); + expect(built).toEqual(['wss://relay.dormouse.sh/ws/host']); + }); +}); + +describe('remote host provider', () => { + it('streams a PTY without stripping it again', async () => { + // The extension host already ran the protocol parser once per chunk and + // answered its queries; a second parser here would answer everything twice + // and corrupt the PTY. What arrives is what the local xterm renders. + const mod = await freshHost(); + const bound = fakeDeps(); + const provider = mod.createRemoteHostProvider(bound.deps()); + const seen: string[] = []; + const exits: number[] = []; + const stream = provider.streamPty('pty-1', { + onData: (data) => void seen.push(data), + onExit: (code) => void exits.push(code), + }); + await stream.ready; + + bound.emitData('pty-1', 'hello\x1b]0;title\x07'); + bound.emitData('pty-other', 'not mine'); + bound.emitExit('pty-other', 3); + bound.emitExit('pty-1', 7); + + expect(seen).toEqual(['hello\x1b]0;title\x07']); + expect(exits).toEqual([7]); + + stream.stop(); + bound.emitData('pty-1', 'after'); + expect(seen).toHaveLength(1); + }); + + it('replays a local PTY exit that preceded provider subscription', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + const provider = mod.createRemoteHostProvider(bound.deps()); + bound.emitExit('pty-1', 23); + + const sink = fakeSink(); + const stream = provider.streamPty('pty-1', sink); + await stream.ready; + + expect(sink.exits).toEqual([23]); + stream.stop(); + }); + + it('asks the webviews for the directory and for an attach', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + bound.answers.set('directory', [{ surfaceId: 'surface-1' }]); + bound.answers.set('surfaceOp', [{ ptyId: 'pty-1', cols: 100, rows: 30 }]); + const provider = mod.createRemoteHostProvider(bound.deps()); + + expect(await provider.collectDirectory()).toEqual([{ surfaceId: 'surface-1' }]); + + const handle = await provider.resolveSurface('surface-1', { cols: 100, rows: 30 }); + expect(handle).toMatchObject({ ptyId: 'pty-1', cols: 100, rows: 30 }); + // Attach-is-the-resize: the size rides the attach, because the owner is the + // only one that can reach its xterm. + expect(bound.asked.at(-1)).toEqual({ + op: 'surfaceOp', + params: { surfaceId: 'surface-1', op: 'attach', cols: 100, rows: 30 }, + }); + }); + + it('reports no surface when nobody answers', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + const provider = mod.createRemoteHostProvider(bound.deps()); + expect(await provider.resolveSurface('nobody', {})).toBeNull(); + }); + + it('leaves the last known size standing when a resize goes unanswered', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + bound.answers.set('surfaceOp', [{ ptyId: 'pty-1', cols: 100, rows: 30 }]); + const provider = mod.createRemoteHostProvider(bound.deps()); + const handle = (await provider.resolveSurface('surface-1', { cols: 100, rows: 30 }))!; + + bound.answers.set('surfaceOp', []); + expect(await handle.resize(120, 40)).toEqual({ cols: 100, rows: 30 }); + expect(handle.cols).toBe(100); + }); + + it('drives a PTY of its own through the pty manager', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + const drove: unknown[] = []; + const provider = mod.createRemoteHostProvider({ + ...bound.deps(), + writePty: (id, data) => void drove.push({ id, data }), + resizePty: (id, cols, rows) => void drove.push({ id, cols, rows }), + }); + + // The link only claims a PTY an attach routed to another window, so a local + // one can never be taken from under the manager that owns it. + provider.writePty('pty-1', 'ls\r'); + provider.resizePty('pty-1', 120, 40); + expect(drove).toEqual([ + { id: 'pty-1', data: 'ls\r' }, + { id: 'pty-1', cols: 120, rows: 40 }, + ]); + }); + + it('fires every directory watcher on an invalidation, and stops after unsubscribe', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + const provider = mod.createRemoteHostProvider(bound.deps()); + let fired = 0; + const stop = provider.watchDirectory(() => { + fired += 1; + }); + + mod.notifyDirectoryChanged(); + expect(fired).toBe(1); + + stop(); + mod.notifyDirectoryChanged(); + expect(fired).toBe(1); + }); +}); + +/** + * The second tier, over a real socket: this module as the broker window and a + * link-only stand-in as the window whose terminals it is serving. + */ +describe('serving the other windows', () => { + /** Bind the socket, wire the link as the router does, and let a peer join. */ + async function brokerWith(far: ReturnType) { + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + bridgeLinkToHost(mod, opened!, bound); + await opened!.ensurePeerNet(() => {}); + const link = await openFarWindow(far); + return { mod, bound, link }; + } + + it('serves a directory of every window, this one first', async () => { + const far = fakeWindow({ entries: [{ surfaceId: 'far-1' }] }); + const { mod, bound } = await brokerWith(far); + bound.answers.set('directory', [{ surfaceId: 'near-1' }]); + const provider = mod.createRemoteHostProvider(bound.deps()); + + // Both tiers at once: whatever the phone is asking about lives in exactly + // one webview of one window, so a serial ask would spend the near tier's + // whole budget before the owner is reached. + await waitFor(async () => (await provider.collectDirectory()).length === 2); + expect(await provider.collectDirectory()).toEqual([ + { surfaceId: 'near-1' }, + { surfaceId: 'far-1' }, + ]); + }); + + it('attaches, streams, and drives a terminal that lives in another window', async () => { + const far = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + const { mod, bound } = await brokerWith(far); + const provider = mod.createRemoteHostProvider(bound.deps()); + + // The attach is what teaches the link where that PTY lives; everything + // after it is routed by that. + let handle: Awaited> = null; + await waitFor(async () => { + handle = await provider.resolveSurface('far-1', { cols: 80, rows: 24 }); + return handle !== null; + }); + + const sink = fakeSink(); + const stream = provider.streamPty(handle!.ptyId, sink); + await stream.ready; + far.emitData('pty-far', 'from the other window'); + await waitFor(() => sink.data.length > 0); + + provider.writePty(handle!.ptyId, 'ls\r'); + provider.resizePty(handle!.ptyId, 120, 40); + await waitFor(() => far.writes.length > 0 && far.resizes.length > 0); + expect(far.writes).toEqual([{ ptyId: 'pty-far', data: 'ls\r' }]); + expect(far.resizes).toEqual([{ ptyId: 'pty-far', cols: 120, rows: 40 }]); + + stream.stop(); + await tick(); + far.emitData('pty-far', 'after the unsubscribe'); + await tick(100); + expect(sink.data).toEqual(['from the other window']); + }); + + it('binds a duplicate restored PTY id to the peer whose surface answer was selected', async () => { + let firstSurfaceOps = 0; + let secondSurfaceOps = 0; + const restoredSurface = { ptyId: 'restored-pty', cols: 80, rows: 24 }; + const first = fakeWindow({ + entries: [{ surfaceId: 'restored-surface' }], + surfaces: new Proxy( + { 'restored-surface': restoredSurface }, + { + get: (target, property, receiver) => { + if (property === 'restored-surface') firstSurfaceOps += 1; + return Reflect.get(target, property, receiver) as typeof restoredSurface; + }, + }, + ), + }); + const second = fakeWindow({ + entries: [{ surfaceId: 'restored-surface' }], + surfaces: new Proxy( + { 'restored-surface': restoredSurface }, + { + get: (target, property, receiver) => { + if (property === 'restored-surface') secondSurfaceOps += 1; + return Reflect.get(target, property, receiver) as typeof restoredSurface; + }, + }, + ), + }); + const { mod, bound } = await brokerWith(first); + await openFarWindow(second); + const localFallbacks: unknown[] = []; + const providerDeps = bound.deps(); + providerDeps.writePty = (ptyId, data) => void localFallbacks.push({ ptyId, data }); + providerDeps.resizePty = (ptyId, cols, rows) => + void localFallbacks.push({ ptyId, cols, rows }); + const provider = mod.createRemoteHostProvider(providerDeps); + + const handle = await provider.resolveSurface('restored-surface', { cols: 80, rows: 24 }); + expect(handle).not.toBeNull(); + // The key held by SurfaceHandle is provider-local, not the colliding id + // understood inside either peer window. + expect(handle!.ptyId).not.toBe('restored-pty'); + + // The read-only resolve reaches both duplicate claimants; the mutating + // attach and every follow-up address only the retained first peer. + expect([firstSurfaceOps, secondSurfaceOps]).toEqual([2, 1]); + await handle!.resize(100, 30); + expect([firstSurfaceOps, secondSurfaceOps]).toEqual([3, 1]); + + const sink = fakeSink(); + const stream = provider.streamPty(handle!.ptyId, sink); + await stream.ready; + first.emitData('restored-pty', 'selected peer'); + second.emitData('restored-pty', 'other peer'); + await waitFor(() => sink.data.length > 0); + expect(sink.data).toEqual(['selected peer']); + + provider.writePty(handle!.ptyId, 'pwd\r'); + await waitFor(() => first.writes.length > 0); + expect(first.writes).toEqual([{ ptyId: 'restored-pty', data: 'pwd\r' }]); + expect(second.writes).toEqual([]); + + first.emitExit('restored-pty', 17); + await waitFor(() => sink.exits.length > 0); + provider.writePty(handle!.ptyId, 'after exit'); + provider.resizePty(handle!.ptyId, 120, 40); + expect(localFallbacks).toEqual([]); + stream.stop(); + }); + + it('tells a joining window whether there is a Host, without waiting for a change', async () => { + // `status` events are emitted when the Host's lifecycle changes, and a + // window connecting changes nothing — so a window opened after the + // enrollment would sit disarmed until the user reloaded it. + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + bridgeLinkToHost(mod, opened!, bound); + mod.initRemoteHost(fakeContext().context); + mod.handleRemoteHostCommand({ + rhId: 'rh-0', + cmd: 'enroll', + params: { serverUrl: 'https://evil.example', password: 'p', label: 'Laptop' }, + }); + await waitFor(() => opened!.isPeerBroker()); + + const far = fakeWindow(); + await openFarWindow(far); + + await waitFor(() => far.uiEvents.length > 0); + // Addressed to the window that joined, and nothing else is invented for it. + expect(far.uiEvents).toEqual([{ name: 'status', enrolled: false }]); + }); + + it('answers a forwarded command over the link and nowhere else', async () => { + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + bridgeLinkToHost(mod, opened!, bound); + mod.initRemoteHost(fakeContext().context); + // The enroll bootstrap is the shortest way to a bound socket with a running + // service; the origin is refused, which does not stop it running. + mod.handleRemoteHostCommand({ + rhId: 'rh-0', + cmd: 'enroll', + params: { serverUrl: 'https://evil.example', password: 'p', label: 'Laptop' }, + }); + await waitFor(() => opened!.isPeerBroker()); + + const far = fakeWindow(); + const link = await openFarWindow(far); + expect(link.forwardCommand({ rhId: 'rh-9', cmd: 'status' })).toBe(true); + + await waitFor(() => far.results.length > 0); + expect(far.results[0]).toMatchObject({ rhId: 'rh-9', result: { enrolled: false } }); + // Not broadcast here as well: an `rhId` belongs to one window's adapter, so + // a copy would settle nothing and would show that window's Host state to + // webviews that never asked. + expect(results(bound.posted).some((result) => result.rhId === 'rh-9')).toBe(false); + }); +}); diff --git a/vscode-ext/test/vscode-stub.ts b/vscode-ext/test/vscode-stub.ts new file mode 100644 index 00000000..6aed6a99 --- /dev/null +++ b/vscode-ext/test/vscode-stub.ts @@ -0,0 +1,16 @@ +/** + * Stands in for the `vscode` module under test. + * + * The extension host modules worth unit-testing barely touch the API — most + * import it as `import type`, which erases. What is left is the output channel + * `log.ts` opens, so that is all this provides. Anything else is deliberately + * absent: a test that reaches further should fail loudly rather than pass + * against a fake that quietly does nothing. + */ + +export const window = { + createOutputChannel: () => ({ + appendLine: () => {}, + dispose: () => {}, + }), +}; diff --git a/vscode-ext/vitest.config.mts b/vscode-ext/vitest.config.mts new file mode 100644 index 00000000..0bcb7ab7 --- /dev/null +++ b/vscode-ext/vitest.config.mts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Unit tests for the extension host. The `vscode` module only exists inside a + * running VS Code, so it is aliased to a stub; everything under test either + * imports it as a type (erased) or goes through `log.ts`. + * + * Modules that genuinely need the real editor — commands, webview hosting — are + * not covered here and would need `@vscode/test-electron`. + */ +export default defineConfig({ + resolve: { + alias: { + vscode: new URL('test/vscode-stub.ts', import.meta.url).pathname, + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +});