From a7fe522045a545abffb388861515aa4a5957ad57 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 17 Aug 2026 23:57:31 -0700 Subject: [PATCH 01/56] Add SELF_HOST.md: run the coordinating server on your own tailnet An assistant-run playbook for self-hosting the Dormouse `server` behind Tailscale. Above the fold it covers the only path that exists today: build the current checkout into a self-contained release under Application Support, run it from a macOS LaunchAgent bound to loopback, and put `tailscale serve` in front for private HTTPS at the laptop's tailnet name. The always-on cloud relay (DigitalOcean + continuous deployment from `main`) is designed but unbuilt, so it lives under `## Future` as the `always-on-relay` scope per the AGENTS.md spec-lifecycle conventions. Notes on two choices the runbook makes: - The installed service listens on 3100, not 3000, because `dev:server` and `dev:pocket-server` both take 3000 on the same laptop that runs the installed copy. - It requires adding `DORMOUSE_BIND_HOST`. `server/src/index.ts` calls `serve({ fetch, port })` with no hostname today, so the server binds every interface; the local install must not expose plaintext 3100 to the LAN or the tailnet. Co-Authored-By: Claude Opus 5 (1M context) --- SELF_HOST.md | 1091 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1091 insertions(+) create mode 100644 SELF_HOST.md diff --git a/SELF_HOST.md b/SELF_HOST.md new file mode 100644 index 00000000..4f30788d --- /dev/null +++ b/SELF_HOST.md @@ -0,0 +1,1091 @@ +# 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 + binary pins its webview `connect-src` to the SaaS origin, so a self-host relay + needs a local build: + + ```sh + DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone + ``` + + `standalone/scripts/tauri.mjs` reads that variable and overrides the + checked-in CSP for that build only. Note the limitation before promising it + works everywhere: `vscode-ext/src/webview-html.ts` hardcodes its webview + `connect-src` with no override hook and no remote origin, so `pnpm + dogfood:vscode` currently produces a Host that cannot reach a tailnet relay. + Use the standalone Host, or widen that CSP first and say so. + +## 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 +``` + +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 both `account.json` and `hosts.json` outside the installed release. + 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`. +- `account.json` and `hosts.json` survive 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. +- 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. + +This also requires the server entrypoint to support an explicit +loopback bind setting. Add a narrowly named variable such as +`DORMOUSE_BIND_HOST`, pass it through the supported `@hono/node-server` listen +option, and cover it with a test. Do not overload an unrelated generic `HOST` +variable. Preserve the current default so the cloud path under `## Future` stays +compatible, but the local configuration must set: + +```dotenv +DORMOUSE_BIND_HOST=127.0.0.1 +``` + +Update `docs/specs/server.md` above the fold with the new configuration and +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 local 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 + +~/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. 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/index.ts`. +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 build +whose `DORMOUSE_REMOTE_CONNECT_SRC` includes `https://*.ts.net wss://*.ts.net`. +After `account.json` and `hosts.json` exist: + +1. Record ownership and checksums 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` +- Standalone CSP override: `docs/specs/standalone.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:** the standalone Host likely lacks the + `*.ts.net` `connect-src` custom build 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 +``` + +### 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/index.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 build. +After `account.json` and `hosts.json` exist: + +1. Record their ownership and checksums without printing their 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. The two JSON files +contain Host bearer credentials even though passkey public keys are not secret, +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. From 27d2fb33e5dec1499e2f91635fad37719bb6c8ec Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 00:07:32 -0700 Subject: [PATCH 02/56] Add DORMOUSE_BIND_HOST so the selfhost install can bind loopback only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server always speaks plain HTTP and expects a TLS proxy in front. When that proxy is local — `tailscale serve` on the same laptop — the listen interface becomes a security boundary: `serve({ fetch, port })` with no hostname bound every interface, so the plaintext port was reachable from the LAN and from the tailnet itself, bypassing the proxy. `DORMOUSE_BIND_HOST` closes that. Unset still binds everything, which is what a container wants (the namespace is the boundary and the port is published explicitly), so this is additive. Env parsing moves out of the entrypoint into `server/src/config.ts` so the mapping is testable without binding a port. `bind-host.test.mjs` spawns the real entrypoint and asserts both halves: loopback answers and a non-loopback address does not when the var is set, and the unbound default still serves every interface when it isn't. Also corrects the runbook's claim about VS Code Hosts. The blocker is not the webview CSP: `enableRemoteHost` is passed only by `standalone/src/main.tsx`, so the shared entrypoint the extension renders never loads the relay, enrollment, or pairing modules. A VS Code Host is a feature, not a build flag. Co-Authored-By: Claude Opus 5 (1M context) --- SELF_HOST.md | 42 ++++++------ docs/specs/server.md | 14 ++++ server/src/config.ts | 55 ++++++++++++++++ server/src/index.ts | 49 +++++++------- server/test/bind-host.test.mjs | 115 +++++++++++++++++++++++++++++++++ server/test/config.test.mjs | 54 ++++++++++++++++ 6 files changed, 285 insertions(+), 44 deletions(-) create mode 100644 server/src/config.ts create mode 100644 server/test/bind-host.test.mjs create mode 100644 server/test/config.test.mjs diff --git a/SELF_HOST.md b/SELF_HOST.md index 4f30788d..618c64bf 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -66,11 +66,15 @@ known: ``` `standalone/scripts/tauri.mjs` reads that variable and overrides the - checked-in CSP for that build only. Note the limitation before promising it - works everywhere: `vscode-ext/src/webview-html.ts` hardcodes its webview - `connect-src` with no override hook and no remote origin, so `pnpm - dogfood:vscode` currently produces a Host that cannot reach a tailnet relay. - Use the standalone Host, or widen that CSP first and say so. + checked-in CSP for that build only. + + The Host must be the standalone app. Remote hosting is standalone-only today: + `enableRemoteHost` is passed just by `standalone/src/main.tsx`, so the shared + webview entrypoint `lib/src/main.tsx` — the one the VS Code extension renders + — never loads the relay, enrollment, or pairing modules at all. That, not the + webview CSP in `vscode-ext/src/webview-html.ts`, is why `pnpm dogfood:vscode` + cannot produce a Host for a self-host relay. Do not offer the user a CSP + override as a fix; supporting a VS Code Host is a feature, not a build flag. ## Architecture @@ -189,22 +193,23 @@ 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. -This also requires the server entrypoint to support an explicit -loopback bind setting. Add a narrowly named variable such as -`DORMOUSE_BIND_HOST`, pass it through the supported `@hono/node-server` listen -option, and cover it with a test. Do not overload an unrelated generic `HOST` -variable. Preserve the current default so the cloud path under `## Future` stays -compatible, but the local configuration must set: +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 ``` -Update `docs/specs/server.md` above the fold with the new configuration and -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 local installer changes an invariant -it audits; this path adds no GitHub workflow or deployment secret. +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 @@ -258,7 +263,8 @@ On each invocation it must: 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/index.ts`. +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. @@ -590,7 +596,7 @@ Also update: 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/index.ts`. + `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 diff --git a/docs/specs/server.md b/docs/specs/server.md index 8b686983..0bcf0535 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -34,12 +34,26 @@ UI lives in `lib`/`standalone`. | `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. | +| `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. | WebAuthn requires a secure context: `localhost` works for development; for a real phone, put the server behind TLS (`tailscale serve` is the intended selfhost path, any reverse proxy works). The server itself always speaks plain HTTP. +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`. + `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. diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..1db73506 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,55 @@ +/** + * 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'; + +/** 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; +} + +/** 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 { + const port = Number(env.PORT ?? 3000); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new ConfigError(`PORT must be an integer between 0 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'); + + return { port, bindHost, setupPassword, origin, stateDir, pocketDir }; +} diff --git a/server/src/index.ts b/server/src/index.ts index 9ac080b4..b6648b79 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,39 +1,36 @@ /** - * 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} 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'; - -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); +import { ConfigError, readConfig } from './config.js'; + +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, ...appConfig } = loadConfig(); -const { app, injectWebSocket } = createApp({ setupPassword, origin, stateDir, pocketDir }); +const { app, injectWebSocket } = createApp(appConfig); -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 ${appConfig.origin})`, + ); }); // Bind the relay's WS upgrade handler onto the running server (@hono/node-ws). diff --git a/server/test/bind-host.test.mjs b/server/test/bind-host.test.mjs new file mode 100644 index 00000000..03cd72de --- /dev/null +++ b/server/test/bind-host.test.mjs @@ -0,0 +1,115 @@ +/** + * `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'], + }); + + 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}`)); + }); + }); + + 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..9c80a461 --- /dev/null +++ b/server/test/config.test.mjs @@ -0,0 +1,54 @@ +/** + * 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('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'); +}); From b824e48597e3ba898fa5d1e78c5c83f0bdf1aa49 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 08:54:03 -0700 Subject: [PATCH 03/56] Make VS Code a first-class remote Host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote hosting was standalone-only, and not by any deliberate design: nothing in `lib/src/remote/host/` is Tauri-specific, but `enableRemoteHost` was passed only by `standalone/src/main.tsx`, so the entrypoint the VS Code webview renders never loaded the relay, enrollment, or pairing modules at all. Turning the flag on is not enough. Standalone is one webview per app; VS Code is many webviews over one extension host, and that breaks two assumptions the Host stack was built on. Storage. Enrollment and the ACL persist through `local-json-store`, which means `localStorage`. That is wrong twice in VS Code: webview `localStorage` is not the persistence story here, and `hostToken` is a bearer credential granting the `/ws/host` socket. `local-json-store` now takes per-prefix backend claims, and the webview hands `dormouse.remote-host.` to the extension host — enrollment to SecretStorage, ACL to globalState, both prefix-gated and size-capped so a webview can never reach unrelated extension state. Since the store API is synchronous by contract, `main.tsx` hydrates it into memory alongside `resumeOrRestore` before anything reads it. Election. Every webview mounts the same Wall, so each would start its own RemoteHost against the same enrollment, displace the others on the single socket, and arm its own alarm push. The extension host arbitrates a named `remote-host` lease — it is the only party that sees every webview and outlives each one — and re-offers it on dispose, so closing one Dormouse view hands the Host to another open one instead of dropping it until reload. Activation starts un-owned wherever a lease exists, so two webviews racing to mount cannot both activate before the first answer arrives. CSP. The webview `connect-src` had no remote origin, so the socket could not open regardless. The sources are now baked in at build time by a new esbuild wrapper, defaulting to the SaaS origin, with the same `DORMOUSE_REMOTE_CONNECT_SRC` per-build opt-in the standalone binary already uses. Host lifetime is "while a Dormouse view exists" — `retainContextWhenHidden` is already set on both hosting modes, so hiding the panel keeps it connected. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/server.md | 16 ++++ docs/specs/transport.md | 2 + docs/specs/vscode.md | 24 +++++ lib/src/lib/local-json-store.test.ts | 109 ++++++++++++++++++++- lib/src/lib/local-json-store.ts | 55 ++++++++++- lib/src/lib/platform/types.ts | 19 ++++ lib/src/lib/platform/vscode-adapter.ts | 53 +++++++++++ lib/src/main.tsx | 21 ++++- lib/src/remote/host/activation.test.ts | 125 +++++++++++++++++++++++++ lib/src/remote/host/activation.ts | 42 ++++++++- lib/src/remote/host/enrollment.ts | 18 ++-- lib/src/remote/host/store.ts | 12 +++ vscode-ext/package.json | 6 +- vscode-ext/scripts/esbuild.mjs | 56 +++++++++++ vscode-ext/src/extension.ts | 5 + vscode-ext/src/message-router.ts | 65 +++++++++++++ vscode-ext/src/message-types.ts | 5 + vscode-ext/src/remote-host-store.ts | 79 ++++++++++++++++ vscode-ext/src/webview-html.ts | 15 ++- 19 files changed, 705 insertions(+), 22 deletions(-) create mode 100644 lib/src/remote/host/activation.test.ts create mode 100644 lib/src/remote/host/store.ts create mode 100644 vscode-ext/scripts/esbuild.mjs create mode 100644 vscode-ext/src/remote-host-store.ts diff --git a/docs/specs/server.md b/docs/specs/server.md index 7a55b34d..1a1e64f3 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -48,6 +48,22 @@ policy all use that normalized origin. ## Host webview CSP (self-host builds) +Both Hosts — the standalone Tauri app and the VS Code extension — render the +webview that holds the relay socket, so in both the webview `connect-src` bounds +where the Host can reach a relay server. Both default to the SaaS origin only +and take the same build-time override, `DORMOUSE_REMOTE_CONNECT_SRC`: + +```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 +``` + +The standalone path is described below; the VS Code path substitutes the sources +into the extension bundle at build time (`docs/specs/vscode.md` → "CSP policy"), +and the rest of that Host's selfhost story — where its enrollment and ACL live, +and which webview owns the socket — is in `docs/specs/vscode.md` → "Remote Host: +store and lease". + 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 diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 2dec7575..dc2aa33e 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -95,6 +95,8 @@ 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. +Host-owned storage and single-instance roles are VS Code-only additions to the adapter surface, both optional on `PlatformAdapter`. `hydrateScopedStore(prefix)` (`store:read` → `store:entries`, then fire-and-forget `store:write`) moves every key under one prefix into extension-host storage and installs a synchronous write-through cache over it, because `local-json-store` is synchronous by contract and the remote Host's bearer credential must not sit in webview `localStorage`. `claimSingleton(name, onChange)` (`singleton:claim` → `singleton:lease`) asks the host to arbitrate a role that at most one webview may hold, since only the extension host sees every webview. Adapters that omit either are single-instance with local storage, which is correct for standalone and the website. Both are prefix/name gated on the host side — the webview names the key, so the host decides what that name may reach. See `docs/specs/vscode.md` → "Remote Host: store and lease". + 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 e9ed990a..793c8729 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -22,6 +22,8 @@ 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-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys +├── scripts/esbuild.mjs — extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -214,6 +216,8 @@ 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 remote-server `connect-src` sources are a build-time constant, not a runtime value: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into the bundle, defaulting to the SaaS origin (`https://*.dormouse.sh wss://*.dormouse.sh`). Without them a VS Code Host cannot hold its `/ws/host` socket at all. 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` → "Host webview CSP"). It is a `declare const` rather than an import so the value is a literal in the bundle and nothing at runtime can move it. + `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 @@ -237,6 +241,26 @@ 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: store and lease + +VS Code is a first-class remote Host. Two things have to be true that standalone gets for free, because standalone is one webview per app and VS Code is many webviews over one extension host. + +**The store.** The Host's enrollment (`{ serverUrl, hostId, hostToken, origin, rpId }`) and its ACL persist through `local-json-store`, which defaults to `localStorage`. That is wrong here twice over: webview `localStorage` is not VS Code's persistence story, and `hostToken` is a bearer credential that grants the `/ws/host` socket. So the webview claims the `dormouse.remote-host.` prefix and backs it with the extension host — enrollment in `SecretStorage` (OS keychain), ACL in `globalState`, both global because a Host identity belongs to the machine and not to a folder. + +`local-json-store` is synchronous by contract, so the store is pulled across at boot and installed as an in-memory, write-through backend before anything reads it: `lib/src/main.tsx` awaits `PlatformAdapter.hydrateScopedStore` alongside `resumeOrRestore`. A failed read installs an empty cache rather than throwing — the Host then behaves as un-enrolled instead of blocking webview boot. + +Both sides gate on the prefix. The webview names the keys, so `remote-host-store.ts` refuses any key outside the Host namespace and caps values at 64 KiB; a compromised webview can neither read nor write unrelated extension state. + +Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vscode-adapter.ts` (`hydrateScopedStore`), `lib/src/lib/local-json-store.ts` (prefix claims), `lib/src/remote/host/store.ts` (the shared prefix). + +**The lease.** A window can show a `WebviewView` and any number of `WebviewPanel`s at once. Each mounts the same Wall, so each would start its own `RemoteHost` against the same enrollment — they would displace each other on the single `/ws/host` socket (`server/test/relay-displaced.test.mjs`) and each would arm its own alarm push. The extension host arbitrates instead, because it is the only party that sees every webview and outlives each one: `message-router.ts` grants the named role `remote-host` to the first claimant and re-offers it when the holder is disposed, so closing the Dormouse view hands the Host to another open one rather than dropping it until a reload. + +On the webview side `activation.ts` starts un-owned whenever the adapter offers `claimSingleton`, so two webviews racing to mount cannot both activate before the first answer arrives. Adapters without the hook (standalone, the website) are single-instance and stay owned from the start. + +Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PlatformAdapter.claimSingleton`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. + +**Lifetime.** The Host lives as long as a Dormouse webview exists in the window. `retainContextWhenHidden: true` is set on both hosting modes, so hiding the panel keeps it connected; only disposing every Dormouse view, or closing the window, takes it offline. + ### Build and development Source of truth: diff --git a/lib/src/lib/local-json-store.test.ts b/lib/src/lib/local-json-store.test.ts index de4f2964..2c2caa61 100644 --- a/lib/src/lib/local-json-store.test.ts +++ b/lib/src/lib/local-json-store.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { loadJson, saveJson } from './local-json-store'; +import { loadJson, removeJson, saveJson, setJsonStoreBackend } from './local-json-store'; function stubLocalStorage(): Map { const store = new Map(); @@ -84,4 +84,111 @@ 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(); + }); + }); + + describe('prefix-claimed backends', () => { + function fakeBackend() { + const map = new Map(); + return { + map, + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + removeItem: (key: string) => void map.delete(key), + }; + } + + afterEach(() => { + setJsonStoreBackend('a.', null); + setJsonStoreBackend('a.b.', null); + }); + + it('routes a claimed prefix to its backend and leaves other keys on localStorage', () => { + const local = stubLocalStorage(); + const backend = fakeBackend(); + setJsonStoreBackend('a.', backend); + + saveJson('a.one', { id: 'w1' }); + saveJson('other.two', { id: 'w2' }); + + expect(backend.map.has('a.one')).toBe(true); + // The unrelated key must not be swept into the claimed backend — this is + // what keeps alert settings and watched commands on their own storage. + expect(backend.map.has('other.two')).toBe(false); + expect(local.get('other.two')).toBe(JSON.stringify({ id: 'w2' })); + expect(local.has('a.one')).toBe(false); + + expect(loadJson('a.one', null, isWidget)).toEqual({ id: 'w1' }); + expect(loadJson('other.two', null, isWidget)).toEqual({ id: 'w2' }); + }); + + it('prefers the longest matching prefix', () => { + stubLocalStorage(); + const outer = fakeBackend(); + const inner = fakeBackend(); + setJsonStoreBackend('a.', outer); + setJsonStoreBackend('a.b.', inner); + + saveJson('a.b.key', 1); + saveJson('a.key', 2); + + expect(inner.map.has('a.b.key')).toBe(true); + expect(outer.map.has('a.b.key')).toBe(false); + expect(outer.map.has('a.key')).toBe(true); + }); + + it('releases a claim back to localStorage', () => { + const local = stubLocalStorage(); + const backend = fakeBackend(); + setJsonStoreBackend('a.', backend); + setJsonStoreBackend('a.', null); + + saveJson('a.one', { id: 'w1' }); + + expect(backend.map.size).toBe(0); + expect(local.get('a.one')).toBe(JSON.stringify({ id: 'w1' })); + }); + + it('removeJson deletes through the claimed backend', () => { + stubLocalStorage(); + const backend = fakeBackend(); + setJsonStoreBackend('a.', backend); + saveJson('a.one', { id: 'w1' }); + + removeJson('a.one'); + + expect(backend.map.has('a.one')).toBe(false); + }); + + it('a throwing backend never propagates', () => { + stubLocalStorage(); + setJsonStoreBackend('a.', { + getItem: () => { + throw new Error('nope'); + }, + setItem: () => { + throw new Error('nope'); + }, + removeItem: () => { + throw new Error('nope'); + }, + }); + + expect(() => saveJson('a.one', 1)).not.toThrow(); + expect(loadJson('a.one', 'fallback')).toBe('fallback'); + expect(() => removeJson('a.one')).not.toThrow(); + }); + }); }); diff --git a/lib/src/lib/local-json-store.ts b/lib/src/lib/local-json-store.ts index 5311ff8e..483467ab 100644 --- a/lib/src/lib/local-json-store.ts +++ b/lib/src/lib/local-json-store.ts @@ -13,8 +13,46 @@ * Each caller supplies its own key, fallback, and (optionally) a type guard, so * the fallback and validation stay caller-specific while the boilerplate lives * here once. + * + * `localStorage` is the default backend, but a host whose storage lives + * elsewhere can claim a key prefix with {@link setJsonStoreBackend} — the VS + * Code webview routes `dormouse.remote-host.*` to the extension host, whose + * `SecretStorage` holds the Host's bearer credential (docs/specs/vscode.md). + * The claim is per-prefix rather than global so unrelated stores (alert + * settings, watched commands) keep their own backend. */ +/** The minimal `localStorage` surface these helpers use. */ +export interface JsonStoreBackend { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** Prefix claims, longest-first so a more specific prefix wins. */ +const backends: Array<{ prefix: string; backend: JsonStoreBackend }> = []; + +/** + * Route every key starting with `prefix` to `backend`. Pass `null` to release + * the claim. Backends must be synchronous: callers read at module init and on + * every access, so an async store has to be hydrated into memory first. + */ +export function setJsonStoreBackend(prefix: string, backend: JsonStoreBackend | null): void { + const at = backends.findIndex((entry) => entry.prefix === prefix); + if (at !== -1) backends.splice(at, 1); + if (backend) { + backends.push({ prefix, backend }); + backends.sort((a, b) => b.prefix.length - a.prefix.length); + } +} + +function backendFor(key: string): JsonStoreBackend | undefined { + for (const entry of backends) { + if (key.startsWith(entry.prefix)) return entry.backend; + } + return globalThis.localStorage as JsonStoreBackend | undefined; +} + /** * Read and JSON-parse the value at `key`, returning `fallback` if storage is * unavailable, the key is missing, the JSON is malformed, or `validate` (when @@ -26,7 +64,7 @@ export function loadJson( validate?: (value: unknown) => value is V, ): V | F { try { - const raw = globalThis.localStorage?.getItem(key); + const raw = backendFor(key)?.getItem(key); if (!raw) return fallback; const parsed: unknown = JSON.parse(raw); if (validate && !validate(parsed)) return fallback; @@ -42,8 +80,21 @@ export function loadJson( */ export function saveJson(key: string, value: unknown): void { try { - globalThis.localStorage?.setItem(key, JSON.stringify(value)); + backendFor(key)?.setItem(key, JSON.stringify(value)); } catch { // No localStorage / quota exceeded: the in-memory value still works. } } + +/** + * Delete the value at `key`, swallowing any failure. Callers must go through + * this rather than touching `localStorage` directly, or a claimed prefix would + * clear the wrong store. + */ +export function removeJson(key: string): void { + try { + backendFor(key)?.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 ed060316..bd6329b2 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -117,6 +117,25 @@ export interface PlatformAdapter { init(): Promise; shutdown(): void; + /** + * Make every key under `prefix` readable synchronously from a host-owned + * store instead of `localStorage`, then keep it written through. Optional: + * only hosts whose real storage lives outside the webview implement it (VS + * Code, where the extension host holds `SecretStorage`). Callers must await + * it before any module reads those keys, because `local-json-store` is + * synchronous by contract. Adapters that omit it leave `localStorage` in + * charge, which is correct for standalone and the website. + */ + hydrateScopedStore?(prefix: string): Promise; + + /** + * Claim a named role that at most one app instance may hold, and be told + * whenever the claim is granted or revoked. Optional: only hosts that can + * show several webviews over one backend implement it (VS Code). Adapters + * that omit it are single-instance, so callers treat the role as held. + */ + claimSingleton?(name: string, onChange: (held: boolean) => void): void; + // Shell detection getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]>; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index d7737eb8..184660b6 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -14,6 +14,7 @@ import { getTerminalTheme, onTerminalThemeChange } from '../terminal-theme'; import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; +import { setJsonStoreBackend } from '../local-json-store'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; @@ -190,6 +191,58 @@ export class VSCodeAdapter implements PlatformAdapter { // No initialization needed — the webview is already running } + /** + * Ask the extension host for a named single-instance role and report every + * grant/revoke. The extension host is the arbiter because it is the only + * thing that outlives and sees all of this window's webviews; it re-offers + * the role when the holder is disposed, so closing the Dormouse view hands + * the Host to another open one rather than dropping it until reload. + */ + claimSingleton(name: string, onChange: (held: boolean) => void): void { + window.addEventListener('message', (event: MessageEvent) => { + if (!isHostMessage(event.data, this.hostMessageToken)) return; + const msg = event.data; + if (msg.type === 'singleton:lease' && msg.name === name) onChange(!!msg.held); + }); + this.vscode.postMessage({ type: 'singleton:claim', name }); + } + + /** + * Pull every `prefix`-scoped value out of extension-host storage and install + * a synchronous, write-through backend over it (docs/specs/vscode.md → "Host + * store"). Webview `localStorage` is not the VS Code persistence story, and + * the remote Host's enrollment carries a bearer credential that belongs in + * `SecretStorage`, so the store has to live on the other side of the message + * boundary. A failed read installs an empty cache rather than throwing: the + * Host then behaves as un-enrolled instead of blocking webview boot. + */ + async hydrateScopedStore(prefix: string): Promise { + let entries: Record = {}; + try { + entries = + (await this.requestResponse( + 'store:read', + 'store:entries', + { prefix }, + (msg) => msg.entries as Record, + )) ?? {}; + } catch { + // Timed out or the host declined — fall through with an empty cache. + } + const cache = new Map(Object.entries(entries)); + setJsonStoreBackend(prefix, { + getItem: (key) => cache.get(key) ?? null, + setItem: (key, value) => { + cache.set(key, value); + this.vscode.postMessage({ type: 'store:write', key, value }); + }, + removeItem: (key) => { + cache.delete(key); + this.vscode.postMessage({ type: 'store:write', key, value: null }); + }, + }); + } + shutdown(): void { // No-op — the extension host handles cleanup } diff --git a/lib/src/main.tsx b/lib/src/main.tsx index ea7b91df..d6d9d909 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -4,12 +4,18 @@ 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 { REMOTE_HOST_STORE_PREFIX } from "./remote/host/store"; 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 can be a remote Host: the dev server has no PTYs behind it, and the +// extension host is what arbitrates the single-Host lease across webviews. +const isVscode = typeof acquireVsCodeApi === "function"; + +if (isVscode) { installVscodeThemeVarResolver(); } @@ -18,10 +24,19 @@ initAlertStateReceiver(); // Request PTY list before rendering so Wall can restore existing sessions. // On non-VSCode platforms (or first launch), this resolves immediately with no IDs. -resumeOrRestore(platform).then((result) => { +// +// The Host store is hydrated in the same wait: `local-json-store` is +// synchronous by contract, so a host that keeps those keys outside the webview +// (VS Code → extension-host SecretStorage) must have them in memory before the +// remote-Host modules read them at mount. Adapters without the hook resolve +// immediately and keep localStorage. +Promise.all([ + resumeOrRestore(platform), + platform.hydrateScopedStore?.(REMOTE_HOST_STORE_PREFIX) ?? Promise.resolve(), +]).then(([result]) => { createRoot(document.getElementById("root")!).render( - + , ); }); diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts new file mode 100644 index 00000000..bb1d8e9f --- /dev/null +++ b/lib/src/remote/host/activation.test.ts @@ -0,0 +1,125 @@ +/** + * The single-Host lease. VS Code can show several Dormouse webviews over one + * extension host; without this gate each would start its own `RemoteHost` + * against the same enrollment, fight over the one `/ws/host` socket, and arm + * its own alarm push. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const started: Array<{ stopped: boolean }> = []; + +vi.mock('./remote-host', () => ({ + RemoteHost: class { + activeRecords: never[] = []; + status = 'connecting'; + #self = { stopped: false }; + constructor() { + started.push(this.#self); + } + start() {} + stop() { + this.#self.stopped = true; + } + }, +})); +vi.mock('./remote-api', () => ({ RemoteApiSession: class {} })); +vi.mock('./alert-push', () => ({ + startAlertPush: () => () => {}, + refreshPushDevices: async () => {}, +})); +vi.mock('../../lib/push-devices', () => ({ + resetPushDevices: () => {}, + setPushDevicesRefresher: () => {}, +})); +vi.mock('./enrollment', () => ({ + getEnrollment: () => ({ + serverUrl: 'https://relay.example.ts.net', + hostId: 'host-1', + hostToken: 'token', + origin: 'https://relay.example.ts.net', + rpId: 'relay.example.ts.net', + }), + clearEnrollment: () => {}, + enrollHost: async () => ({}), +})); + +let claimSingleton: ((name: string, onChange: (held: boolean) => void) => void) | undefined; +vi.mock('../../lib/platform', () => ({ + getPlatform: () => ({ claimSingleton }), +})); + +async function freshModule() { + vi.resetModules(); + return import('./activation'); +} + +beforeEach(() => { + started.length = 0; + claimSingleton = undefined; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('remote host activation lease', () => { + it('activates immediately on a host with no lease (standalone)', async () => { + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + expect(started).toHaveLength(1); + }); + + it('waits for the lease on a host that arbitrates', async () => { + let grant: ((held: boolean) => void) | null = null; + claimSingleton = (_name, onChange) => { + grant = onChange; + }; + + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + + // Mount alone must not start a Host — the answer has not arrived yet. + expect(started).toHaveLength(0); + + grant!(true); + expect(started).toHaveLength(1); + expect(started[0].stopped).toBe(false); + }); + + it('stops when the lease is revoked and restarts when re-granted', async () => { + let grant: ((held: boolean) => void) | null = null; + claimSingleton = (_name, onChange) => { + grant = onChange; + }; + + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + grant!(true); + expect(started).toHaveLength(1); + + grant!(false); + expect(started[0].stopped).toBe(true); + + grant!(true); + expect(started).toHaveLength(2); + }); + + it('a repeated grant does not start a second Host', async () => { + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + mod.setRemoteHostOwnership(true); + mod.setRemoteHostOwnership(true); + expect(started).toHaveLength(1); + }); + + it('claims under the shared role name', async () => { + const names: string[] = []; + claimSingleton = (name) => void names.push(name); + + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + + expect(names).toEqual(['remote-host']); + }); +}); diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 71b35937..0f613b9a 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -14,6 +14,7 @@ * window.dormouseRemoteHost.clearEnrollment() */ +import { getPlatform } from '../../lib/platform'; import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; import { refreshPushDevices, startAlertPush, type AlertPushDeps } from './alert-push'; import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; @@ -23,6 +24,20 @@ import { RemoteHost } from './remote-host'; let current: RemoteHost | null = null; let stopPush: (() => void) | null = null; +/** + * Whether this app instance is the one allowed to be the Host. + * + * Standalone is a single webview per app, so it owns the role outright and this + * stays `true`. VS Code can show several Dormouse webviews at once (a + * `WebviewView` plus any number of `WebviewPanel`s), and each would otherwise + * start its own `RemoteHost` against the same enrollment — they would fight + * over the single `/ws/host` socket (the server displaces the previous holder, + * see `server/test/relay-displaced.test.mjs`) and each would arm its own alarm + * push. So a host that can have more than one webview hands out a lease + * instead, and only the holder activates. + */ +let owned = true; + function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { const host = new RemoteHost({ enrollment, @@ -53,9 +68,24 @@ function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { return host; } -/** Start the Host if an enrollment exists and none is running. Idempotent. */ +/** + * Grant or revoke this instance's claim to being the Host, starting or stopping + * it to match. Called by the platform's singleton lease; hosts without one stay + * granted from the start. + */ +export function setRemoteHostOwnership(next: boolean): void { + if (owned === next) return; + owned = next; + if (owned) activateRemoteHost(); + else stopRemoteHost(); +} + +/** + * Start the Host if an enrollment exists, this instance holds the lease, and + * none is running. Idempotent. + */ export function activateRemoteHost(): void { - if (current) return; + if (current || !owned) return; const enrollment = getEnrollment(); if (!enrollment) return; current = startFromEnrollment(enrollment); @@ -91,6 +121,14 @@ function remoteHostStatus(): RemoteHostConsoleStatus { /** Install the `window.dormouseRemoteHost` console hook and activate. Idempotent. */ export function installRemoteHostConsoleHook(): void { + // A host that can show several webviews arbitrates which one is the Host. + // Start un-owned so two webviews racing to mount cannot both activate before + // the first lease answer arrives. + const claimSingleton = getPlatform().claimSingleton; + if (claimSingleton) { + owned = false; + claimSingleton('remote-host', setRemoteHostOwnership); + } activateRemoteHost(); const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; if (target.dormouseRemoteHost) return; diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index 59891f2a..3b719af8 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -6,12 +6,16 @@ * 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. + * Persisted through `local-json-store` (browser-only, no platform adapter + * dependency) so the standalone app can rehydrate and reconnect on the next + * launch. The VS Code webview claims the `dormouse.remote-host.` prefix and + * backs it with the extension host's `SecretStorage` — `hostToken` is a bearer + * credential, and webview `localStorage` is not the VS Code persistence story + * (docs/specs/vscode.md). */ import { API_ROUTES, type HostEnrollResponse } from 'server-lib-common'; -import { loadJson, saveJson } from '../../lib/local-json-store'; +import { loadJson, removeJson, saveJson } from '../../lib/local-json-store'; export interface HostEnrollment { /** Origin the Server is reachable at, e.g. `https://dormouse.tailnet.ts.net`. */ @@ -25,7 +29,7 @@ export interface HostEnrollment { rpId: string; } -/** Single localStorage key holding the whole enrollment blob. */ +/** Single store key holding the whole enrollment blob. */ export const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; function isEnrollment(value: unknown): value is HostEnrollment { @@ -46,11 +50,7 @@ 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 { diff --git a/lib/src/remote/host/store.ts b/lib/src/remote/host/store.ts new file mode 100644 index 00000000..5bc48b37 --- /dev/null +++ b/lib/src/remote/host/store.ts @@ -0,0 +1,12 @@ +/** + * The one key prefix every Host-side persisted value lives under + * (`enrollment.ts` → `ENROLLMENT_KEY`, `acl.ts` → `ACL_KEY_PREFIX`). + * + * It exists so a host can move the whole Host store somewhere other than + * `localStorage` in one claim: the VS Code webview hands this prefix to + * `PlatformAdapter.hydrateScopedStore`, and the extension host backs it with + * `SecretStorage` (the enrollment blob carries `hostToken`, a bearer + * credential) plus `globalState` for the ACL. Both sides validate against this + * prefix, so a webview can never reach unrelated extension storage. + */ +export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 05bd8863..df675b9f 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1,7 +1,7 @@ { "name": "dormouse", - "displayName": "Dormouse — Terminal Multiplexer", - "description": "A persistent multitasking terminal — tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", + "displayName": "Dormouse \u2014 Terminal Multiplexer", + "description": "A persistent multitasking terminal \u2014 tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", "version": "1.1.0", "publisher": "diffplug", "license": "FSL-1.1-MIT", @@ -104,7 +104,7 @@ "pretypecheck": "pnpm --filter dor-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", + "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", "package": "vsce package --no-dependencies --out dormouse.vsix", diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs new file mode 100644 index 00000000..ab7ba4e7 --- /dev/null +++ b/vscode-ext/scripts/esbuild.mjs @@ -0,0 +1,56 @@ +// Bundles the extension host and the PTY host, and is the single place that +// bakes the webview's remote-server `connect-src` into the build. +// +// The published extension is scoped to the SaaS origin only, so a compromised +// webview cannot exfiltrate to an arbitrary host. 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/tauri.mjs` + `csp.mjs`) so both Hosts widen the same way +// with the same variable. See docs/specs/server.md → "Host webview CSP". + +import * as esbuild from 'esbuild'; + +/** The remote-server sources baked into the published extension. */ +export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +const remoteSrc = process.env.DORMOUSE_REMOTE_CONNECT_SRC?.trim() || DEFAULT_REMOTE_CONNECT_SRC; +if (remoteSrc !== DEFAULT_REMOTE_CONNECT_SRC) { + console.error(`[esbuild] webview connect-src remote sources overridden: ${remoteSrc}`); +} + +const watch = process.argv.includes('--watch'); + +const common = { + bundle: true, + format: 'cjs', + platform: 'node', + external: ['vscode', 'node-pty'], +}; + +const builds = [ + { + ...common, + entryPoints: ['src/extension.ts'], + outdir: 'dist', + define: { __DORMOUSE_REMOTE_CONNECT_SRC__: JSON.stringify(remoteSrc) }, + }, + { + ...common, + entryPoints: ['src/pty-host.js'], + outfile: 'dist/pty-host.js', + external: ['node-pty'], + }, +]; + +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))); +} diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index ee9e03e3..7e706f20 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -11,6 +11,7 @@ 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 { initRemoteHostStore } from './remote-host-store'; type NewTerminalMessage = Extract; @@ -73,6 +74,10 @@ function setupPanel( } export function activate(context: vscode.ExtensionContext) { + // The remote Host's enrollment (SecretStorage) and ACL (globalState) are + // read by the webview through `store:read`; give the store its context + // before any webview can ask. See remote-host-store.ts. + initRemoteHostStore(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..68632a7e 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -21,6 +21,7 @@ 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 { readStore, writeStore } from './remote-host-store'; import { log } from './log'; import type { WebviewChannel } from './webview-messaging'; @@ -32,6 +33,39 @@ 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(); + +/** + * Arbiter for named single-instance roles across this window's webviews — today + * only `remote-host`, so exactly one webview holds the `/ws/host` socket and + * arms alarm push (see `lib/src/remote/host/activation.ts`). The extension host + * arbitrates because it is the only party that sees every webview and outlives + * each one. First claimant wins; when the holder is disposed the role is + * re-offered, so closing the Dormouse view hands the Host to another open one + * instead of dropping it until a reload. + */ +interface SingletonClaimant { + wants: Set; + holds: Set; + notify(name: string, held: boolean): void; +} +const singletonClaimants = new Set(); + +function electSingleton(name: string): void { + const holder = [...singletonClaimants].find((c) => c.holds.has(name)); + if (holder) return; + const next = [...singletonClaimants].find((c) => c.wants.has(name)); + if (!next) return; + next.holds.add(name); + next.notify(name, true); +} + +function releaseSingletons(claimant: SingletonClaimant): void { + const released = [...claimant.holds]; + claimant.holds.clear(); + claimant.wants.clear(); + singletonClaimants.delete(claimant); + for (const name of released) electSingleton(name); +} interface ActiveRouter { flushSessionSave(timeoutMs?: number): Promise; ownsPty(id: string): boolean; @@ -171,6 +205,15 @@ export function attachRouter( // Track which PTY IDs were spawned (or reconnected) through this webview const ownedPtyIds = new Set(); + + // This webview's stake in the window-wide single-instance roles. + const claimant: SingletonClaimant = { + wants: new Set(), + holds: new Set(), + notify: (name, held) => + void post({ type: 'singleton:lease', name, held } satisfies ExtensionMessage), + }; + singletonClaimants.add(claimant); const pendingFlushRequests = new Map void; timeout: ReturnType }>(); let disposed = false; @@ -493,6 +536,27 @@ export function attachRouter( } satisfies ExtensionMessage), ); break; + case 'singleton:claim': + claimant.wants.add(msg.name); + if (claimant.holds.has(msg.name)) claimant.notify(msg.name, true); + else electSingleton(msg.name); + break; + case 'store:read': + // The Host's enrollment + ACL live in extension-host storage, not in + // webview localStorage (remote-host-store.ts explains why). Both sides + // gate on the key prefix. + readStore(typeof msg.prefix === 'string' ? msg.prefix : '').then( + (entries) => post({ + type: 'store:entries', requestId: msg.requestId, entries, + } satisfies ExtensionMessage), + () => post({ + type: 'store:entries', requestId: msg.requestId, entries: {}, + } satisfies ExtensionMessage), + ); + break; + case 'store:write': + void writeStore(msg.key, msg.value); + 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 }; @@ -661,6 +725,7 @@ export function attachRouter( if (disposed) return; disposed = true; activeRouters.delete(router); + releaseSingletons(claimant); removeWatchedCommandListener(); removeAlertSettingsListener(); resolveAllFlushRequests(); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 3c48a26a..7edade10 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -28,6 +28,9 @@ 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 } + | { type: 'singleton:claim'; name: string } + | { type: 'store:read'; prefix: string; requestId: string } + | { type: 'store:write'; key: string; value: string | null } | { type: 'dormouse:init' } | ({ type: 'dormouse:themeColors' } & TerminalColors) | { type: 'dormouse:saveState'; state: unknown } @@ -73,6 +76,8 @@ 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: 'store:entries'; requestId: string; entries: Record } + | { type: 'singleton:lease'; name: string; held: boolean } | { type: 'dormouse:newTerminal'; shell?: string; diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts new file mode 100644 index 00000000..69cccc73 --- /dev/null +++ b/vscode-ext/src/remote-host-store.ts @@ -0,0 +1,79 @@ +/** + * Extension-host storage for the webview's remote-Host keys + * (docs/specs/vscode.md → "Host store"). + * + * The webview cannot keep these in `localStorage`: VS Code's persistence story + * is `setState`/`workspaceState`/`globalState`, and the enrollment blob carries + * `hostToken` — a bearer credential that grants the `/ws/host` socket — so it + * belongs in `SecretStorage` (OS keychain), not in a webview-origin store. + * + * Split by sensitivity: the enrollment blob goes to `SecretStorage`, the ACL + * (public key records, no secret) to `globalState`. Both are global rather than + * workspace-scoped, because a Host identity belongs to the machine, not to a + * folder. + * + * Everything here is prefix-gated. The webview names keys, so an untrusted + * message must never be able to read or write extension state outside the + * Host's own namespace. + */ + +import type * as vscode from 'vscode'; + +/** Mirrors `lib/src/remote/host/store.ts`; both sides gate on it. */ +export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; + +/** Mirrors `lib/src/remote/host/enrollment.ts`; the one secret-backed key. */ +const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; + +/** + * Enough for an enrollment blob or a sizable ACL, small enough that a + * compromised webview cannot bloat the keychain or globalState. + */ +const MAX_VALUE_BYTES = 64 * 1024; + +let context: vscode.ExtensionContext | null = null; + +export function initRemoteHostStore(ctx: vscode.ExtensionContext): void { + context = ctx; +} + +function allowed(key: string): boolean { + return key.startsWith(REMOTE_HOST_STORE_PREFIX); +} + +/** + * Every stored value whose key starts with `prefix`. Returns `{}` for any + * prefix outside the Host namespace, so a webview asking for something else + * learns nothing. + */ +export async function readStore(prefix: string): Promise> { + if (!context || !allowed(prefix)) return {}; + const entries: Record = {}; + + const enrollment = await context.secrets.get(ENROLLMENT_KEY); + if (enrollment !== undefined && ENROLLMENT_KEY.startsWith(prefix)) { + entries[ENROLLMENT_KEY] = enrollment; + } + + for (const key of context.globalState.keys()) { + if (!allowed(key) || !key.startsWith(prefix) || key === ENROLLMENT_KEY) continue; + const value = context.globalState.get(key); + if (typeof value === 'string') entries[key] = value; + } + + return entries; +} + +/** Write (or, with `null`, delete) one Host-namespace key. */ +export async function writeStore(key: string, value: string | null): Promise { + if (!context || !allowed(key)) return; + if (value !== null && Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) return; + + if (key === ENROLLMENT_KEY) { + if (value === null) await context.secrets.delete(key); + else await context.secrets.store(key, value); + return; + } + + await context.globalState.update(key, value === null ? undefined : value); +} diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index 6134cabd..a575c0ad 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -1,4 +1,11 @@ import * as vscode from 'vscode'; + +/** + * Remote-server `connect-src` sources, substituted by esbuild at build time + * (`scripts/esbuild.mjs`). Declared rather than imported so the value is a + * literal in the bundle and cannot be changed at runtime. + */ +declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; import * as path from 'path'; import * as fs from 'fs'; import { randomBytes } from 'crypto'; @@ -50,8 +57,12 @@ 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). - `connect-src ${webview.cspSource} ws://127.0.0.1:* ws://localhost:*`, + // browser surfaces; see docs/specs/dor-browser.md). The remote sources are + // baked in at build time (scripts/esbuild.mjs) — the published extension + // reaches the SaaS relay only, and a selfhoster widens it for their own + // build with DORMOUSE_REMOTE_CONNECT_SRC, exactly as the standalone binary + // does. Without them a VS Code Host cannot hold its `/ws/host` socket. + `connect-src ${webview.cspSource} ws://127.0.0.1:* ws://localhost:* ${__DORMOUSE_REMOTE_CONNECT_SRC__}`, // `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 // ever embed is 127.0.0.1/localhost on an OS-assigned port. Without a From 2bbcb0bdb3fe8bcc667b89782db1113cb889774e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 09:20:57 -0700 Subject: [PATCH 04/56] Cleanup pass on the VS Code remote Host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from a four-angle review of the previous commit. The one that mattered: `activation.ts` called `claimSingleton` through a detached reference, so `this` was undefined and the first real webview would have thrown inside the adapter — after `owned = false` was already set, leaving a permanently disabled Host. The adapter binds every method reached that way and this one was missing from the list; the tests mock the platform as an object literal, so they could not see it. Now called as `platform.claimSingleton(…)`. Deduplication the previous commit set out to do and then didn't: the store prefix and enrollment key were re-declared in the extension with "Mirrors …" comments, though `lib/src/remote/host/store.ts` exists precisely to be the one definition. Both are imported now, and `store.ts` owns `ENROLLMENT_KEY` so the extension can have it without dragging `server-lib-common` into its bundle. Likewise `DEFAULT_REMOTE_CONNECT_SRC`, which had been copied into the new esbuild wrapper: the two Hosts keep their different substitution mechanisms but now share one definition in `scripts/csp-defaults.mjs`, so changing the SaaS origin cannot ship one Host pointed at the old one. `claimSingleton` also added a second `message` listener with its own copy of the auth guard, never removed, paying a token check on every `pty:data`. It now registers in a Map dispatched from the constructor's existing listener, so re-claiming replaces rather than stacks. Simplifications: the lease's `holds` set was derivable, so one module-level `singletonHolders` map holds the answer instead of N per-claimant sets; `readStore` no longer re-proves the prefix it already checked, and guards before the keychain read rather than discarding it after; the prefix registry is a Map with first-match instead of a sorted array with longest-match, since claims are documented as non-overlapping. Two real holes the review surfaced, both now closed: the console `enroll()` started a Host without consulting the lease, and the test that claimed to cover repeated grants never exercised the lease at all (it left `claimSingleton` unset, so both calls hit an early return). The console hook also outlives `vi.resetModules()`, which was letting one test call the previous module's closure. Also: restored em-dashes mangled in `vscode-ext/package.json`, fixed the `watch` script so `--watch` reaches esbuild instead of a trailing `cp`, and documented that the lease is per-window while the enrollment it guards is machine-wide — two windows still elect one holder each. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 2 + lib/src/lib/local-json-store.test.ts | 57 ++++++++------------------ lib/src/lib/local-json-store.ts | 16 +++----- lib/src/lib/platform/vscode-adapter.ts | 13 +++--- lib/src/main.tsx | 2 +- lib/src/remote/host/activation.test.ts | 54 ++++++++++++++---------- lib/src/remote/host/activation.ts | 17 +++++--- lib/src/remote/host/enrollment.ts | 4 +- lib/src/remote/host/store.ts | 7 ++++ scripts/csp-defaults.mjs | 25 +++++++++++ standalone/scripts/csp.mjs | 6 ++- vscode-ext/package.json | 6 +-- vscode-ext/scripts/esbuild.mjs | 9 +--- vscode-ext/src/message-router.ts | 40 +++++++++--------- vscode-ext/src/remote-host-store.ts | 24 ++++++----- vscode-ext/src/webview-html.ts | 12 +++--- 16 files changed, 162 insertions(+), 132 deletions(-) create mode 100644 scripts/csp-defaults.mjs diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 793c8729..80372d00 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -257,6 +257,8 @@ Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vs On the webview side `activation.ts` starts un-owned whenever the adapter offers `claimSingleton`, so two webviews racing to mount cannot both activate before the first answer arrives. Adapters without the hook (standalone, the website) are single-instance and stay owned from the start. +**Scope of the lease.** It is per-window, because the extension host is per-window — but the enrollment it guards lives in `SecretStorage`/`globalState`, which are machine-wide. Two VS Code windows each showing a Dormouse view therefore elect one holder *each*, and the relay displaces whichever connected first. The lease removes the multi-webview case, not the multi-window one; a cross-window lease would have to be arbitrated on shared state rather than in extension-host memory. Until then the server remains the final arbiter, which is correct but noisier than it should be. + Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PlatformAdapter.claimSingleton`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. **Lifetime.** The Host lives as long as a Dormouse webview exists in the window. `retainContextWhenHidden: true` is set on both hosting modes, so hiding the panel keeps it connected; only disposing every Dormouse view, or closing the window, takes it offline. diff --git a/lib/src/lib/local-json-store.test.ts b/lib/src/lib/local-json-store.test.ts index 2c2caa61..737e9e61 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, removeJson, saveJson, setJsonStoreBackend } from './local-json-store'; +/** A Map-backed `Storage` surface, usable as a stub or as a claimed backend. */ +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 { @@ -100,24 +107,11 @@ describe('local-json-store', () => { }); describe('prefix-claimed backends', () => { - function fakeBackend() { - const map = new Map(); - return { - map, - getItem: (key: string) => map.get(key) ?? null, - setItem: (key: string, value: string) => void map.set(key, value), - removeItem: (key: string) => void map.delete(key), - }; - } - - afterEach(() => { - setJsonStoreBackend('a.', null); - setJsonStoreBackend('a.b.', null); - }); + afterEach(() => setJsonStoreBackend('a.', null)); it('routes a claimed prefix to its backend and leaves other keys on localStorage', () => { const local = stubLocalStorage(); - const backend = fakeBackend(); + const backend = memoryStore(); setJsonStoreBackend('a.', backend); saveJson('a.one', { id: 'w1' }); @@ -134,24 +128,9 @@ describe('local-json-store', () => { expect(loadJson('other.two', null, isWidget)).toEqual({ id: 'w2' }); }); - it('prefers the longest matching prefix', () => { - stubLocalStorage(); - const outer = fakeBackend(); - const inner = fakeBackend(); - setJsonStoreBackend('a.', outer); - setJsonStoreBackend('a.b.', inner); - - saveJson('a.b.key', 1); - saveJson('a.key', 2); - - expect(inner.map.has('a.b.key')).toBe(true); - expect(outer.map.has('a.b.key')).toBe(false); - expect(outer.map.has('a.key')).toBe(true); - }); - it('releases a claim back to localStorage', () => { const local = stubLocalStorage(); - const backend = fakeBackend(); + const backend = memoryStore(); setJsonStoreBackend('a.', backend); setJsonStoreBackend('a.', null); @@ -163,7 +142,7 @@ describe('local-json-store', () => { it('removeJson deletes through the claimed backend', () => { stubLocalStorage(); - const backend = fakeBackend(); + const backend = memoryStore(); setJsonStoreBackend('a.', backend); saveJson('a.one', { id: 'w1' }); diff --git a/lib/src/lib/local-json-store.ts b/lib/src/lib/local-json-store.ts index 483467ab..a54b8414 100644 --- a/lib/src/lib/local-json-store.ts +++ b/lib/src/lib/local-json-store.ts @@ -29,8 +29,8 @@ export interface JsonStoreBackend { removeItem(key: string): void; } -/** Prefix claims, longest-first so a more specific prefix wins. */ -const backends: Array<{ prefix: string; backend: JsonStoreBackend }> = []; +/** Claimed prefixes. Claims must not overlap; the first match wins. */ +const backends = new Map(); /** * Route every key starting with `prefix` to `backend`. Pass `null` to release @@ -38,17 +38,13 @@ const backends: Array<{ prefix: string; backend: JsonStoreBackend }> = []; * every access, so an async store has to be hydrated into memory first. */ export function setJsonStoreBackend(prefix: string, backend: JsonStoreBackend | null): void { - const at = backends.findIndex((entry) => entry.prefix === prefix); - if (at !== -1) backends.splice(at, 1); - if (backend) { - backends.push({ prefix, backend }); - backends.sort((a, b) => b.prefix.length - a.prefix.length); - } + if (backend) backends.set(prefix, backend); + else backends.delete(prefix); } function backendFor(key: string): JsonStoreBackend | undefined { - for (const entry of backends) { - if (key.startsWith(entry.prefix)) return entry.backend; + for (const [prefix, backend] of backends) { + if (key.startsWith(prefix)) return backend; } return globalThis.localStorage as JsonStoreBackend | undefined; } diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 184660b6..9e901cb1 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -31,6 +31,7 @@ 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>(); + private singletonHandlers = new Map void>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -152,6 +153,8 @@ export class VSCodeAdapter implements PlatformAdapter { respond, }, })); + } else if (msg.type === 'singleton:lease') { + this.singletonHandlers.get(msg.name)?.(!!msg.held); } }); } @@ -199,11 +202,11 @@ export class VSCodeAdapter implements PlatformAdapter { * the Host to another open one rather than dropping it until reload. */ claimSingleton(name: string, onChange: (held: boolean) => void): void { - window.addEventListener('message', (event: MessageEvent) => { - if (!isHostMessage(event.data, this.hostMessageToken)) return; - const msg = event.data; - if (msg.type === 'singleton:lease' && msg.name === name) onChange(!!msg.held); - }); + // One entry per role, dispatched from the constructor's authenticated + // listener: re-claiming (a React effect remounting, StrictMode's double + // mount) replaces the handler instead of stacking another listener on the + // busiest message path in the app. + this.singletonHandlers.set(name, onChange); this.vscode.postMessage({ type: 'singleton:claim', name }); } diff --git a/lib/src/main.tsx b/lib/src/main.tsx index d6d9d909..27918688 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -32,7 +32,7 @@ initAlertStateReceiver(); // immediately and keep localStorage. Promise.all([ resumeOrRestore(platform), - platform.hydrateScopedStore?.(REMOTE_HOST_STORE_PREFIX) ?? Promise.resolve(), + platform.hydrateScopedStore?.(REMOTE_HOST_STORE_PREFIX), ]).then(([result]) => { createRoot(document.getElementById("root")!).render( diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index bb1d8e9f..a3e90163 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -57,12 +57,25 @@ async function freshModule() { beforeEach(() => { started.length = 0; claimSingleton = undefined; + // 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(); }); +async function installWithLease() { + let grant!: (held: boolean) => void; + claimSingleton = (_name, onChange) => { + grant = onChange; + }; + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + return { mod, grant: (held: boolean) => grant(held) }; +} + describe('remote host activation lease', () => { it('activates immediately on a host with no lease (standalone)', async () => { const mod = await freshModule(); @@ -71,45 +84,32 @@ describe('remote host activation lease', () => { }); it('waits for the lease on a host that arbitrates', async () => { - let grant: ((held: boolean) => void) | null = null; - claimSingleton = (_name, onChange) => { - grant = onChange; - }; - - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); + const { grant } = await installWithLease(); // Mount alone must not start a Host — the answer has not arrived yet. expect(started).toHaveLength(0); - grant!(true); + grant(true); expect(started).toHaveLength(1); expect(started[0].stopped).toBe(false); }); it('stops when the lease is revoked and restarts when re-granted', async () => { - let grant: ((held: boolean) => void) | null = null; - claimSingleton = (_name, onChange) => { - grant = onChange; - }; - - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - grant!(true); + const { grant } = await installWithLease(); + grant(true); expect(started).toHaveLength(1); - grant!(false); + grant(false); expect(started[0].stopped).toBe(true); - grant!(true); + grant(true); expect(started).toHaveLength(2); }); it('a repeated grant does not start a second Host', async () => { - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - mod.setRemoteHostOwnership(true); - mod.setRemoteHostOwnership(true); + const { grant } = await installWithLease(); + grant(true); + grant(true); expect(started).toHaveLength(1); }); @@ -122,4 +122,14 @@ describe('remote host activation lease', () => { expect(names).toEqual(['remote-host']); }); + + it('enrolling from a non-holder does not start a competing Host', async () => { + await installWithLease(); + const hook = (globalThis as { dormouseRemoteHost?: { enroll: (a: string, b: string, c: string) => Promise } }) + .dormouseRemoteHost!; + + await hook.enroll('https://relay.example.ts.net', 'password', 'Laptop'); + + expect(started).toHaveLength(0); + }); }); diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 0f613b9a..458fe369 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -123,20 +123,25 @@ function remoteHostStatus(): RemoteHostConsoleStatus { export function installRemoteHostConsoleHook(): void { // A host that can show several webviews arbitrates which one is the Host. // Start un-owned so two webviews racing to mount cannot both activate before - // the first lease answer arrives. - const claimSingleton = getPlatform().claimSingleton; - if (claimSingleton) { + // the first lease answer arrives, and let the grant do the activating. + // Called through the platform object, never a detached reference — the + // adapter's methods are `this`-bound to their message channel. + const platform = getPlatform(); + if (platform.claimSingleton) { owned = false; - claimSingleton('remote-host', setRemoteHostOwnership); + platform.claimSingleton('remote-host', setRemoteHostOwnership); + } else { + activateRemoteHost(); } - 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); + // Enrolling does not override the lease: a webview that is not the holder + // persists the credentials and leaves starting to whichever one is. + if (owned) current = startFromEnrollment(enrollment); return { hostId: enrollment.hostId, serverUrl: enrollment.serverUrl }; }, status: remoteHostStatus, diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index 3b719af8..f0acee4f 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -16,6 +16,7 @@ import { API_ROUTES, type HostEnrollResponse } from 'server-lib-common'; import { loadJson, removeJson, saveJson } 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`. */ @@ -29,8 +30,7 @@ export interface HostEnrollment { rpId: string; } -/** Single store key holding the whole enrollment blob. */ -export const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; +export { ENROLLMENT_KEY } from './store'; function isEnrollment(value: unknown): value is HostEnrollment { if (!value || typeof value !== 'object') return false; diff --git a/lib/src/remote/host/store.ts b/lib/src/remote/host/store.ts index 5bc48b37..d4e205f3 100644 --- a/lib/src/remote/host/store.ts +++ b/lib/src/remote/host/store.ts @@ -10,3 +10,10 @@ * prefix, so a webview can never reach unrelated extension storage. */ export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; + +/** + * The enrollment blob's key. It lives here rather than in `enrollment.ts` so + * the extension host can import it without pulling `server-lib-common` into the + * extension bundle; `enrollment.ts` re-exports it for its own callers. + */ +export const ENROLLMENT_KEY = `${REMOTE_HOST_STORE_PREFIX}enrollment`; diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs new file mode 100644 index 00000000..ccd4b1ba --- /dev/null +++ b/scripts/csp-defaults.mjs @@ -0,0 +1,25 @@ +// The one definition of where a Host may reach a relay server, shared by both +// Hosts' build scripts. +// +// The standalone binary and the VS Code extension bake this into their webview +// CSP by different mechanisms — Tauri has a config file to override +// (`standalone/scripts/csp.mjs` + `tauri.mjs`), the extension has no runtime +// config so esbuild substitutes a bundle literal +// (`vscode-ext/scripts/esbuild.mjs`) — but 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". + +/** The remote-server `connect-src` sources baked into the published builds. */ +export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +/** + * 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. + */ +export function resolveRemoteConnectSrc(env = process.env, label = 'build') { + const override = env.DORMOUSE_REMOTE_CONNECT_SRC?.trim(); + if (!override) return DEFAULT_REMOTE_CONNECT_SRC; + console.error(`[${label}] connect-src remote sources overridden: ${override}`); + return override; +} diff --git a/standalone/scripts/csp.mjs b/standalone/scripts/csp.mjs index 8815ffff..8041b58d 100644 --- a/standalone/scripts/csp.mjs +++ b/standalone/scripts/csp.mjs @@ -9,8 +9,10 @@ // — 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'; +// Defined once for both Hosts in scripts/csp-defaults.mjs; re-exported here so +// this module stays the single entry point for the standalone CSP rules. +export { DEFAULT_REMOTE_CONNECT_SRC } from '../../scripts/csp-defaults.mjs'; +import { DEFAULT_REMOTE_CONNECT_SRC } from '../../scripts/csp-defaults.mjs'; /** * Return `baseCsp` with its default remote-server sources replaced by diff --git a/vscode-ext/package.json b/vscode-ext/package.json index df675b9f..d378ef68 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1,7 +1,7 @@ { "name": "dormouse", - "displayName": "Dormouse \u2014 Terminal Multiplexer", - "description": "A persistent multitasking terminal \u2014 tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", + "displayName": "Dormouse — Terminal Multiplexer", + "description": "A persistent multitasking terminal — tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", "version": "1.1.0", "publisher": "diffplug", "license": "FSL-1.1-MIT", @@ -106,7 +106,7 @@ "test": "pnpm typecheck", "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 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", diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs index ab7ba4e7..857708ec 100644 --- a/vscode-ext/scripts/esbuild.mjs +++ b/vscode-ext/scripts/esbuild.mjs @@ -13,13 +13,9 @@ import * as esbuild from 'esbuild'; -/** The remote-server sources baked into the published extension. */ -export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; +import { resolveRemoteConnectSrc } from '../../scripts/csp-defaults.mjs'; -const remoteSrc = process.env.DORMOUSE_REMOTE_CONNECT_SRC?.trim() || DEFAULT_REMOTE_CONNECT_SRC; -if (remoteSrc !== DEFAULT_REMOTE_CONNECT_SRC) { - console.error(`[esbuild] webview connect-src remote sources overridden: ${remoteSrc}`); -} +const remoteSrc = resolveRemoteConnectSrc(process.env, 'esbuild'); const watch = process.argv.includes('--watch'); @@ -41,7 +37,6 @@ const builds = [ ...common, entryPoints: ['src/pty-host.js'], outfile: 'dist/pty-host.js', - external: ['node-pty'], }, ]; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 68632a7e..50936216 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -45,26 +45,31 @@ const globalOwnedPtyIds = new Set(); */ interface SingletonClaimant { wants: Set; - holds: Set; notify(name: string, held: boolean): void; } const singletonClaimants = new Set(); +/** Who currently holds each role — the one place the answer is stored. */ +const singletonHolders = new Map(); function electSingleton(name: string): void { - const holder = [...singletonClaimants].find((c) => c.holds.has(name)); - if (holder) return; - const next = [...singletonClaimants].find((c) => c.wants.has(name)); - if (!next) return; - next.holds.add(name); - next.notify(name, true); + let holder = singletonHolders.get(name); + if (!holder) { + holder = [...singletonClaimants].find((claimant) => claimant.wants.has(name)); + if (!holder) return; + singletonHolders.set(name, holder); + } + // Idempotent: re-claiming (a webview remounting) re-answers the holder. + holder.notify(name, true); } function releaseSingletons(claimant: SingletonClaimant): void { - const released = [...claimant.holds]; - claimant.holds.clear(); claimant.wants.clear(); singletonClaimants.delete(claimant); - for (const name of released) electSingleton(name); + for (const [name, holder] of singletonHolders) { + if (holder !== claimant) continue; + singletonHolders.delete(name); + electSingleton(name); + } } interface ActiveRouter { flushSessionSave(timeoutMs?: number): Promise; @@ -209,7 +214,6 @@ export function attachRouter( // This webview's stake in the window-wide single-instance roles. const claimant: SingletonClaimant = { wants: new Set(), - holds: new Set(), notify: (name, held) => void post({ type: 'singleton:lease', name, held } satisfies ExtensionMessage), }; @@ -538,21 +542,17 @@ export function attachRouter( break; case 'singleton:claim': claimant.wants.add(msg.name); - if (claimant.holds.has(msg.name)) claimant.notify(msg.name, true); - else electSingleton(msg.name); + electSingleton(msg.name); break; case 'store:read': // The Host's enrollment + ACL live in extension-host storage, not in // webview localStorage (remote-host-store.ts explains why). Both sides // gate on the key prefix. - readStore(typeof msg.prefix === 'string' ? msg.prefix : '').then( - (entries) => post({ + readStore(typeof msg.prefix === 'string' ? msg.prefix : '') + .catch(() => ({})) + .then((entries) => post({ type: 'store:entries', requestId: msg.requestId, entries, - } satisfies ExtensionMessage), - () => post({ - type: 'store:entries', requestId: msg.requestId, entries: {}, - } satisfies ExtensionMessage), - ); + } satisfies ExtensionMessage)); break; case 'store:write': void writeStore(msg.key, msg.value); diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index 69cccc73..309eee43 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -19,11 +19,12 @@ import type * as vscode from 'vscode'; -/** Mirrors `lib/src/remote/host/store.ts`; both sides gate on it. */ -export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; +// Imported, not mirrored: a prefix that drifted between the two sides would +// break the gate in one direction only. `store.ts` is dependency-free so it +// costs the extension bundle nothing. +import { ENROLLMENT_KEY, REMOTE_HOST_STORE_PREFIX } from '../../lib/src/remote/host/store'; -/** Mirrors `lib/src/remote/host/enrollment.ts`; the one secret-backed key. */ -const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; +export { REMOTE_HOST_STORE_PREFIX }; /** * Enough for an enrollment blob or a sizable ACL, small enough that a @@ -50,17 +51,20 @@ export async function readStore(prefix: string): Promise> if (!context || !allowed(prefix)) return {}; const entries: Record = {}; - const enrollment = await context.secrets.get(ENROLLMENT_KEY); - if (enrollment !== undefined && ENROLLMENT_KEY.startsWith(prefix)) { - entries[ENROLLMENT_KEY] = enrollment; - } - + // `allowed(prefix)` above already proved every in-range key is in namespace. for (const key of context.globalState.keys()) { - if (!allowed(key) || !key.startsWith(prefix) || key === ENROLLMENT_KEY) continue; + if (!key.startsWith(prefix) || key === ENROLLMENT_KEY) continue; const value = context.globalState.get(key); if (typeof value === 'string') entries[key] = value; } + // Guard before the read: a narrower prefix must not pay for a keychain hit + // whose result it would discard. + if (ENROLLMENT_KEY.startsWith(prefix)) { + const enrollment = await context.secrets.get(ENROLLMENT_KEY); + if (enrollment !== undefined) entries[ENROLLMENT_KEY] = enrollment; + } + return entries; } diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index a575c0ad..ae608932 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -1,16 +1,18 @@ 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'; + /** * Remote-server `connect-src` sources, substituted by esbuild at build time * (`scripts/esbuild.mjs`). Declared rather than imported so the value is a * literal in the bundle and cannot be changed at runtime. */ declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; -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'; function serializeForInlineScript(value: unknown): string { return JSON.stringify(value ?? null) From fd5b63859e803c083aeef0662081f1534c2b006d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 11:02:57 -0700 Subject: [PATCH 05/56] Address review: keep host-store caches coherent across webviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lease hands the Host between webviews, but each webview hydrated the store once at boot and served every read from that snapshot. So a webview that mounted before another approved a pairing would, on taking the lease, load its stale ACL, start from it, and write the full record list back — dropping the pairing from globalState permanently, not just for the session. `clearEnrollment` had the mirror-image problem: one webview deletes the secret, another keeps `hostToken` in cache and starts a Host against a revoked enrollment. Committed writes are now broadcast to every webview as `store:changed` and applied to each cache, which is what makes the spec's claim — that closing one Dormouse view hands the Host to another — true regardless of when the others booted. `writeStore` returns whether it wrote so only real changes are announced. The broadcast includes the writer: re-applying your own write is a no-op, and skipping self would mean identifying it. Also from the review: - `store:write` and `singleton:claim` trusted their payload. `WebviewMessage` is a claim about the sender, not a runtime check, and a non-string key threw inside `allowed()` as an unhandled rejection instead of a refused write. Both now validate, matching what `store:read` already did. - The boot read inherited `requestResponse`'s 1s default while being gated on a `SecretStorage` unlock. A cold keychain could blow through it, installing an empty cache and leaving the Host silently un-enrolled — indistinguishable from never having enrolled. Budget is now 10s and a miss warns. The `try`/`catch` around it was dead: `requestResponse` resolves null rather than rejecting. - `esbuild.mjs` had no equivalent of the standalone drift guard, so losing the `define` would surface as a ReferenceError in `getWebviewHtml` and an empty webview, with nothing failing at build time. It now asserts the placeholder is gone from the bundle and the resolved sources are present. - Two doc comments pointed at a spec heading that does not exist, and the code map listed `scripts/esbuild.mjs` inside the `src/` tree it is not in. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 4 +- lib/src/lib/platform/vscode-adapter.test.ts | 152 ++++++++++++++++---- lib/src/lib/platform/vscode-adapter.ts | 61 ++++++-- vscode-ext/scripts/esbuild.mjs | 29 ++++ vscode-ext/src/message-router.ts | 34 ++++- vscode-ext/src/message-types.ts | 1 + vscode-ext/src/remote-host-store.ts | 16 ++- 7 files changed, 245 insertions(+), 52 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 80372d00..5c4200c7 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -23,7 +23,7 @@ Extension Host (vscode-ext/src/) ├── 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-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys -├── scripts/esbuild.mjs — extension + pty-host bundles; bakes the webview's remote `connect-src` +└── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -251,6 +251,8 @@ VS Code is a first-class remote Host. Two things have to be true that standalone Both sides gate on the prefix. The webview names the keys, so `remote-host-store.ts` refuses any key outside the Host namespace and caps values at 64 KiB; a compromised webview can neither read nor write unrelated extension state. +A boot-time snapshot alone would be wrong, because the lease hands the Host between webviews: a webview that hydrated before another approved a pairing would later take the lease, read its stale ACL, and write that back — dropping the pairing permanently. So a committed write is broadcast to every webview (`store:changed`) and applied to each cache. The broadcast goes to the writer too; re-applying your own write is a no-op, and skipping self would mean identifying it. Only writes that actually happened are announced, which is why `writeStore` returns whether it wrote. + Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vscode-adapter.ts` (`hydrateScopedStore`), `lib/src/lib/local-json-store.ts` (prefix claims), `lib/src/remote/host/store.ts` (the shared prefix). **The lease.** A window can show a `WebviewView` and any number of `WebviewPanel`s at once. Each mounts the same Wall, so each would start its own `RemoteHost` against the same enrollment — they would displace each other on the single `/ws/host` socket (`server/test/relay-displaced.test.mjs`) and each would arm its own alarm push. The extension host arbitrates instead, because it is the only party that sees every webview and outlives each one: `message-router.ts` grants the named role `remote-host` to the first claimant and re-offers it when the holder is disposed, so closing the Dormouse view hands the Host to another open one rather than dropping it until a reload. diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index f457e333..fa5b1fd6 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -29,6 +29,7 @@ import { } from '../terminal-protocol'; import { HOST_MESSAGE_TOKEN_FIELD, HOST_MESSAGE_TOKEN_GLOBAL } from '../vscode-message-token'; import { VSCodeAdapter } from './vscode-adapter'; +import { loadJson, saveJson, setJsonStoreBackend } from '../local-json-store'; /** Stand-in for the per-boot token the extension host injects at webview boot. */ const HOST_TOKEN = 'test-host-message-token'; @@ -44,36 +45,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 +381,93 @@ describe('VSCodeAdapter PTY exit handling', () => { }); }); }); + + +describe('VSCodeAdapter host store', () => { + const PREFIX = 'dormouse.remote-host.'; + const KEY = `${PREFIX}acl.host-1`; + + beforeEach(stubWebviewEnv); + + afterEach(() => { + setJsonStoreBackend(PREFIX, null); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + /** Answer the `store:read` the adapter just posted, as the host would. */ + function answerRead(entries: Record): void { + const request = postMessage.mock.calls.map((call) => call[0]).find((m) => m.type === 'store:read'); + expect(request).toBeTruthy(); + windowTarget.dispatchEvent( + hostMessage({ type: 'store:entries', requestId: request.requestId, entries }), + ); + } + + async function hydrated(entries: Record) { + const adapter = new VSCodeAdapter(); + const done = adapter.hydrateScopedStore(PREFIX); + answerRead(entries); + await done; + return adapter; + } + + it('serves reads from the hydrated snapshot', async () => { + await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + expect(loadJson(KEY, [])).toEqual([{ id: 'a' }]); + }); + + it('writes through to the host', async () => { + await hydrated({}); + saveJson(KEY, [{ id: 'b' }]); + expect(postMessage).toHaveBeenCalledWith({ + type: 'store:write', + key: KEY, + value: JSON.stringify([{ id: 'b' }]), + }); + }); + + it("applies another webview's committed write, so a later lease grant is not stale", async () => { + await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + + // The webview holding the lease approves a pairing; the host broadcasts it. + windowTarget.dispatchEvent( + hostMessage({ type: 'store:changed', key: KEY, value: JSON.stringify([{ id: 'a' }, { id: 'b' }]) }), + ); + + // Without this the next holder would start from the boot snapshot and write + // it back, dropping the pairing permanently. + expect(loadJson(KEY, [])).toEqual([{ id: 'a' }, { id: 'b' }]); + }); + + it('applies a broadcast deletion', async () => { + await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + + windowTarget.dispatchEvent(hostMessage({ type: 'store:changed', key: KEY, value: null })); + + expect(loadJson(KEY, null)).toBeNull(); + }); + + it('ignores an unauthenticated broadcast', async () => { + await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + + windowTarget.dispatchEvent( + hostMessage({ type: 'store:changed', key: KEY, value: JSON.stringify([]) }, 'wrong-token'), + ); + + expect(loadJson(KEY, [])).toEqual([{ id: 'a' }]); + }); + + it('installs an empty cache when the host never answers', async () => { + vi.useFakeTimers(); + try { + const adapter = new VSCodeAdapter(); + const done = adapter.hydrateScopedStore(PREFIX); + await vi.advanceTimersByTimeAsync(10_000); + await done; + } finally { + vi.useRealTimers(); + } + expect(loadJson(KEY, null)).toBeNull(); + }); +}); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 9e901cb1..abf11ffa 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -16,6 +16,13 @@ import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; import { setJsonStoreBackend } from '../local-json-store'; +/** + * Budget for the boot-time host-store read. Generous because it is gated on an + * OS keychain unlock, and a miss degrades the Host to "un-enrolled" rather than + * failing loudly. + */ +const HOST_STORE_READ_TIMEOUT_MS = 10_000; + export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; private hostState: unknown = (globalThis as typeof globalThis & { __DORMOUSE_HOST_STATE__?: unknown }).__DORMOUSE_HOST_STATE__ ?? null; @@ -32,6 +39,8 @@ export class VSCodeAdapter implements PlatformAdapter { private watchedCommandHandlers = new Set<(names: string[]) => void>(); private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>(); private singletonHandlers = new Map void>(); + /** Hydrated host-store caches, by claimed prefix — see `hydrateScopedStore`. */ + private scopedCaches = new Map>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -155,6 +164,8 @@ export class VSCodeAdapter implements PlatformAdapter { })); } else if (msg.type === 'singleton:lease') { this.singletonHandlers.get(msg.name)?.(!!msg.held); + } else if (msg.type === 'store:changed') { + this.applyStoreChange(msg.key, msg.value ?? null); } }); } @@ -212,27 +223,34 @@ export class VSCodeAdapter implements PlatformAdapter { /** * Pull every `prefix`-scoped value out of extension-host storage and install - * a synchronous, write-through backend over it (docs/specs/vscode.md → "Host - * store"). Webview `localStorage` is not the VS Code persistence story, and + * a synchronous, write-through backend over it (docs/specs/vscode.md → + * "Remote Host: store and lease"). Webview `localStorage` is not the VS Code persistence story, and * the remote Host's enrollment carries a bearer credential that belongs in * `SecretStorage`, so the store has to live on the other side of the message * boundary. A failed read installs an empty cache rather than throwing: the * Host then behaves as un-enrolled instead of blocking webview boot. */ async hydrateScopedStore(prefix: string): Promise { - let entries: Record = {}; - try { - entries = - (await this.requestResponse( - 'store:read', - 'store:entries', - { prefix }, - (msg) => msg.entries as Record, - )) ?? {}; - } catch { - // Timed out or the host declined — fall through with an empty cache. + // The host answers only after reading `SecretStorage`, which on a cold OS + // keychain (or a locked libsecret) can take well over the default second. + // `requestResponse` resolves `null` on timeout rather than rejecting, so a + // too-short budget silently installs an empty cache and the Host reads as + // un-enrolled — indistinguishable from never having enrolled. + const entries = await this.requestResponse( + 'store:read', + 'store:entries', + { prefix }, + (msg) => msg.entries as Record, + HOST_STORE_READ_TIMEOUT_MS, + ); + if (entries === null) { + console.warn( + `[dormouse] host store "${prefix}" did not answer in ${HOST_STORE_READ_TIMEOUT_MS}ms; ` + + 'continuing without it. A remote Host enrollment will read as absent.', + ); } - const cache = new Map(Object.entries(entries)); + const cache = new Map(Object.entries(entries ?? {})); + this.scopedCaches.set(prefix, cache); setJsonStoreBackend(prefix, { getItem: (key) => cache.get(key) ?? null, setItem: (key, value) => { @@ -246,6 +264,21 @@ export class VSCodeAdapter implements PlatformAdapter { }); } + /** + * Apply another webview's committed write to this one's cache. Without it a + * webview serves reads from its boot-time snapshot forever, and — because the + * lease can hand it the Host later — would start from that snapshot and write + * it back, dropping every pairing the previous holder approved. + */ + private applyStoreChange(key: string, value: string | null): void { + for (const [prefix, cache] of this.scopedCaches) { + if (!key.startsWith(prefix)) continue; + if (value === null) cache.delete(key); + else cache.set(key, value); + return; + } + } + shutdown(): void { // No-op — the extension host handles cleanup } diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs index 857708ec..cd2ada20 100644 --- a/vscode-ext/scripts/esbuild.mjs +++ b/vscode-ext/scripts/esbuild.mjs @@ -11,6 +11,8 @@ // (`standalone/scripts/tauri.mjs` + `csp.mjs`) so both Hosts widen the same way // with the same variable. See docs/specs/server.md → "Host webview CSP". +import { readFileSync } from 'node:fs'; + import * as esbuild from 'esbuild'; import { resolveRemoteConnectSrc } from '../../scripts/csp-defaults.mjs'; @@ -48,4 +50,31 @@ if (watch) { console.error('[esbuild] watching'); } else { await Promise.all(builds.map((options) => esbuild.build(options))); + assertConnectSrcBaked(); +} + +/** + * Fail the build if the `define` did not reach the bundle. + * + * `webview-html.ts` reads `__DORMOUSE_REMOTE_CONNECT_SRC__` as a `declare const`, + * so if the substitution is ever lost — someone re-inlines the esbuild call, or + * adds a bundle entry that pulls in that module without the define — TypeScript + * still compiles and the failure only appears at runtime, as a ReferenceError + * inside `getWebviewHtml` that renders an empty webview. The standalone side + * fails loudly on the same class of drift (`standalone/scripts/csp.mjs`), so + * this side should too. + */ +function assertConnectSrcBaked() { + const bundle = readFileSync('dist/extension.js', 'utf8'); + if (bundle.includes('__DORMOUSE_REMOTE_CONNECT_SRC__')) { + throw new Error( + 'CSP: __DORMOUSE_REMOTE_CONNECT_SRC__ survived into dist/extension.js — the esbuild ' + + 'define did not apply, and the webview would throw a ReferenceError at render.', + ); + } + if (!bundle.includes(remoteSrc)) { + throw new Error( + `CSP: dist/extension.js does not contain the resolved connect-src sources (${remoteSrc}).`, + ); + } } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 50936216..ed6aea56 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -75,6 +75,21 @@ interface ActiveRouter { flushSessionSave(timeoutMs?: number): Promise; ownsPty(id: string): boolean; forwardDorControlRequest(request: DorControlRequest): void; + notifyStoreChanged(key: string, value: string | null): void; +} + +/** + * Tell every webview about a committed Host-store write. + * + * Each webview caches the store at boot and serves reads from that cache, so + * without this a second webview keeps a stale snapshot — and since the lease + * can hand it the Host later, it would start from that snapshot and write it + * back, losing every pairing approved by the previous holder. Broadcast to all + * routers including the writer: re-applying your own write is a no-op, and + * skipping self would mean identifying it. + */ +function broadcastStoreChange(key: string, value: string | null): void { + for (const router of activeRouters) router.notifyStoreChanged(key, value); } const activeRouters = new Set(); @@ -541,6 +556,8 @@ export function attachRouter( ); break; case 'singleton:claim': + // `WebviewMessage` is a claim about the sender, not a runtime check. + if (typeof msg.name !== 'string') break; claimant.wants.add(msg.name); electSingleton(msg.name); break; @@ -554,9 +571,18 @@ export function attachRouter( type: 'store:entries', requestId: msg.requestId, entries, } satisfies ExtensionMessage)); break; - case 'store:write': - void writeStore(msg.key, msg.value); + case 'store:write': { + // Same bar as `store:read` below: a non-string key would throw inside + // `allowed()` as an unhandled rejection rather than a refused write. + const key = msg.key; + const value = msg.value; + if (typeof key !== 'string') break; + if (typeof value !== 'string' && value !== null) break; + void writeStore(key, value).then((written) => { + if (written) broadcastStoreChange(key, value); + }); 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 }; @@ -721,6 +747,10 @@ export function attachRouter( flushSessionSave, ownsPty, forwardDorControlRequest, + notifyStoreChanged(key: string, value: string | null) { + if (disposed) return; + void post({ type: 'store:changed', key, value } satisfies ExtensionMessage); + }, dispose() { if (disposed) return; disposed = true; diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 7edade10..03199a27 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -78,6 +78,7 @@ export type ExtensionMessage = | { type: 'iframe:proxyUrl'; requestId: string; result: IframeProxyResult } | { type: 'store:entries'; requestId: string; entries: Record } | { type: 'singleton:lease'; name: string; held: boolean } + | { type: 'store:changed'; key: string; value: string | null } | { type: 'dormouse:newTerminal'; shell?: string; diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index 309eee43..03cb4317 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -1,6 +1,6 @@ /** * Extension-host storage for the webview's remote-Host keys - * (docs/specs/vscode.md → "Host store"). + * (docs/specs/vscode.md → "Remote Host: store and lease"). * * The webview cannot keep these in `localStorage`: VS Code's persistence story * is `setState`/`workspaceState`/`globalState`, and the enrollment blob carries @@ -68,16 +68,20 @@ export async function readStore(prefix: string): Promise> return entries; } -/** Write (or, with `null`, delete) one Host-namespace key. */ -export async function writeStore(key: string, value: string | null): Promise { - if (!context || !allowed(key)) return; - if (value !== null && Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) return; +/** + * Write (or, with `null`, delete) one Host-namespace key. Returns whether the + * write happened, so the caller only announces changes that are real. + */ +export async function writeStore(key: string, value: string | null): Promise { + if (!context || !allowed(key)) return false; + if (value !== null && Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) return false; if (key === ENROLLMENT_KEY) { if (value === null) await context.secrets.delete(key); else await context.secrets.store(key, value); - return; + return true; } await context.globalState.update(key, value === null ? undefined : value); + return true; } From 682c821a7ff11f5031bed896b60a3fe8e4729c7e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 11:16:10 -0700 Subject: [PATCH 06/56] Address second review: buffer in-flight writes, unblock first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real holes in the previous push. A broadcast that arrived while a webview was still hydrating was dropped: `applyStoreChange` walks the cache map, and the prefix only enters it once the read resolves. That is not a narrow window — the host snapshots `globalState` before it waits on the keychain, so another webview can commit a pairing that the in-flight snapshot cannot contain, and the widened timeout made the gap bigger. Reached through boot, it is the same permanent pairing loss the broadcast was added to prevent. Changes with no cache yet are now buffered with their value (a deletion has to survive too) and applied on top of the snapshot before it goes live. Raising the read budget to 10s also raised the blank-webview ceiling to 10s, because `main.tsx` gated `render` on it. The ordering constraint was never "hydrated before first paint" — it is "hydrated before anything reads a `dormouse.remote-host.` key", which happens when `installRemoteHostConsoleHook` runs, downstream of render in a lazily-mounted component. Boot now starts the read and publishes it via `setHostStoreReady`; `RemotePairingModalHost` awaits `hostStoreReady()` before installing. The terminal paints on `resumeOrRestore` alone. Also: the code-map entry closed the tree with a second terminator, one comment said `store:read` was below when it is above, and the `enroll` comment promised a handoff nothing performs — nothing signals the current holder, so the Host starts on the next lease grant or reload. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 6 +++-- lib/src/lib/platform/vscode-adapter.test.ts | 26 +++++++++++++++++++ lib/src/lib/platform/vscode-adapter.ts | 20 ++++++++++++++ lib/src/main.tsx | 20 +++++++------- .../remote/host/RemotePairingModalHost.tsx | 13 +++++++++- lib/src/remote/host/activation.ts | 3 ++- lib/src/remote/host/store.ts | 22 ++++++++++++++++ vscode-ext/src/message-router.ts | 2 +- 8 files changed, 97 insertions(+), 15 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 5c4200c7..2b09b676 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -23,7 +23,7 @@ Extension Host (vscode-ext/src/) ├── 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-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys -└── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` +├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -247,7 +247,9 @@ VS Code is a first-class remote Host. Two things have to be true that standalone **The store.** The Host's enrollment (`{ serverUrl, hostId, hostToken, origin, rpId }`) and its ACL persist through `local-json-store`, which defaults to `localStorage`. That is wrong here twice over: webview `localStorage` is not VS Code's persistence story, and `hostToken` is a bearer credential that grants the `/ws/host` socket. So the webview claims the `dormouse.remote-host.` prefix and backs it with the extension host — enrollment in `SecretStorage` (OS keychain), ACL in `globalState`, both global because a Host identity belongs to the machine and not to a folder. -`local-json-store` is synchronous by contract, so the store is pulled across at boot and installed as an in-memory, write-through backend before anything reads it: `lib/src/main.tsx` awaits `PlatformAdapter.hydrateScopedStore` alongside `resumeOrRestore`. A failed read installs an empty cache rather than throwing — the Host then behaves as un-enrolled instead of blocking webview boot. +`local-json-store` is synchronous by contract, so the store is pulled across at boot and installed as an in-memory, write-through backend. First paint deliberately does not wait on it: the read is gated on an OS keychain unlock, and a blank terminal for that long reads as a hang. The real constraint is narrower — hydrated before anything reads a `dormouse.remote-host.` key — so `lib/src/main.tsx` starts the read and publishes it with `setHostStoreReady`, and the lazily-mounted `RemotePairingModalHost` awaits `hostStoreReady()` before calling `installRemoteHostConsoleHook`. A read that never answers installs an empty cache and warns: the Host reads as un-enrolled, which is fail-safe for the data but would otherwise be silent. + +A broadcast that lands while a webview is still hydrating is buffered and applied on top of the snapshot, because the host reads `globalState` before it waits on the keychain — so the snapshot in flight can be older than a write that has already committed. Both sides gate on the prefix. The webview names the keys, so `remote-host-store.ts` refuses any key outside the Host namespace and caps values at 64 KiB; a compromised webview can neither read nor write unrelated extension state. diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index fa5b1fd6..911e4df8 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -458,6 +458,32 @@ describe('VSCodeAdapter host store', () => { expect(loadJson(KEY, [])).toEqual([{ id: 'a' }]); }); + it('keeps a write committed while the read is still in flight', async () => { + const adapter = new VSCodeAdapter(); + const done = adapter.hydrateScopedStore(PREFIX); + + // The host snapshots globalState before it waits on the keychain, so the + // holder can commit a pairing that the in-flight snapshot cannot contain. + windowTarget.dispatchEvent( + hostMessage({ type: 'store:changed', key: KEY, value: JSON.stringify([{ id: 'a' }, { id: 'b' }]) }), + ); + answerRead({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + await done; + + expect(loadJson(KEY, [])).toEqual([{ id: 'a' }, { id: 'b' }]); + }); + + it('keeps a deletion committed while the read is still in flight', async () => { + const adapter = new VSCodeAdapter(); + const done = adapter.hydrateScopedStore(PREFIX); + + windowTarget.dispatchEvent(hostMessage({ type: 'store:changed', key: KEY, value: null })); + answerRead({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + await done; + + expect(loadJson(KEY, null)).toBeNull(); + }); + it('installs an empty cache when the host never answers', async () => { vi.useFakeTimers(); try { diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index abf11ffa..16cf4f7b 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -41,6 +41,14 @@ export class VSCodeAdapter implements PlatformAdapter { private singletonHandlers = new Map void>(); /** Hydrated host-store caches, by claimed prefix — see `hydrateScopedStore`. */ private scopedCaches = new Map>(); + /** + * Broadcasts that landed before their prefix finished hydrating. The read is + * gated on a keychain unlock, so this window is wide enough to matter: the + * host snapshots `globalState` before that wait, so another webview can + * commit a change that the in-flight snapshot will not contain. Carries the + * value, not just the key, because a deletion has to survive too. + */ + private pendingStoreChanges = new Map(); constructor() { this.vscode = acquireVsCodeApi(); @@ -250,6 +258,14 @@ export class VSCodeAdapter implements PlatformAdapter { ); } const cache = new Map(Object.entries(entries ?? {})); + // Anything committed while the read was in flight is newer than the + // snapshot, so it is applied on top of it before the cache goes live. + for (const [key, pending] of this.pendingStoreChanges) { + if (!key.startsWith(prefix)) continue; + if (pending === null) cache.delete(key); + else cache.set(key, pending); + this.pendingStoreChanges.delete(key); + } this.scopedCaches.set(prefix, cache); setJsonStoreBackend(prefix, { getItem: (key) => cache.get(key) ?? null, @@ -277,6 +293,10 @@ export class VSCodeAdapter implements PlatformAdapter { else cache.set(key, value); return; } + // No cache holds this key yet: either its prefix is still hydrating (buffer + // it — `hydrateScopedStore` drains it) or nothing here claimed the prefix, + // in which case the entry is inert. + this.pendingStoreChanges.set(key, value); } shutdown(): void { diff --git a/lib/src/main.tsx b/lib/src/main.tsx index 27918688..854843b5 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -4,7 +4,7 @@ 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 { REMOTE_HOST_STORE_PREFIX } from "./remote/host/store"; +import { REMOTE_HOST_STORE_PREFIX, setHostStoreReady } from "./remote/host/store"; import App from "./App"; import "./index.css"; @@ -25,15 +25,15 @@ initAlertStateReceiver(); // Request PTY list before rendering so Wall can restore existing sessions. // On non-VSCode platforms (or first launch), this resolves immediately with no IDs. // -// The Host store is hydrated in the same wait: `local-json-store` is -// synchronous by contract, so a host that keeps those keys outside the webview -// (VS Code → extension-host SecretStorage) must have them in memory before the -// remote-Host modules read them at mount. Adapters without the hook resolve -// immediately and keep localStorage. -Promise.all([ - resumeOrRestore(platform), - platform.hydrateScopedStore?.(REMOTE_HOST_STORE_PREFIX), -]).then(([result]) => { +// Host-store hydration starts now but deliberately does not gate first paint: +// its read waits on an OS keychain, and a blank terminal for that long reads as +// a hang. `local-json-store` is synchronous by contract, so the keys must be in +// memory before the remote-Host modules read them — but that happens when the +// lazily-mounted Host calls `installRemoteHostConsoleHook`, well after render, +// so it awaits `hostStoreReady()` instead. +setHostStoreReady(platform.hydrateScopedStore?.(REMOTE_HOST_STORE_PREFIX)); + +resumeOrRestore(platform).then((result) => { createRoot(document.getElementById("root")!).render( diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx index c5bccd2e..0f0ee1f7 100644 --- a/lib/src/remote/host/RemotePairingModalHost.tsx +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -5,6 +5,7 @@ import { subscribePairingApproval, } from './pairing-approval'; import { installRemoteHostConsoleHook } from './activation'; +import { hostStoreReady } from './store'; /** * Renders the head of the pairing-approval queue and, on mount, activates the @@ -21,7 +22,17 @@ export function RemotePairingModalHost({ const head = pending[0] ?? null; useEffect(() => { - installRemoteHostConsoleHook(); + // The Host's enrollment and ACL may live outside the webview (VS Code), in + // which case boot started an async read that must land before anything + // reads those keys. First paint deliberately does not wait on it, so this + // is where the ordering is enforced. + let cancelled = false; + void hostStoreReady().then(() => { + if (!cancelled) installRemoteHostConsoleHook(); + }); + return () => { + cancelled = true; + }; }, []); useEffect(() => { diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 458fe369..1dcc7d98 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -140,7 +140,8 @@ export function installRemoteHostConsoleHook(): void { const enrollment = await enrollHost(serverUrl, password, label); stopRemoteHost(); // Enrolling does not override the lease: a webview that is not the holder - // persists the credentials and leaves starting to whichever one is. + // only persists the credentials. Nothing signals the current holder, so + // the Host starts on the next lease grant or reload, not on this call. if (owned) current = startFromEnrollment(enrollment); return { hostId: enrollment.hostId, serverUrl: enrollment.serverUrl }; }, diff --git a/lib/src/remote/host/store.ts b/lib/src/remote/host/store.ts index d4e205f3..b3cbae1f 100644 --- a/lib/src/remote/host/store.ts +++ b/lib/src/remote/host/store.ts @@ -17,3 +17,25 @@ export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; * extension bundle; `enrollment.ts` re-exports it for its own callers. */ export const ENROLLMENT_KEY = `${REMOTE_HOST_STORE_PREFIX}enrollment`; + +/** + * Resolves once the Host store is readable — see + * `PlatformAdapter.hydrateScopedStore`. + * + * The webview entry starts hydration at boot but must not gate first paint on + * it: the read waits on an OS keychain, which can take seconds, and a blank + * terminal for that long reads as a hang. The real ordering constraint is + * narrower — hydrated before anything reads a `dormouse.remote-host.` key, + * which happens when `installRemoteHostConsoleHook` runs, downstream of render. + * So the entry publishes the promise here and the lazily-mounted Host awaits + * it. Hosts that never hydrate leave the resolved default in place. + */ +let ready: Promise = Promise.resolve(); + +export function setHostStoreReady(promise: Promise | undefined): void { + ready = promise ?? Promise.resolve(); +} + +export function hostStoreReady(): Promise { + return ready; +} diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index ed6aea56..fe40bb22 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -572,7 +572,7 @@ export function attachRouter( } satisfies ExtensionMessage)); break; case 'store:write': { - // Same bar as `store:read` below: a non-string key would throw inside + // Same bar as `store:read` above: a non-string key would throw inside // `allowed()` as an unhandled rejection rather than a refused write. const key = msg.key; const value = msg.value; From 09f31cb5b219e9dae07a924c2ee88a6bbc524510 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 11:28:12 -0700 Subject: [PATCH 07/56] Arbitrate the Host role across VS Code windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webview lease was per-extension-host, and VS Code runs one per window. With two windows open, each elected its own Host, both connected `/ws/host` with the same enrollment, and the server closed the displaced socket — whose `close` handler reconnects with backoff and displaces the other one. That is not a degraded mode, it is an endless fight, with each window arming its own alarm push. Multiple windows are a normal way to use VS Code, so window-local arbitration was never enough. A window may now grant the role only while it holds a lease recorded in the extension's `globalStorageUri`: per-extension, shared by every window, and with no cross-window change event to depend on, so ownership is a heartbeat with a TTL rather than a flag. The holder re-stamps every 5s and a record unstamped for 15s is free, which is what recovers the role from a window killed without running its disposables. A clean dispose unlinks the record, and a filesystem watcher lets the next window take over without waiting for its poll. Two cases the rules have to get right, both tested: a fresh claim is confirmed by re-reading, because two windows can judge the same record stale in the same instant and both write, and the loser must not believe it won; and a heartbeat stamped far in the future is treated as stale, or a clock jump would lock every window out until the skew elapsed. This makes the revocation path load-bearing. Losing the window lease is not just losing the right to be re-offered the role — the webview holding it is told `held: false` and stops its Host. A `/simplify` pass had flagged that branch as dead and suggested a grant-only protocol; keeping it was right. The decision logic and the cycle are pure and live in lib so the concurrency cases are testable without a filesystem; `window-lease.ts` is the fs and timers around them. Nothing starts until a webview first claims `remote-host`, so a user who never enrolls a Host never sees the file or the timer. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 13 +- lib/src/lib/vscode-window-lease.test.ts | 186 ++++++++++++++++++++++++ lib/src/lib/vscode-window-lease.ts | 101 +++++++++++++ vscode-ext/src/extension.ts | 4 + vscode-ext/src/message-router.ts | 38 +++++ vscode-ext/src/window-lease.ts | 184 +++++++++++++++++++++++ 6 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 lib/src/lib/vscode-window-lease.test.ts create mode 100644 lib/src/lib/vscode-window-lease.ts create mode 100644 vscode-ext/src/window-lease.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 2b09b676..08a20632 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -23,6 +23,7 @@ Extension Host (vscode-ext/src/) ├── 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-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys +├── window-lease.ts — cross-window Host lease: heartbeat record in globalStorageUri ├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -261,7 +262,17 @@ Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vs On the webview side `activation.ts` starts un-owned whenever the adapter offers `claimSingleton`, so two webviews racing to mount cannot both activate before the first answer arrives. Adapters without the hook (standalone, the website) are single-instance and stay owned from the start. -**Scope of the lease.** It is per-window, because the extension host is per-window — but the enrollment it guards lives in `SecretStorage`/`globalState`, which are machine-wide. Two VS Code windows each showing a Dormouse view therefore elect one holder *each*, and the relay displaces whichever connected first. The lease removes the multi-webview case, not the multi-window one; a cross-window lease would have to be arbitrated on shared state rather than in extension-host memory. Until then the server remains the final arbiter, which is correct but noisier than it should be. +**Across windows.** The election above is per-window, because the extension host is — but the enrollment it guards is machine-wide, so window-local arbitration alone is not enough. Left there, every window would elect its own Host, all of them would connect `/ws/host` with the same enrollment, 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. + +So there is a second tier. A window may grant the role only while it holds a lease recorded in the extension's `globalStorageUri` — per-extension, shared by every window, and (unlike `globalState`) with no cross-window change event to depend on, so ownership is a heartbeat with a TTL rather than a flag. The holder re-stamps every 5s; a record unstamped for 15s is free. That TTL is what recovers the role from a window that died without running its disposables; a clean dispose deletes the record so the handoff is prompt, and a filesystem watcher makes the next window notice without waiting for its poll. + +A fresh claim is confirmed by re-reading: two windows can judge the same record stale in the same instant and both write, and the loser must not believe it won. Renewing skips that round trip, since the record already named the renewer. A heartbeat stamped far in the *future* counts as stale too — otherwise a clock jump would lock every window out of the role until the skew elapsed. + +Losing the window lease is not merely losing the right to be re-offered the role: any webview holding it is told `held: false` and stops its Host. That is the one path that sends a revocation, and it is why the lease is a boolean rather than a one-way grant. + +Nothing here starts until a webview first claims `remote-host`, so a user who never enrolls a Host never gets the file or the timer. + +Source of truth: the rules and the cycle in `lib/src/lib/vscode-window-lease.ts` (tested in `lib/src/lib/vscode-window-lease.test.ts`), the filesystem and timers around them in `vscode-ext/src/window-lease.ts`, and `windowLeaseHeld` gating `electSingleton` in `vscode-ext/src/message-router.ts`. Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PlatformAdapter.claimSingleton`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. diff --git a/lib/src/lib/vscode-window-lease.test.ts b/lib/src/lib/vscode-window-lease.test.ts new file mode 100644 index 00000000..15b5a010 --- /dev/null +++ b/lib/src/lib/vscode-window-lease.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { + LEASE_TTL_MS, + decideWindowLease, + isWindowLeaseRecord, + runWindowLeaseCycle, + type WindowLeaseIo, + type WindowLeaseRecord, +} from './vscode-window-lease'; + +const SELF = 'window-a'; +const NOW = 1_700_000_000_000; + +describe('decideWindowLease', () => { + it('takes an unclaimed lease', () => { + expect(decideWindowLease(null, SELF, NOW)).toBe('take'); + }); + + it('holds its own claim', () => { + expect(decideWindowLease({ owner: SELF, heartbeatAt: NOW - 1_000 }, SELF, NOW)).toBe('hold'); + }); + + it('holds its own claim even when the heartbeat has gone stale', () => { + // Our own stale record means we were slow, not that we lost it — re-stamp + // rather than racing ourselves for a lease we already hold. + const stale = { owner: SELF, heartbeatAt: NOW - LEASE_TTL_MS * 10 }; + expect(decideWindowLease(stale, SELF, NOW)).toBe('hold'); + }); + + it('waits while another window is heartbeating', () => { + expect(decideWindowLease({ owner: 'window-b', heartbeatAt: NOW - 1_000 }, SELF, NOW)).toBe('wait'); + }); + + it('takes over once another window stops heartbeating', () => { + const abandoned = { owner: 'window-b', heartbeatAt: NOW - LEASE_TTL_MS - 1 }; + expect(decideWindowLease(abandoned, SELF, NOW)).toBe('take'); + }); + + it('takes over a heartbeat stamped far in the future', () => { + // Otherwise a clock jump locks every window out until the skew elapses. + const skewed = { owner: 'window-b', heartbeatAt: NOW + LEASE_TTL_MS + 1 }; + expect(decideWindowLease(skewed, SELF, NOW)).toBe('take'); + }); + + it('respects an explicit ttl', () => { + const record = { owner: 'window-b', heartbeatAt: NOW - 100 }; + expect(decideWindowLease(record, SELF, NOW, 50)).toBe('take'); + expect(decideWindowLease(record, SELF, NOW, 1_000)).toBe('wait'); + }); +}); + +describe('isWindowLeaseRecord', () => { + it('accepts a well-formed record', () => { + expect(isWindowLeaseRecord({ owner: 'w', heartbeatAt: NOW })).toBe(true); + }); + + it('rejects malformed or partial records', () => { + expect(isWindowLeaseRecord(null)).toBe(false); + expect(isWindowLeaseRecord({})).toBe(false); + expect(isWindowLeaseRecord({ owner: 'w' })).toBe(false); + expect(isWindowLeaseRecord({ owner: 5, heartbeatAt: NOW })).toBe(false); + expect(isWindowLeaseRecord({ owner: 'w', heartbeatAt: 'soon' })).toBe(false); + expect(isWindowLeaseRecord({ owner: 'w', heartbeatAt: NaN })).toBe(false); + }); +}); + + +/** A shared lease file every simulated window reads and writes. */ +function fakeFile(initial: WindowLeaseRecord | null = null) { + let record = initial; + let onSettle: (() => void) | null = null; + return { + get record() { + return record; + }, + set record(next: WindowLeaseRecord | null) { + record = next; + }, + /** Run `fn` while a claimant is between writing and confirming. */ + duringSettle(fn: () => void) { + onSettle = fn; + }, + io(selfId: string, now = () => NOW): WindowLeaseIo { + return { + read: async () => record, + write: async (next) => { + record = next; + }, + now, + settle: async () => { + onSettle?.(); + onSettle = null; + }, + }; + }, + }; +} + +describe('runWindowLeaseCycle', () => { + it('claims an unheld lease and confirms it', async () => { + const file = fakeFile(); + expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(true); + expect(file.record?.owner).toBe(SELF); + }); + + it('does not claim while another window is alive', async () => { + const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - 1_000 }); + expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(false); + // And it must not have stamped over the live holder. + expect(file.record?.owner).toBe('window-b'); + }); + + it('takes over an abandoned lease', async () => { + const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - LEASE_TTL_MS - 1 }); + expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(true); + expect(file.record?.owner).toBe(SELF); + }); + + it('loses a contested takeover to the window that wrote last', async () => { + const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - LEASE_TTL_MS - 1 }); + // Both windows judge the same record stale; the other one writes second. + file.duringSettle(() => { + file.record = { owner: 'window-c', heartbeatAt: NOW }; + }); + + expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(false); + expect(file.record?.owner).toBe('window-c'); + }); + + it('renews without paying for a confirmation round trip', async () => { + const file = fakeFile({ owner: SELF, heartbeatAt: NOW - 1_000 }); + let settled = false; + const io = file.io(SELF); + const held = await runWindowLeaseCycle( + { ...io, settle: async () => { settled = true; } }, + SELF, + ); + + expect(held).toBe(true); + expect(settled).toBe(false); + }); + + it('re-stamps the heartbeat on renewal', async () => { + const file = fakeFile({ owner: SELF, heartbeatAt: NOW - 4_000 }); + await runWindowLeaseCycle(file.io(SELF, () => NOW), SELF); + expect(file.record?.heartbeatAt).toBe(NOW); + }); + + it('propagates a write failure rather than claiming', async () => { + const file = fakeFile(); + const io: WindowLeaseIo = { + ...file.io(SELF), + write: async () => { + throw new Error('read-only filesystem'); + }, + }; + await expect(runWindowLeaseCycle(io, SELF)).rejects.toThrow('read-only'); + }); + + it('hands over cleanly when the holder releases', async () => { + const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - 1_000 }); + expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(false); + + // window-b disposed and unlinked the file. + file.record = null; + + expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(true); + }); + + it('keeps exactly one holder across many contending windows', async () => { + const file = fakeFile(); + const ids = ['w1', 'w2', 'w3', 'w4']; + + // First pass: one wins the empty file. + const first = []; + for (const id of ids) first.push(await runWindowLeaseCycle(file.io(id), id)); + expect(first.filter(Boolean)).toHaveLength(1); + + // Steady state: the winner renews, everyone else keeps standing down. + const owner = file.record!.owner; + const second = []; + for (const id of ids) second.push(await runWindowLeaseCycle(file.io(id), id)); + expect(second.filter(Boolean)).toHaveLength(1); + expect(file.record?.owner).toBe(owner); + }); +}); diff --git a/lib/src/lib/vscode-window-lease.ts b/lib/src/lib/vscode-window-lease.ts new file mode 100644 index 00000000..22a1f469 --- /dev/null +++ b/lib/src/lib/vscode-window-lease.ts @@ -0,0 +1,101 @@ +/** + * The decision half of the cross-window Host lease (docs/specs/vscode.md → + * "Remote Host: store and lease"). + * + * VS Code runs one extension host per window, so the in-window webview lease + * cannot see another window. Without a second tier every window elects its own + * Host, they all connect `/ws/host` with the same enrollment, and the server + * displaces whoever connected first (`server/src/relay.ts`) — whose `close` + * handler reconnects and displaces the next one, forever, each window arming + * its own alarm push. + * + * Arbitration therefore has to happen on state every window can see. The I/O + * half lives in `vscode-ext/src/window-lease.ts`, which keeps a heartbeat + * record in the extension's `globalStorageUri`; this module holds the rules, + * which is where the interesting cases are (staleness, self-ownership, a clock + * that jumped). It is pure so those cases are testable without a filesystem. + */ + +/** One window's claim on being the Host, as persisted in the lease file. */ +export interface WindowLeaseRecord { + /** Random per-extension-host id — identifies the window, not the machine. */ + owner: string; + /** When the owner last proved it was alive, as epoch ms. */ + heartbeatAt: number; +} + +/** + * How long a record outlives its last heartbeat. A window that is killed + * without running its disposables (a crash, a force-quit) leaves the file + * behind, so the only thing that frees it is age. + */ +export const LEASE_TTL_MS = 15_000; + +/** How often the holder re-stamps its heartbeat, and others re-check. */ +export const LEASE_RENEW_MS = 5_000; + +export function isWindowLeaseRecord(value: unknown): value is WindowLeaseRecord { + if (!value || typeof value !== 'object') return false; + const record = value as WindowLeaseRecord; + return typeof record.owner === 'string' && Number.isFinite(record.heartbeatAt); +} + +/** + * `take` — write our own record; `hold` — ours already, re-stamp it; + * `wait` — someone else holds a live claim. + */ +export type WindowLeaseAction = 'take' | 'hold' | 'wait'; + +export function decideWindowLease( + record: WindowLeaseRecord | null, + selfId: string, + now: number, + ttlMs = LEASE_TTL_MS, +): WindowLeaseAction { + if (!record) return 'take'; + if (record.owner === selfId) return 'hold'; + // A heartbeat far in the future is as unusable as one far in the past: the + // clock moved under us, and treating it as live would deadlock every window + // out of the role until the skew elapsed. + if (Math.abs(now - record.heartbeatAt) > ttlMs) return 'take'; + return 'wait'; +} + +/** + * The filesystem the lease cycle needs, so the protocol can be exercised + * without one. `settle` is the pause between claiming and believing the claim. + */ +export interface WindowLeaseIo { + read(): Promise; + write(record: WindowLeaseRecord): Promise; + now(): number; + settle(): Promise; +} + +/** + * Run one arbitration cycle and report whether this window holds the role + * afterwards. + * + * A fresh claim is confirmed by re-reading, because two windows can judge the + * same record stale in the same instant and both write — the file keeps one of + * them, and the loser must not believe it won. Renewing an existing claim skips + * that round trip: the record already named us, so a takeover would have to + * have happened inside this cycle, and the next one catches it. + * + * Write failures propagate; a lease you cannot write is one you cannot hold. + */ +export async function runWindowLeaseCycle( + io: WindowLeaseIo, + selfId: string, + ttlMs = LEASE_TTL_MS, +): Promise { + const action = decideWindowLease(await io.read(), selfId, io.now(), ttlMs); + if (action === 'wait') return false; + + await io.write({ owner: selfId, heartbeatAt: io.now() }); + if (action === 'hold') return true; + + await io.settle(); + const confirmed = await io.read(); + return confirmed?.owner === selfId; +} diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 7e706f20..561b3ee0 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -12,6 +12,7 @@ import { workspaceTitle } from './workspace-chrome'; import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection'; import type { ExtensionMessage } from './message-types'; import { initRemoteHostStore } from './remote-host-store'; +import { initWindowLease } from './window-lease'; type NewTerminalMessage = Extract; @@ -78,6 +79,9 @@ export function activate(context: vscode.ExtensionContext) { // read by the webview through `store:read`; give the store its context // before any webview can ask. See remote-host-store.ts. initRemoteHostStore(context); + // Storage location only — the lease itself does not start until a webview + // claims the Host role (window-lease.ts). + initWindowLease(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 fe40bb22..3a9fcfac 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -22,6 +22,7 @@ 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 { readStore, writeStore } from './remote-host-store'; +import { ensureWindowLease } from './window-lease'; import { log } from './log'; import type { WebviewChannel } from './webview-messaging'; @@ -51,7 +52,41 @@ const singletonClaimants = new Set(); /** Who currently holds each role — the one place the answer is stored. */ const singletonHolders = new Map(); +/** + * Whether this *window* may hold single-instance roles at all. + * + * One extension host runs per window, so the arbitration above is blind to + * every other window. Left to itself each window would elect its own Host, all + * of them would connect `/ws/host` with the same enrollment, and the server's + * displacement would turn into an endless reconnect fight. `window-lease.ts` + * arbitrates across windows on shared storage; nothing is granted here until it + * says this window won. + */ +let windowLeaseHeld = false; + +function wantedSingletonNames(): Set { + const names = new Set(); + for (const claimant of singletonClaimants) { + for (const name of claimant.wants) names.add(name); + } + return names; +} + +function onWindowLeaseChange(held: boolean): void { + if (windowLeaseHeld === held) return; + windowLeaseHeld = held; + if (held) { + for (const name of wantedSingletonNames()) electSingleton(name); + return; + } + // Lost across windows: whoever held it here must stop, not merely stop being + // re-offered it. + for (const [name, holder] of singletonHolders) holder.notify(name, false); + singletonHolders.clear(); +} + function electSingleton(name: string): void { + if (!windowLeaseHeld) return; let holder = singletonHolders.get(name); if (!holder) { holder = [...singletonClaimants].find((claimant) => claimant.wants.has(name)); @@ -559,6 +594,9 @@ export function attachRouter( // `WebviewMessage` is a claim about the sender, not a runtime check. if (typeof msg.name !== 'string') break; claimant.wants.add(msg.name); + // First claim in this window starts the cross-window arbitration; it + // answers asynchronously, and `onWindowLeaseChange` elects when it does. + ensureWindowLease(onWindowLeaseChange); electSingleton(msg.name); break; case 'store:read': diff --git a/vscode-ext/src/window-lease.ts b/vscode-ext/src/window-lease.ts new file mode 100644 index 00000000..163799af --- /dev/null +++ b/vscode-ext/src/window-lease.ts @@ -0,0 +1,184 @@ +/** + * The I/O half of the cross-window Host lease. Rules and rationale live in + * `lib/src/lib/vscode-window-lease.ts`; this file is the filesystem and timers + * around them. + * + * The shared state is a single JSON record in the extension's + * `globalStorageUri` — per-extension, shared by every window, and unlike + * `globalState` it has no cross-window change event to rely on, so ownership is + * a heartbeat with a TTL rather than a flag. A window that dies without running + * its disposables leaves the file behind; only staleness frees it. + * + * Lazily started: a user who never enrolls a Host should never see this file or + * its timer, so nothing here runs until a webview claims the `remote-host` role. + */ + +import { randomUUID } from 'node:crypto'; +import { watch, type FSWatcher } from 'node:fs'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type * as vscode from 'vscode'; + +import { + LEASE_RENEW_MS, + isWindowLeaseRecord, + runWindowLeaseCycle, + type WindowLeaseRecord, +} from '../../lib/src/lib/vscode-window-lease'; +import { log } from './log'; + +const LEASE_FILE = 'remote-host.lease.json'; + +/** + * How long to wait before confirming a write landed. Two windows can find the + * same lease stale and both write; the file ends up with one of them, so the + * writer re-reads before believing itself the owner. + */ +const CLAIM_VERIFY_MS = 250; + +interface LeaseState { + dir: string; + file: string; + selfId: string; + held: boolean; + timer: ReturnType | null; + watcher: FSWatcher | null; + onChange: (held: boolean) => void; + stopped: boolean; +} + +let state: LeaseState | null = null; +let extensionContext: vscode.ExtensionContext | null = null; + +/** + * Hand the lease its storage location. Deliberately does no I/O: a user who + * never enrolls a Host should never see the file or its timer, so arbitration + * does not begin until {@link ensureWindowLease}. + */ +export function initWindowLease(context: vscode.ExtensionContext): void { + extensionContext = context; +} + +async function readRecord(file: string): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(file, 'utf8')); + return isWindowLeaseRecord(parsed) ? parsed : null; + } catch { + // Missing, truncated mid-write, or corrupt — all mean "no live claim". + return null; + } +} + +/** Write via temp + rename so a reader never sees a half-written record. */ +async function writeRecord(current: LeaseState, record: WindowLeaseRecord): Promise { + const temp = `${current.file}.${current.selfId}.tmp`; + await writeFile(temp, JSON.stringify(record), 'utf8'); + await rename(temp, current.file); +} + +function setHeld(current: LeaseState, held: boolean): void { + if (current.held === held || current.stopped) return; + current.held = held; + log.info(`[window-lease] ${held ? 'acquired' : 'released'} the remote-host role`); + current.onChange(held); +} + +async function tick(current: LeaseState): Promise { + if (current.stopped) return; + try { + const held = await runWindowLeaseCycle( + { + read: () => readRecord(current.file), + write: (record) => writeRecord(current, record), + now: () => Date.now(), + settle: () => new Promise((resolve) => setTimeout(resolve, CLAIM_VERIFY_MS)), + }, + current.selfId, + ); + setHeld(current, held); + } catch (err) { + // A lease we cannot write is a lease we cannot hold; stand down rather than + // run a Host this window may not own. + log.error(`[window-lease] cycle failed: ${String(err)}`); + setHeld(current, false); + } +} + +/** + * Start arbitrating, and report every change in this window's ownership. + * Idempotent: repeated calls re-use the running lease and re-announce its + * current state to the new listener. + */ +export function ensureWindowLease(onChange: (held: boolean) => void): void { + if (state) { + onChange(state.held); + return; + } + const context = extensionContext; + if (!context) return; + + const dir = context.globalStorageUri.fsPath; + const current: LeaseState = { + dir, + file: join(dir, LEASE_FILE), + selfId: randomUUID(), + held: false, + timer: null, + watcher: null, + onChange, + stopped: false, + }; + state = current; + + void (async () => { + // VS Code does not create globalStorageUri until something writes to it. + await mkdir(dir, { recursive: true }).catch(() => {}); + if (current.stopped) return; + await tick(current); + + current.timer = setInterval(() => void tick(current), LEASE_RENEW_MS); + try { + // The heartbeat alone would make a clean handoff take up to a TTL; the + // watcher turns "the holder released it" into a prompt takeover. Purely + // an accelerator — correctness is the timer's job. + current.watcher = watch(dir, (_event, filename) => { + if (filename && filename !== LEASE_FILE) return; + void tick(current); + }); + } catch { + // No watcher on this platform/filesystem: the interval still converges. + } + })(); + + context.subscriptions.push({ dispose: () => void disposeWindowLease() }); +} + +/** Whether this window currently owns the Host role. */ +export function holdsWindowLease(): boolean { + return state?.held ?? false; +} + +/** + * Stop arbitrating and, if this window is the owner, hand the role over + * immediately rather than making the next window wait out the TTL. + */ +export async function disposeWindowLease(): Promise { + const current = state; + if (!current) return; + state = null; + current.stopped = true; + if (current.timer) clearInterval(current.timer); + current.watcher?.close(); + + if (!current.held) return; + const record = await readRecord(current.file); + if (record?.owner !== current.selfId) return; + await unlink(current.file).catch(() => {}); +} + +/** Test seam: forget any running lease without touching the filesystem. */ +export function resetWindowLeaseForTest(): void { + state = null; + extensionContext = null; +} From 03d0474c57adb03889d6b29514e9a77f0c19daa2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 12:06:52 -0700 Subject: [PATCH 08/56] Let the remote Host reach terminals in sibling webviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone could only ever see one webview's terminals. Each webview is its own JS realm with its own xterm registry, so `collectDirectorySnapshot` listed the local registry and `surface.attach` resolved against it — meaning the bottom panel, and every editor tab, were invisible to each other. The lease made that deterministic rather than fixing it: one webview holds the Host, and its panes were the whole world. Most of what was needed already existed. `pty:input` and `pty:resize` go straight to `ptyManager`, ungated by webview ownership, so the Host could already drive a sibling's PTY; and pane ids carry a random suffix, so surface ids are unique across webviews without namespacing. The only real gap in the transport was streaming: `pty:data` reached the owning webview only. A webview may now subscribe to a PTY it does not own, tracked separately from `ownedPtyIds` so it never affects union status, `killOnDispose`, or ownership. Semantic events stay owner-only — they maintain the owner's pane state, and a subscriber wants bytes, not a second copy of that state. The extension host brokers the rest, being the only party that sees every webview: it fans a directory request out to the others and settles when they have all answered or a 1s budget expires, and it routes a surface op to whichever webview owns the id. Every webview installs a responder regardless of whether it is the Host, so its terminals are reachable from whichever one is; the responder is a registry lookup, the directory collector, and a resize, with none of the relay or enrollment machinery behind it. Attach and resize on a foreign surface go to the owner rather than to the PTY, because attach-is-the-resize has to drive the live xterm or the owning pane's view drifts from the size the phone set. The directory emits twice — local entries immediately, then merged once peers answer — so the phone never waits on a round trip to see the panes that are already here. This is the within-window tier. Reaching other windows needs a channel between extension host processes; the lease holder is the natural broker and `dor`'s control socket is the pattern, but none of that is built. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 21 ++ lib/src/lib/platform/types.ts | 48 +++++ lib/src/lib/platform/vscode-adapter.ts | 58 +++++ lib/src/main.tsx | 5 + lib/src/remote/host/peer-surfaces.test.ts | 248 ++++++++++++++++++++++ lib/src/remote/host/peer-surfaces.ts | 62 ++++++ lib/src/remote/host/remote-api.ts | 168 +++++++++++---- vscode-ext/src/message-router.ts | 144 ++++++++++++- vscode-ext/src/message-types.ts | 12 ++ 9 files changed, 727 insertions(+), 39 deletions(-) create mode 100644 lib/src/remote/host/peer-surfaces.test.ts create mode 100644 lib/src/remote/host/peer-surfaces.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 08a20632..7f5a3238 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -24,6 +24,7 @@ Extension Host (vscode-ext/src/) ├── webview-html.ts — CSP injection, nonce + message-token generation, asset URI rewriting ├── remote-host-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys ├── window-lease.ts — cross-window Host lease: heartbeat record in globalStorageUri +│ (peer-surface brokering lives in message-router.ts) ├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -278,6 +279,26 @@ Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-rout **Lifetime.** The Host lives as long as a Dormouse webview exists in the window. `retainContextWhenHidden: true` is set on both hosting modes, so hiding the panel keeps it connected; only disposing every Dormouse view, or closing the window, takes it offline. +### Peer surfaces + +The Host runs in one webview, but a window's terminals are spread across all of them: each webview is its own JS realm with its own xterm registry (`lib/src/lib/terminal-store.ts`). Left alone the phone would see one webview's panes — not the window's — because `collectDirectorySnapshot` iterates the local registry and `surface.attach` resolves against it. + +The extension host brokers, since it is the only party that can see every webview. Three things make it work, and two of them were already true: + +- **PTY input and resize are not ownership-gated.** `pty:input` and `pty:resize` go straight to `ptyManager`, so the Host webview can already drive a sibling's PTY. +- **Pane ids are unique across webviews.** They are minted `pane--` (`lib/src/components/Wall.tsx`), so surface ids need no namespacing to be routed. +- **Streaming needed one change.** `pty:data` was delivered only to the owning webview; a webview may now also `pty:subscribe` to a PTY it does not own. Subscriptions are tracked separately from `ownedPtyIds`, so they never affect Workspace union status, `killOnDispose`, or who the host considers the owner. Semantic events stay owner-only — they drive the owner's pane state, and a subscriber is streaming bytes, not keeping a second copy of that state. + +Every webview installs a responder (`lib/src/remote/host/peer-surfaces.ts`) whether or not it is the Host, so its terminals are reachable from whichever one is. It carries none of the relay, enrollment, or pairing machinery — a registry lookup, the directory collector, and a resize. + +`attach` and `resize` on a foreign surface go to the owner rather than to the PTY, because attach-is-the-resize has to drive the live xterm or the owning pane's own view drifts from the size the phone set. The owner replies with the size it settled at and the `ptyId`; the Host then subscribes and streams. `detach` has nothing to undo on the owner — the Host stops streaming and the pane keeps its size, which is what last-attach-wins means. + +The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. The broker settles a fan-out when every webview has replied or a 1s budget expires, so a webview with no live content cannot hang the picker. + +Reserved: this is the within-window tier. Reaching terminals in *other windows* needs a channel between extension hosts — the lease holder is the natural broker and `dor`'s control socket is the pattern — and is not built. + +Source of truth: the broker in `vscode-ext/src/message-router.ts` (`peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the responder in `lib/src/remote/host/peer-surfaces.ts`, and the foreign-surface path in `remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. + ### Build and development Source of truth: diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index bd6329b2..f792098f 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -112,6 +112,48 @@ export interface AgentBrowserPopResult { error?: string; } +/** What a peer webview reports back about a surface it owns. */ +export interface PeerSurfaceResult { + ok: boolean; + ptyId?: string; + cols?: number; + rows?: number; +} + +/** + * Reach terminals that belong to another webview of the same host window. + * + * The remote Host runs in exactly one webview, but a window's terminals are + * spread across all of them and each webview has its own xterm registry. The + * Host therefore cannot list or drive a sibling's pane directly; the host + * process brokers, and this is the webview end of that. See + * docs/specs/vscode.md → "Peer surfaces". + */ +export interface PeerBridge { + /** Directory entries contributed by every other webview in this window. */ + directory(): Promise; + /** Drive a surface owned by another webview; `ok: false` if nobody owns it. */ + surfaceOp( + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, + ): Promise; + /** Start/stop receiving `pty:data` for a PTY this webview does not own. */ + subscribePty(id: string): void; + unsubscribePty(id: string): void; + /** Answer the broker on behalf of this webview's own surfaces. */ + serve(handlers: { + directory: () => unknown[]; + surfaceOp: ( + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, + ) => PeerSurfaceResult; + }): void; +} + export interface PlatformAdapter { // Lifecycle init(): Promise; @@ -136,6 +178,12 @@ export interface PlatformAdapter { */ claimSingleton?(name: string, onChange: (held: boolean) => void): void; + /** + * Reach surfaces owned by sibling webviews. Optional: only a host that can + * show several webviews over one backend has peers at all. + */ + peers?: PeerBridge; + // Shell detection getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]>; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 16cf4f7b..b6de05ef 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -14,6 +14,7 @@ import { getTerminalTheme, onTerminalThemeChange } from '../terminal-theme'; import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; +import type { PeerBridge } from './types'; import { setJsonStoreBackend } from '../local-json-store'; /** @@ -23,6 +24,13 @@ import { setJsonStoreBackend } from '../local-json-store'; */ const HOST_STORE_READ_TIMEOUT_MS = 10_000; +/** + * Budget for a peer round trip. Comfortably above the broker's own + * `PEER_REPLY_BUDGET_MS`, so a slow sibling shows up as an incomplete directory + * rather than as a timeout on this side. + */ +const PEER_REQUEST_TIMEOUT_MS = 3_000; + export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; private hostState: unknown = (globalThis as typeof globalThis & { __DORMOUSE_HOST_STATE__?: unknown }).__DORMOUSE_HOST_STATE__ ?? null; @@ -174,6 +182,21 @@ export class VSCodeAdapter implements PlatformAdapter { this.singletonHandlers.get(msg.name)?.(!!msg.held); } else if (msg.type === 'store:changed') { this.applyStoreChange(msg.key, msg.value ?? null); + } else if (msg.type === 'peer:directoryRequest') { + // Answer even with no handler installed: the broker waits for every + // webview, so silence would stall the asker until its budget expires. + this.vscode.postMessage({ + type: 'peer:directoryEntries', + requestId: msg.requestId, + entries: this.peerHandlers?.directory() ?? [], + }); + } else if (msg.type === 'peer:surfaceRequest') { + // Only the owner answers; a miss stays silent so it cannot beat the + // real owner's reply to the broker. + const result = this.peerHandlers?.surfaceOp(msg.surfaceId, msg.op, msg.cols, msg.rows); + if (result?.ok) { + this.vscode.postMessage({ type: 'peer:surfaceResult', requestId: msg.requestId, ...result }); + } } }); } @@ -220,6 +243,41 @@ export class VSCodeAdapter implements PlatformAdapter { * the role when the holder is disposed, so closing the Dormouse view hands * the Host to another open one rather than dropping it until reload. */ + /** + * Reach terminals owned by sibling webviews, brokered by the extension host + * (docs/specs/vscode.md → "Peer surfaces"). Present unconditionally: every + * webview both asks (when it is the Host) and answers (for its own panes). + */ + readonly peers: PeerBridge = { + directory: async () => { + const entries = await this.requestResponse( + 'peer:directory', + 'peer:directoryResult', + {}, + (msg) => msg.entries as unknown[], + PEER_REQUEST_TIMEOUT_MS, + ); + return entries ?? []; + }, + surfaceOp: async (surfaceId, op, cols, rows) => { + const result = await this.requestResponse( + 'peer:surfaceOp', + 'peer:surfaceOpResult', + { surfaceId, op, cols, rows }, + (msg) => ({ ok: !!msg.ok, ptyId: msg.ptyId, cols: msg.cols, rows: msg.rows }), + PEER_REQUEST_TIMEOUT_MS, + ); + return result ?? { ok: false }; + }, + subscribePty: (id) => this.vscode.postMessage({ type: 'pty:subscribe', id }), + unsubscribePty: (id) => this.vscode.postMessage({ type: 'pty:unsubscribe', id }), + serve: (handlers) => { + this.peerHandlers = handlers; + }, + }; + + private peerHandlers: Parameters[0] | null = null; + claimSingleton(name: string, onChange: (held: boolean) => void): void { // One entry per role, dispatched from the constructor's authenticated // listener: re-claiming (a React effect remounting, StrictMode's double diff --git a/lib/src/main.tsx b/lib/src/main.tsx index 854843b5..09e36f7b 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -5,6 +5,7 @@ import { resumeOrRestore } from "./lib/reconnect"; import { initAlertStateReceiver } from "./lib/terminal-registry"; import { installVscodeThemeVarResolver } from "./lib/themes/vscode-color-observer"; import { REMOTE_HOST_STORE_PREFIX, setHostStoreReady } from "./remote/host/store"; +import { installPeerSurfaceResponder } from "./remote/host/peer-surfaces"; import App from "./App"; import "./index.css"; @@ -17,6 +18,10 @@ const isVscode = typeof acquireVsCodeApi === "function"; if (isVscode) { installVscodeThemeVarResolver(); + // Every webview answers for its own terminals, whether or not it is the one + // holding the Host — 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 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..24e1e4a2 --- /dev/null +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -0,0 +1,248 @@ +/** + * Attaching to a terminal owned by a *sibling* webview. Only one webview in a + * VS Code window is the remote Host, but the window's terminals are spread + * across all of them, so the Host has to reach the others through the peer + * bridge (docs/specs/vscode.md → "Peer surfaces"). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + REMOTE_EVENTS, + REMOTE_METHODS, + fromBase64Url, + utf8Decode, + type RemoteEventMsg, + type RemoteResponse, +} from 'server-lib-common'; +import { FakePtyAdapter, setPlatform, type PlatformAdapter } from '../../lib/platform'; +import { registry, type TerminalEntry } from '../../lib/terminal-store'; +import { RemoteApiSession } from './remote-api'; + +type SentPayload = RemoteResponse | RemoteEventMsg; +type DataHandler = (detail: { id: string; data: string }) => void; +type ExitHandler = (detail: { id: string; exitCode: number }) => void; + +/** A platform whose peer bridge stands in for the other webviews. */ +class PeerPlatform { + readonly dataHandlers = new Set(); + readonly exitHandlers = new Set(); + readonly resizePty = vi.fn(); + readonly writePty = vi.fn(); + readonly subscribed: string[] = []; + readonly unsubscribed: string[] = []; + readonly ops: Array<{ surfaceId: string; op: string; cols?: number; rows?: number }> = []; + + /** Surfaces the imaginary sibling webview owns. */ + peerSurfaces = new Map(); + peerEntries: unknown[] = []; + + readonly peers = { + directory: async () => this.peerEntries, + surfaceOp: async (surfaceId: string, op: 'attach' | 'detach' | 'resize', cols?: number, rows?: number) => { + this.ops.push({ surfaceId, op, cols, rows }); + const surface = this.peerSurfaces.get(surfaceId); + if (!surface) return { ok: false }; + if (op !== 'detach' && cols && rows) { + surface.cols = cols; + surface.rows = rows; + } + return { ok: true, ptyId: surface.ptyId, cols: surface.cols, rows: surface.rows }; + }, + subscribePty: (id: string) => void this.subscribed.push(id), + unsubscribePty: (id: string) => void this.unsubscribed.push(id), + serve: () => {}, + }; + + onPtyData(handler: DataHandler): void { + this.dataHandlers.add(handler); + } + offPtyData(handler: DataHandler): void { + this.dataHandlers.delete(handler); + } + onPtyExit(handler: ExitHandler): void { + this.exitHandlers.add(handler); + } + offPtyExit(handler: ExitHandler): void { + this.exitHandlers.delete(handler); + } + emitData(id: string, data: string): void { + for (const handler of this.dataHandlers) handler({ id, data }); + } + asAdapter(): PlatformAdapter { + return this as unknown as PlatformAdapter; + } +} + +function decodeTerminalData(payload: SentPayload): string { + const event = payload as RemoteEventMsg; + return utf8Decode(fromBase64Url((event.data as { bytes: string }).bytes)); +} + +/** Let the peer round trips (they are promises) settle. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('remote-api peer surfaces', () => { + afterEach(() => { + registry.clear(); + setPlatform(new FakePtyAdapter()); + }); + + function session(platform: PeerPlatform) { + const sent: SentPayload[] = []; + setPlatform(platform.asAdapter()); + return { + sent, + api: new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }), + }; + } + + it('attaches to a surface owned by another webview', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + const { api, sent } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 100, rows: 30 }, + }); + await settle(); + + // The owner did the resize — attach-is-the-resize has to go through the + // live xterm, which this webview cannot touch. + expect(platform.ops).toEqual([{ surfaceId: 'surface-far', op: 'attach', cols: 100, rows: 30 }]); + const ok = sent.find((p) => (p as RemoteResponse).requestId === 'attach-1') as RemoteResponse; + expect(ok.result).toEqual({ cols: 100, rows: 30 }); + }); + + it('subscribes to the foreign PTY and streams its bytes', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + const { api, sent } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + }); + await settle(); + + // The host only forwards pty:data for PTYs a webview owns or subscribed to. + expect(platform.subscribed).toEqual(['pty-far']); + + platform.emitData('pty-far', 'hello from the other webview'); + const data = sent.filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalData); + expect(data.map(decodeTerminalData)).toContain('hello from the other webview'); + }); + + it('ignores bytes from PTYs it is not attached to', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + const { api, sent } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + }); + await settle(); + platform.emitData('pty-other', 'not mine'); + + const data = sent.filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalData); + expect(data.map(decodeTerminalData)).not.toContain('not mine'); + }); + + it('routes a later resize back to the owning webview', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + const { api, sent } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + }); + await settle(); + api.handle({ + requestId: 'resize-1', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-far', cols: 120, rows: 40 }, + }); + await settle(); + + expect(platform.ops.at(-1)).toEqual({ surfaceId: 'surface-far', op: 'resize', cols: 120, rows: 40 }); + const ok = sent.find((p) => (p as RemoteResponse).requestId === 'resize-1') as RemoteResponse; + expect(ok.result).toEqual({ cols: 120, rows: 40 }); + }); + + it('stops the foreign stream when the attachment is replaced', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + platform.peerSurfaces.set('surface-far2', { ptyId: 'pty-far2', cols: 80, rows: 24 }); + const { api } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + }); + await settle(); + api.handle({ + requestId: 'attach-2', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far2', cols: 80, rows: 24 }, + }); + await settle(); + + // Otherwise the host keeps forwarding a PTY nobody is reading. + expect(platform.unsubscribed).toEqual(['pty-far']); + }); + + it('fails cleanly when no webview owns the surface', async () => { + const platform = new PeerPlatform(); + const { api, sent } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'nobody', cols: 80, rows: 24 }, + }); + await settle(); + + const reply = sent.find((p) => (p as RemoteResponse).requestId === 'attach-1') as RemoteResponse; + expect(reply.error).toMatch(/no such surface/); + }); + + it('prefers a local surface without asking any peer', async () => { + const platform = new PeerPlatform(); + const terminal = { cols: 80, rows: 24, resize: vi.fn() }; + registry.set('surface-near', { ptyId: 'pty-near', terminal } as unknown as TerminalEntry); + const { api } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-near', cols: 80, rows: 24 }, + }); + await settle(); + + expect(platform.ops).toEqual([]); + expect(platform.subscribed).toEqual([]); + }); + + it('emits local entries first, then a merged snapshot including peers', async () => { + const platform = new PeerPlatform(); + platform.peerEntries = [{ surfaceId: 'surface-far', title: 'other webview' }]; + const { api, sent } = session(platform); + + api.handle({ requestId: 'dir-1', method: REMOTE_METHODS.directoryWatch, params: {} }); + await settle(); + + const snapshots = sent + .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.directorySnapshot) + .map((p) => ((p as RemoteEventMsg).data as { entries: unknown[] }).entries); + // The phone should not wait on a round trip to see this window's own panes. + expect(snapshots.length).toBe(2); + expect(snapshots[1]).toEqual([{ surfaceId: 'surface-far', title: 'other webview' }]); + }); +}); diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts new file mode 100644 index 00000000..dcb337e8 --- /dev/null +++ b/lib/src/remote/host/peer-surfaces.ts @@ -0,0 +1,62 @@ +/** + * The responder half of peer surfaces (docs/specs/vscode.md → "Peer surfaces"). + * + * The remote Host runs in one webview, but a window's terminals are spread + * across all of them and each webview has its own xterm registry. So *every* + * webview installs this, not just the Host's: it answers the broker's questions + * about the panes this webview owns, and drives them when the Host asks. + * + * Deliberately light — the registry, the directory collector, and a resize. It + * carries none of the relay, enrollment, or pairing machinery, so a webview + * that will never be the Host pays almost nothing to make its terminals + * reachable from one that is. + */ + +import { clampTerminalDimension } from 'server-lib-common'; +import { getPlatform } from '../../lib/platform'; +import type { PeerSurfaceResult } from '../../lib/platform/types'; +import { registry } from '../../lib/terminal-store'; +import { collectDirectorySnapshot } from './directory-collect'; + +/** + * Drive one of this webview's own surfaces on the Host's behalf. + * + * `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. `detach` has nothing to undo here: the Host stops + * streaming on its side, and the pane keeps whatever size it was left at, which + * is what last-attach-wins means. + */ +function surfaceOp( + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, +): PeerSurfaceResult { + const entry = registry.get(surfaceId); + if (!entry) return { ok: false }; + + const term = entry.terminal; + if (op === 'detach') { + return { ok: true, ptyId: entry.ptyId, cols: term.cols, rows: term.rows }; + } + + const nextCols = clampTerminalDimension(cols, term.cols); + const nextRows = clampTerminalDimension(rows, term.rows); + if (term.cols !== nextCols || term.rows !== nextRows) { + term.resize(nextCols, nextRows); + } + return { ok: true, ptyId: entry.ptyId, cols: term.cols, rows: term.rows }; +} + +/** + * Make this webview's terminals reachable from whichever webview is the Host. + * Idempotent, and a no-op on hosts with no peers (standalone, the website). + */ +export function installPeerSurfaceResponder(): void { + getPlatform().peers?.serve({ + directory: () => collectDirectorySnapshot(), + surfaceOp, + }); +} diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 327327fb..dcb597f4 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -49,10 +49,26 @@ const DIRECTORY_DEBOUNCE_MS = 150; */ const FORCE_REPAINT_BOUNCE_MS = 60; +/** + * Where an attached surface lives. A window's terminals are spread across its + * webviews and only one of them is the Host, so an attachment is either to a + * pane in this webview's registry or to one a sibling owns, driven through the + * peer bridge (docs/specs/vscode.md → "Peer surfaces"). + */ +type SurfaceTarget = + | { kind: 'local'; entry: TerminalEntry } + | { kind: 'peer'; surfaceId: string; cols: number; rows: number }; + +function targetSize(target: SurfaceTarget): { cols: number; rows: number } { + return target.kind === 'local' + ? { cols: target.entry.terminal.cols, rows: target.entry.terminal.rows } + : { cols: target.cols, rows: target.rows }; +} + interface Attachment { surfaceId: string; ptyId: string; - entry: TerminalEntry; + target: SurfaceTarget; subId: string; onData: (detail: { id: string; data: string }) => void; onExit: (detail: { id: string; exitCode: number }) => void; @@ -132,23 +148,6 @@ 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}`); @@ -214,22 +213,80 @@ export class RemoteApiSession { #emitDirectory(): void { if (this.#directorySubId === null) return; - this.#event(this.#directorySubId, REMOTE_EVENTS.directorySnapshot, { - entries: collectDirectorySnapshot(), + const subId = this.#directorySubId; + const local = collectDirectorySnapshot(); + const peers = getPlatform().peers; + if (!peers) { + this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: local }); + return; + } + // A window's terminals are spread across its webviews, and only this one is + // the Host — the rest have to be asked (docs/specs/vscode.md → "Peer + // surfaces"). Emit twice rather than delaying the local panes behind a + // round trip: the phone renders what is here immediately, then fills in. + this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: local }); + void peers.directory().then((remote) => { + // The subscription may have been replaced or torn down while we waited. + if (this.#directorySubId !== subId || remote.length === 0) return; + this.#event(subId, REMOTE_EVENTS.directorySnapshot, { + entries: [...collectDirectorySnapshot(), ...remote], + }); }); } #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; + } + + const entry = registry.get(params.surfaceId); + if (entry) { + this.#beginAttach(request, params, { kind: 'local', entry }, entry.ptyId); + return; + } + + // Not ours: ask the other webviews of this window. The owner resizes its + // own xterm — attach-is-the-resize has to go through the live terminal, not + // the PTY, or the owning pane's view drifts from the size the phone set. + const peers = getPlatform().peers; + if (!peers) { + this.#fail(request, `no such surface: ${params.surfaceId}`); + return; + } + void peers.surfaceOp(params.surfaceId, 'attach', params.cols, params.rows).then((result) => { + if (!result.ok || !result.ptyId) { + this.#fail(request, `no such surface: ${params.surfaceId}`); + return; + } + peers.subscribePty(result.ptyId); + this.#beginAttach( + request, + params, + { + kind: 'peer', + surfaceId: params.surfaceId, + cols: result.cols ?? 0, + rows: result.rows ?? 0, + }, + result.ptyId, + ); + }); + } + + #beginAttach( + request: RemoteRequest, + params: AttachParams, + target: SurfaceTarget, + ptyId: string, + ): 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 current = targetSize(target); + const cols = clampTerminalDimension(params.cols, current.cols); + const rows = clampTerminalDimension(params.rows, current.rows); const platform = getPlatform(); const subId = request.requestId; const pendingEvents: Array<{ event: string; data: unknown }> = []; @@ -266,7 +323,7 @@ export class RemoteApiSession { const attachment: Attachment = { surfaceId: params.surfaceId, ptyId, - entry, + target, subId, onData, onExit, @@ -278,8 +335,12 @@ export class RemoteApiSession { // 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); + // A peer owner already applied the size in its own xterm before replying, + // so only the local path still has a resize to perform here. + const sized = targetSize(target); + if (sized.cols !== cols || sized.rows !== rows) { + if (target.kind === 'local') target.entry.terminal.resize(cols, rows); + else void this.#resizePeer(target, cols, rows); } else { // Same size: force one repaint with a quick rows bounce on the PTY only, // leaving the already-correct local xterm buffer untouched. Bounce away @@ -300,7 +361,8 @@ export class RemoteApiSession { }, FORCE_REPAINT_BOUNCE_MS); } - const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; + const settled = targetSize(target); + const result: TerminalAttachResult = { cols: settled.cols, rows: settled.rows }; this.#ok(request, result); streaming = true; for (const event of pendingEvents) { @@ -332,13 +394,39 @@ 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 target = attachment.target; + const current = targetSize(target); + const cols = clampTerminalDimension(params.cols, current.cols); + const rows = clampTerminalDimension(params.rows, current.rows); + + if (target.kind === 'local') { + const term = target.entry.terminal; + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); + this.#ok(request, { cols: term.cols, rows: term.rows } satisfies TerminalAttachResult); + return; + } + + void this.#resizePeer(target, cols, rows).then((size) => { + this.#ok(request, { cols: size.cols, rows: size.rows } satisfies TerminalAttachResult); + }); + } + + /** + * Resize a sibling-owned surface and record the size it settled at, so + * `targetSize` keeps answering for the pane we cannot read directly. + */ + async #resizePeer( + target: Extract, + cols: number, + rows: number, + ): Promise<{ cols: number; rows: number }> { + const peers = getPlatform().peers; + const result = await peers?.surfaceOp(target.surfaceId, 'resize', cols, rows); + if (result?.ok) { + target.cols = result.cols ?? cols; + target.rows = result.rows ?? rows; + } + return { cols: target.cols, rows: target.rows }; } #teardownAttachment(): void { @@ -350,6 +438,10 @@ export class RemoteApiSession { const platform = getPlatform(); platform.offPtyData(this.#attachment.onData); platform.offPtyExit(this.#attachment.onExit); + // Stop the host forwarding a PTY this webview never owned. + if (this.#attachment.target.kind === 'peer') { + platform.peers?.unsubscribePty(this.#attachment.ptyId); + } this.#attachment = null; } } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 3a9fcfac..d4f69c86 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -111,8 +111,43 @@ interface ActiveRouter { ownsPty(id: string): boolean; forwardDorControlRequest(request: DorControlRequest): void; notifyStoreChanged(key: string, value: string | null): void; + askDirectory(requestId: string): void; + askSurface( + requestId: string, + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, + ): void; } +/** + * Ask every *other* webview in this window to answer a peer request, and settle + * once they all have (or the budget runs out). + * + * The remote Host runs in one webview, but a window's terminals are spread + * across all of them — each webview has its own xterm registry, so the Host can + * neither list nor attach to a sibling's pane without asking. The extension + * host is the only party that can ask, so it brokers. See docs/specs/vscode.md + * → "Peer surfaces". + */ +const PEER_REPLY_BUDGET_MS = 1_000; + +interface PeerRequest { + /** Replies still outstanding; the request settles when it empties. */ + pending: Set; + entries: unknown[]; + settle: () => void; + timer: ReturnType; +} +const peerDirectoryRequests = new Map(); + +interface PeerSurfaceRequest { + settle: (result: { ok: boolean; ptyId?: string; cols?: number; rows?: number }) => void; + timer: ReturnType; +} +const peerSurfaceRequests = new Map(); + /** * Tell every webview about a committed Host-store write. * @@ -260,6 +295,13 @@ export function attachRouter( // Track which PTY IDs were spawned (or reconnected) through this webview const ownedPtyIds = new Set(); + /** + * PTYs this webview asked to watch without owning them — the remote Host + * streaming a sibling webview's terminal. Kept separate from `ownedPtyIds` so + * it never affects Workspace union status, `killOnDispose`, or which webview + * the host considers the owner. + */ + const subscribedPtyIds = new Set(); // This webview's stake in the window-wide single-instance roles. const claimant: SingletonClaimant = { @@ -380,10 +422,12 @@ export function attachRouter( */ function connectWebview(): () => void { const removeProcessedListener = onProcessedPtyData((id, visibleData) => { - if (!ownedPtyIds.has(id)) return; + if (!ownedPtyIds.has(id) && !subscribedPtyIds.has(id)) return; post({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); }); const removeSemanticListener = onTerminalSemanticEvents((id, events) => { + // Semantic events drive the *owner's* pane state; a subscriber is + // streaming bytes, not maintaining a second copy of that state. if (!ownedPtyIds.has(id)) return; post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); }); @@ -590,6 +634,82 @@ export function attachRouter( } satisfies ExtensionMessage), ); break; + case 'pty:subscribe': + if (typeof msg.id === 'string') subscribedPtyIds.add(msg.id); + break; + case 'pty:unsubscribe': + if (typeof msg.id === 'string') subscribedPtyIds.delete(msg.id); + break; + case 'peer:directory': { + // Fan out to the other webviews in this window and answer once they + // have all replied, or the budget expires — a webview with no live + // content never replies, and must not hang the phone's picker. + const requestId = msg.requestId; + const peers = [...activeRouters].filter((other) => other !== router); + if (peers.length === 0) { + void post({ type: 'peer:directoryResult', requestId, entries: [] } satisfies ExtensionMessage); + break; + } + const settle = () => { + const request = peerDirectoryRequests.get(requestId); + if (!request) return; + peerDirectoryRequests.delete(requestId); + clearTimeout(request.timer); + void post({ + type: 'peer:directoryResult', requestId, entries: request.entries, + } satisfies ExtensionMessage); + }; + peerDirectoryRequests.set(requestId, { + pending: new Set(peers), + entries: [], + settle, + timer: setTimeout(settle, PEER_REPLY_BUDGET_MS), + }); + for (const peer of peers) peer.askDirectory(requestId); + break; + } + case 'peer:directoryEntries': { + const request = peerDirectoryRequests.get(msg.requestId); + if (!request) break; + if (Array.isArray(msg.entries)) request.entries.push(...msg.entries); + request.pending.delete(router); + if (request.pending.size === 0) request.settle(); + break; + } + case 'peer:surfaceOp': { + // Broadcast rather than track surfaceId ownership: only the owning + // webview can answer, the others stay silent, and a window holds a + // handful of webviews. + const requestId = msg.requestId; + const peers = [...activeRouters].filter((other) => other !== router); + const settle = (result: { ok: boolean; ptyId?: string; cols?: number; rows?: number }) => { + const request = peerSurfaceRequests.get(requestId); + if (!request) return; + peerSurfaceRequests.delete(requestId); + clearTimeout(request.timer); + void post({ type: 'peer:surfaceOpResult', requestId, ...result } satisfies ExtensionMessage); + }; + if (peers.length === 0) { + settle({ ok: false }); + break; + } + peerSurfaceRequests.set(requestId, { + settle, + timer: setTimeout(() => settle({ ok: false }), PEER_REPLY_BUDGET_MS), + }); + for (const peer of peers) { + peer.askSurface(requestId, msg.surfaceId, msg.op, msg.cols, msg.rows); + } + break; + } + case 'peer:surfaceResult': { + // Only the owner replies `ok`; a miss from a non-owner is not an answer. + if (!msg.ok) break; + peerSurfaceRequests.get(msg.requestId)?.settle({ + ok: true, ptyId: msg.ptyId, cols: msg.cols, rows: msg.rows, + }); + break; + } case 'singleton:claim': // `WebviewMessage` is a claim about the sender, not a runtime check. if (typeof msg.name !== 'string') break; @@ -789,10 +909,32 @@ export function attachRouter( if (disposed) return; void post({ type: 'store:changed', key, value } satisfies ExtensionMessage); }, + askDirectory(requestId: string) { + if (disposed) return; + void post({ type: 'peer:directoryRequest', requestId } satisfies ExtensionMessage); + }, + askSurface( + requestId: string, + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, + ) { + if (disposed) return; + void post({ + type: 'peer:surfaceRequest', requestId, surfaceId, op, cols, rows, + } satisfies ExtensionMessage); + }, dispose() { if (disposed) return; disposed = true; activeRouters.delete(router); + // A webview that goes away mid-fan-out must not hold the answer open. + for (const request of peerDirectoryRequests.values()) { + if (!request.pending.delete(router)) continue; + if (request.pending.size === 0) request.settle(); + } + subscribedPtyIds.clear(); releaseSingletons(claimant); removeWatchedCommandListener(); removeAlertSettingsListener(); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 03199a27..0e01ffd1 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -29,6 +29,14 @@ export type WebviewMessage = | { type: 'agentBrowser:popIn'; session: string; url?: string; binaryPath?: string; requestId: string } | { type: 'iframe:createProxyUrl'; url: string; requestId: string } | { type: 'singleton:claim'; name: string } + // Peer surfaces: one webview is the remote Host, but the terminals live in + // whichever webview opened them. See docs/specs/vscode.md → "Peer surfaces". + | { type: 'pty:subscribe'; id: string } + | { type: 'pty:unsubscribe'; id: string } + | { type: 'peer:directory'; requestId: string } + | { type: 'peer:directoryEntries'; requestId: string; entries: unknown[] } + | { type: 'peer:surfaceOp'; requestId: string; surfaceId: string; op: 'attach' | 'detach' | 'resize'; cols?: number; rows?: number } + | { type: 'peer:surfaceResult'; requestId: string; ok: boolean; ptyId?: string; cols?: number; rows?: number } | { type: 'store:read'; prefix: string; requestId: string } | { type: 'store:write'; key: string; value: string | null } | { type: 'dormouse:init' } @@ -79,6 +87,10 @@ export type ExtensionMessage = | { type: 'store:entries'; requestId: string; entries: Record } | { type: 'singleton:lease'; name: string; held: boolean } | { type: 'store:changed'; key: string; value: string | null } + | { type: 'peer:directoryRequest'; requestId: string } + | { type: 'peer:directoryResult'; requestId: string; entries: unknown[] } + | { type: 'peer:surfaceRequest'; requestId: string; surfaceId: string; op: 'attach' | 'detach' | 'resize'; cols?: number; rows?: number } + | { type: 'peer:surfaceOpResult'; requestId: string; ok: boolean; ptyId?: string; cols?: number; rows?: number } | { type: 'dormouse:newTerminal'; shell?: string; From 65f7678504dfdbc82aa9a3b486267f6c0bc61a82 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 12:13:11 -0700 Subject: [PATCH 09/56] Reach terminals in other VS Code windows over a broker socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 gave the phone every terminal in one window. This gives it the machine, which is the case that actually matters — several windows open at once is the normal way to work, and until now the other ones were invisible. There is no shared process to broker through: VS Code runs one extension host per window. So the window holding the Host lease listens on a local socket and the others connect to it. The lease makes that one-directional — the webview lease is gated on the window lease, so the broker window is always the Host window, and a peer window only ever answers. Roles follow the lease. Acquire it and the window serves, publishing a mode-0600 rendezvous file naming the socket path and a token; lose it and the window tears the server down and connects as a client. Clients watch that file so a handover does not wait out the reconnect backoff. Sockets live in the temp dir because macOS caps a unix socket path near 104 bytes and the extension's globalStorage path is most of that by itself. A peer answers a directory or surface request by running its own *in-window* fan-out, never the cross-window one, or a request would loop back out. That is also why the link is injected with what it needs instead of importing the router that imports it. Once an attach succeeds the broker records which window owns that PTY: an id says nothing about where it lives, and input and resizes have to reach that window. Input and resize consult the table and fall back to the local manager; a subscribe asks the owning window to stream, and those bytes are injected into the subscriber's ordinary pty:data path, so the Host webview cannot tell a remote terminal from a local one. When a peer disconnects every PTY behind it is dropped and reported exited — a terminal in a closed window is gone, and a later write must not go into a dead socket. The framing and the routing table are pure and live in lib, so a split frame, a malformed frame, a peer that never terminates one, and a peer vanishing mid-attach are covered by tests rather than by reasoning. The sockets themselves are not — vscode-ext has no test runner. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 17 +- lib/src/lib/vscode-peer-link-protocol.test.ts | 95 ++++ lib/src/lib/vscode-peer-link-protocol.ts | 139 ++++++ vscode-ext/src/extension.ts | 3 + vscode-ext/src/message-router.ts | 210 ++++++--- vscode-ext/src/peer-link.ts | 443 ++++++++++++++++++ 6 files changed, 849 insertions(+), 58 deletions(-) create mode 100644 lib/src/lib/vscode-peer-link-protocol.test.ts create mode 100644 lib/src/lib/vscode-peer-link-protocol.ts create mode 100644 vscode-ext/src/peer-link.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 7f5a3238..86a46b08 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -24,6 +24,7 @@ Extension Host (vscode-ext/src/) ├── webview-html.ts — CSP injection, nonce + message-token generation, asset URI rewriting ├── remote-host-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys ├── window-lease.ts — cross-window Host lease: heartbeat record in globalStorageUri +├── peer-link.ts — socket between windows: broker serves, other windows report in │ (peer-surface brokering lives in message-router.ts) ├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel @@ -295,7 +296,21 @@ Every webview installs a responder (`lib/src/remote/host/peer-surfaces.ts`) whet The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. The broker settles a fan-out when every webview has replied or a 1s budget expires, so a webview with no live content cannot hang the picker. -Reserved: this is the within-window tier. Reaching terminals in *other windows* needs a channel between extension hosts — the lease holder is the natural broker and `dor`'s control socket is the pattern — and is not built. +### 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 window holding the Host lease therefore listens on a local socket and every other window connects to it. + +The lease makes this one-directional. Because the webview lease is gated on the window lease, the broker window *is* the Host window — so the broker never has to relay a request back out to a remote Host, and a peer window only ever answers. + +Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. + +A peer window answers a `directory` or `surfaceOp` frame by running its **own in-window** fan-out — never the cross-window one, or a request would loop back out. That is why `configurePeerLink` is handed only `brokerDirectory` / `brokerSurfaceOp`, and why the link is injected with what it needs rather than importing the router (which imports the link). + +Once an attach succeeds the broker records which window owns that `ptyId`, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `pty:input` and `pty:resize` consult that table first and fall back to the local `ptyManager`; `pty:subscribe` asks the owning window to start streaming, and its bytes are injected into the subscriber's normal `pty:data` path, so the Host webview cannot tell a remote terminal from a local one. When a peer disconnects, every PTY routed to it is dropped and reported as exited — a terminal in a closed window is gone, and a later write must not be posted into a dead socket. + +Trust: the socket is user-owned, its path is published only in a mode-0600 file, and a client's first frame must carry the token from that file — the same bar as the `dor` control socket. + +Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and roles, `lib/src/lib/vscode-peer-link-protocol.ts` for the frames, framing, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`), and the `remote*` calls in `vscode-ext/src/message-router.ts`. Source of truth: the broker in `vscode-ext/src/message-router.ts` (`peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the responder in `lib/src/remote/host/peer-surfaces.ts`, and the foreign-surface path in `remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. diff --git a/lib/src/lib/vscode-peer-link-protocol.test.ts b/lib/src/lib/vscode-peer-link-protocol.test.ts new file mode 100644 index 00000000..a2ebd413 --- /dev/null +++ b/lib/src/lib/vscode-peer-link-protocol.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + FrameDecoder, + PeerRouteTable, + encodeFrame, +} from './vscode-peer-link-protocol'; + +describe('FrameDecoder', () => { + it('reads one frame per line', () => { + const decoder = new FrameDecoder(); + const frames = decoder.push( + encodeFrame({ kind: 'directory', id: 'a' }) + encodeFrame({ kind: 'ack', id: 'b' }), + ); + expect(frames).toEqual([ + { kind: 'directory', id: 'a' }, + { kind: 'ack', id: 'b' }, + ]); + }); + + 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: 'ack', id: 'a' }); + expect(decoder.push(`${whole}{"kind":"ack","id":`)).toEqual([{ kind: 'ack', id: 'a' }]); + expect(decoder.push('"b"}\n')).toEqual([{ kind: 'ack', id: 'b' }]); + }); + + it('skips a malformed frame without dropping the ones around it', () => { + const decoder = new FrameDecoder(); + const frames = decoder.push(`{not json}\n${encodeFrame({ kind: 'ack', id: 'a' })}`); + expect(frames).toEqual([{ kind: 'ack', id: 'a' }]); + }); + + it('ignores blank lines', () => { + const decoder = new FrameDecoder(); + expect(decoder.push('\n\n')).toEqual([]); + }); + + it('drops a peer that never terminates a frame', () => { + const decoder = new FrameDecoder(64); + expect(decoder.push('x'.repeat(100))).toEqual([]); + // The buffer was reset, so a well-formed frame still gets through after. + expect(decoder.push(encodeFrame({ kind: 'ack', id: 'a' }))).toEqual([{ kind: 'ack', id: 'a' }]); + }); +}); + +describe('PeerRouteTable', () => { + it('routes a pty to the peer that claimed it', () => { + const table = new PeerRouteTable(); + table.claim('pty-1', 'window-a'); + table.claim('pty-2', 'window-b'); + + expect(table.peerFor('pty-1')).toBe('window-a'); + expect(table.peerFor('pty-2')).toBe('window-b'); + expect(table.peerFor('pty-3')).toBeUndefined(); + }); + + it('releases a single pty', () => { + const table = new PeerRouteTable(); + table.claim('pty-1', 'window-a'); + table.release('pty-1'); + expect(table.peerFor('pty-1')).toBeUndefined(); + }); + + it('forgets every pty behind a peer that disconnected', () => { + const table = new PeerRouteTable(); + table.claim('pty-1', 'window-a'); + table.claim('pty-2', 'window-a'); + table.claim('pty-3', 'window-b'); + + // Otherwise a later write would be routed into a dead socket. + expect(table.forgetPeer('window-a').sort()).toEqual(['pty-1', 'pty-2']); + expect(table.peerFor('pty-1')).toBeUndefined(); + expect(table.peerFor('pty-3')).toBe('window-b'); + expect(table.size).toBe(1); + }); + + it('re-claiming moves a pty to the newer peer', () => { + const table = new PeerRouteTable(); + table.claim('pty-1', 'window-a'); + table.claim('pty-1', 'window-b'); + expect(table.peerFor('pty-1')).toBe('window-b'); + expect(table.forgetPeer('window-a')).toEqual([]); + }); +}); diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts new file mode 100644 index 00000000..ed48f035 --- /dev/null +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -0,0 +1,139 @@ +/** + * 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, and the table + * that remembers which window a streaming PTY came from. + * + * Kept pure so the protocol's edge cases (a split frame, a peer that vanishes + * mid-attach) are testable without spawning processes. + */ + +/** Broker → peer window. */ +export type PeerLinkRequest = + | { kind: 'directory'; id: string } + | { + kind: 'surfaceOp'; + id: string; + surfaceId: string; + op: 'attach' | 'detach' | 'resize'; + cols?: number; + rows?: number; + } + | { kind: 'subscribe'; id: string; ptyId: string } + | { kind: 'unsubscribe'; id: string; ptyId: string } + | { kind: 'write'; id: string; ptyId: string; data: string } + | { kind: 'resizePty'; id: string; ptyId: string; cols: number; rows: number }; + +/** Peer window → broker. */ +export type PeerLinkResponse = + | { kind: 'directoryResult'; id: string; entries: unknown[] } + | { + kind: 'surfaceResult'; + id: string; + ok: boolean; + ptyId?: string; + cols?: number; + rows?: number; + } + | { kind: 'ack'; id: 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 }; + +export type PeerLinkFrame = PeerLinkRequest | PeerLinkResponse; + +/** The first frame a client sends; the server drops the socket if it mismatches. */ +export interface PeerLinkHello { + kind: 'hello'; + token: string; +} + +export function encodeFrame(frame: PeerLinkFrame | PeerLinkHello): 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 = ''; + 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; + if (this.#buffer.length > this.#maxFrameBytes) { + // A peer that will not terminate a frame is not one we can talk to. + this.#buffer = ''; + return []; + } + const frames: unknown[] = []; + let newline = this.#buffer.indexOf('\n'); + while (newline !== -1) { + const line = this.#buffer.slice(0, newline); + this.#buffer = this.#buffer.slice(newline + 1); + if (line.trim()) { + try { + frames.push(JSON.parse(line)); + } catch { + // Malformed frame: skip it, keep the link. + } + } + newline = this.#buffer.indexOf('\n'); + } + return frames; + } +} + +/** + * Which peer a streaming PTY belongs to. + * + * The broker learns this when an attach succeeds, and needs it afterwards to + * send input and resizes to the right window — a `ptyId` alone says nothing + * about where it lives. Entries are dropped when the peer disconnects so a + * later attach cannot be routed into a dead socket. + */ +export class PeerRouteTable { + readonly #byPty = new Map(); + + claim(ptyId: string, peer: T): void { + this.#byPty.set(ptyId, peer); + } + + release(ptyId: string): void { + this.#byPty.delete(ptyId); + } + + peerFor(ptyId: string): T | undefined { + return this.#byPty.get(ptyId); + } + + /** Forget everything routed to `peer`, and report what was dropped. */ + forgetPeer(peer: T): string[] { + const dropped: string[] = []; + for (const [ptyId, owner] of this.#byPty) { + if (owner !== peer) continue; + dropped.push(ptyId); + this.#byPty.delete(ptyId); + } + return dropped; + } + + get size(): number { + return this.#byPty.size; + } +} diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 561b3ee0..1621aa84 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -13,6 +13,7 @@ import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from import type { ExtensionMessage } from './message-types'; import { initRemoteHostStore } from './remote-host-store'; import { initWindowLease } from './window-lease'; +import { disposePeerLink, initPeerLink } from './peer-link'; type NewTerminalMessage = Extract; @@ -82,6 +83,8 @@ export function activate(context: vscode.ExtensionContext) { // Storage location only — the lease itself does not start until a webview // claims the Host role (window-lease.ts). initWindowLease(context); + initPeerLink(context); + context.subscriptions.push({ dispose: () => void disposePeerLink() }); 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 d4f69c86..c38c3464 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -23,6 +23,17 @@ import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runA import { createIframeProxyUrl } from './iframe-proxy-host'; import { readStore, writeStore } from './remote-host-store'; import { ensureWindowLease } from './window-lease'; +import { + configurePeerLink, + isRemotePty, + remoteDirectory, + remoteResize, + remoteSubscribe, + remoteSurfaceOp, + remoteUnsubscribe, + remoteWrite, + setPeerLinkRole, +} from './peer-link'; import { log } from './log'; import type { WebviewChannel } from './webview-messaging'; @@ -75,6 +86,8 @@ function wantedSingletonNames(): Set { function onWindowLeaseChange(held: boolean): void { if (windowLeaseHeld === held) return; windowLeaseHeld = held; + // The holder is the Host, so it is also the window every other one reports to. + setPeerLinkRole(held); if (held) { for (const name of wantedSingletonNames()) electSingleton(name); return; @@ -112,6 +125,8 @@ interface ActiveRouter { forwardDorControlRequest(request: DorControlRequest): void; notifyStoreChanged(key: string, value: string | null): void; askDirectory(requestId: string): void; + deliverForeignData(ptyId: string, data: string): void; + deliverForeignExit(ptyId: string, exitCode: number): void; askSurface( requestId: string, surfaceId: string, @@ -133,20 +148,119 @@ interface ActiveRouter { */ const PEER_REPLY_BUDGET_MS = 1_000; -interface PeerRequest { - /** Replies still outstanding; the request settles when it empties. */ +let nextBrokerRequestId = 0; + +interface PendingDirectory { pending: Set; entries: unknown[]; settle: () => void; timer: ReturnType; } -const peerDirectoryRequests = new Map(); +const peerDirectoryRequests = new Map(); -interface PeerSurfaceRequest { - settle: (result: { ok: boolean; ptyId?: string; cols?: number; rows?: number }) => void; +interface PendingSurface { + settle: (result: PeerSurfaceResult) => void; timer: ReturnType; } -const peerSurfaceRequests = new Map(); +const peerSurfaceRequests = new Map(); + +export interface PeerSurfaceResult { + ok: boolean; + ptyId?: string; + cols?: number; + rows?: number; +} + +// 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 brokers. +configurePeerLink({ + brokerDirectory: () => brokerDirectory(), + brokerSurfaceOp: (surfaceId, op, cols, rows) => brokerSurfaceOp(surfaceId, op, cols, rows), + deliverRemotePtyData: (ptyId, data) => deliverRemotePtyData(ptyId, data), + deliverRemotePtyExit: (ptyId, code) => deliverRemotePtyExit(ptyId, code), + onProcessedPtyData: (listener) => onProcessedPtyData(listener), +}); + +/** + * Collect directory entries from every webview in this window except `exclude`. + * + * Settles when they have all answered or the budget expires: a webview with no + * live content never replies, and must not hang the phone's picker. Callable + * from a webview request (the Host asking) and from a peer window's socket + * (tier 2), which is why it is a plain promise rather than message plumbing. + */ +export function brokerDirectory(exclude?: ActiveRouter): Promise { + const peers = [...activeRouters].filter((router) => router !== exclude); + if (peers.length === 0) return Promise.resolve([]); + + const requestId = `broker-dir-${++nextBrokerRequestId}`; + return new Promise((resolve) => { + const settle = () => { + const request = peerDirectoryRequests.get(requestId); + if (!request) return; + peerDirectoryRequests.delete(requestId); + clearTimeout(request.timer); + resolve(request.entries); + }; + peerDirectoryRequests.set(requestId, { + pending: new Set(peers), + entries: [], + settle, + timer: setTimeout(settle, PEER_REPLY_BUDGET_MS), + }); + for (const peer of peers) peer.askDirectory(requestId); + }); +} + +/** + * Ask every webview except `exclude` to drive a surface; only its owner answers. + * + * Broadcast rather than tracking surfaceId ownership: a window holds a handful + * of webviews, and the owner is the only one that can act anyway. + */ +export function brokerSurfaceOp( + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, + exclude?: ActiveRouter, +): Promise { + const peers = [...activeRouters].filter((router) => router !== exclude); + if (peers.length === 0) return Promise.resolve({ ok: false }); + + const requestId = `broker-surface-${++nextBrokerRequestId}`; + return new Promise((resolve) => { + const settle = (result: PeerSurfaceResult) => { + const request = peerSurfaceRequests.get(requestId); + if (!request) return; + peerSurfaceRequests.delete(requestId); + clearTimeout(request.timer); + resolve(result); + }; + peerSurfaceRequests.set(requestId, { + settle, + timer: setTimeout(() => settle({ ok: false }), PEER_REPLY_BUDGET_MS), + }); + for (const peer of peers) peer.askSurface(requestId, surfaceId, op, cols, rows); + }); +} + +/** + * Hand a webview bytes from a PTY in another *window*. + * + * Local PTYs reach a subscriber through `onProcessedPtyData`; a PTY in another + * window has no such listener here, so the peer link injects it by the same + * route the subscriber already expects. Only webviews that asked for this PTY + * receive it, exactly as with a local subscription. + */ +export function deliverRemotePtyData(ptyId: string, data: string): void { + for (const router of activeRouters) router.deliverForeignData(ptyId, data); +} + +/** As {@link deliverRemotePtyData}, for that PTY ending. */ +export function deliverRemotePtyExit(ptyId: string, exitCode: number): void { + for (const router of activeRouters) router.deliverForeignExit(ptyId, exitCode); +} /** * Tell every webview about a committed Host-store write. @@ -188,7 +302,7 @@ const processedDataListeners = 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); }; } @@ -475,10 +589,11 @@ export function attachRouter( break; } case 'pty:input': - ptyManager.write(msg.id, msg.data); + // `remoteWrite` reports false for anything this window owns. + if (!remoteWrite(msg.id, msg.data)) ptyManager.write(msg.id, msg.data); break; case 'pty:resize': - ptyManager.resize(msg.id, msg.cols, msg.rows); + if (!remoteResize(msg.id, msg.cols, msg.rows)) ptyManager.resize(msg.id, msg.cols, msg.rows); break; case 'pty:kill': release(msg.id); @@ -635,37 +750,25 @@ export function attachRouter( ); break; case 'pty:subscribe': - if (typeof msg.id === 'string') subscribedPtyIds.add(msg.id); + if (typeof msg.id !== 'string') break; + subscribedPtyIds.add(msg.id); + // A PTY in another window has no local listener to hook; ask its window + // to start sending it. + if (isRemotePty(msg.id)) remoteSubscribe(msg.id); break; case 'pty:unsubscribe': - if (typeof msg.id === 'string') subscribedPtyIds.delete(msg.id); + if (typeof msg.id !== 'string') break; + subscribedPtyIds.delete(msg.id); + if (isRemotePty(msg.id)) remoteUnsubscribe(msg.id); break; case 'peer:directory': { - // Fan out to the other webviews in this window and answer once they - // have all replied, or the budget expires — a webview with no live - // content never replies, and must not hang the phone's picker. + // This window's other webviews, plus every window reporting to us. const requestId = msg.requestId; - const peers = [...activeRouters].filter((other) => other !== router); - if (peers.length === 0) { - void post({ type: 'peer:directoryResult', requestId, entries: [] } satisfies ExtensionMessage); - break; - } - const settle = () => { - const request = peerDirectoryRequests.get(requestId); - if (!request) return; - peerDirectoryRequests.delete(requestId); - clearTimeout(request.timer); - void post({ - type: 'peer:directoryResult', requestId, entries: request.entries, - } satisfies ExtensionMessage); - }; - peerDirectoryRequests.set(requestId, { - pending: new Set(peers), - entries: [], - settle, - timer: setTimeout(settle, PEER_REPLY_BUDGET_MS), - }); - for (const peer of peers) peer.askDirectory(requestId); + void Promise.all([brokerDirectory(router), remoteDirectory()]).then(([here, elsewhere]) => + post({ + type: 'peer:directoryResult', requestId, entries: [...here, ...elsewhere], + } satisfies ExtensionMessage), + ); break; } case 'peer:directoryEntries': { @@ -677,29 +780,14 @@ export function attachRouter( break; } case 'peer:surfaceOp': { - // Broadcast rather than track surfaceId ownership: only the owning - // webview can answer, the others stay silent, and a window holds a - // handful of webviews. const requestId = msg.requestId; - const peers = [...activeRouters].filter((other) => other !== router); - const settle = (result: { ok: boolean; ptyId?: string; cols?: number; rows?: number }) => { - const request = peerSurfaceRequests.get(requestId); - if (!request) return; - peerSurfaceRequests.delete(requestId); - clearTimeout(request.timer); - void post({ type: 'peer:surfaceOpResult', requestId, ...result } satisfies ExtensionMessage); - }; - if (peers.length === 0) { - settle({ ok: false }); - break; - } - peerSurfaceRequests.set(requestId, { - settle, - timer: setTimeout(() => settle({ ok: false }), PEER_REPLY_BUDGET_MS), - }); - for (const peer of peers) { - peer.askSurface(requestId, msg.surfaceId, msg.op, msg.cols, msg.rows); - } + const { surfaceId, op, cols, rows } = msg; + void brokerSurfaceOp(surfaceId, op, cols, rows, router) + // Nobody here owns it — try the windows reporting to us. + .then((result) => (result.ok ? result : remoteSurfaceOp(surfaceId, op, cols, rows))) + .then((result) => + post({ type: 'peer:surfaceOpResult', requestId, ...result } satisfies ExtensionMessage), + ); break; } case 'peer:surfaceResult': { @@ -913,6 +1001,14 @@ export function attachRouter( if (disposed) return; void post({ type: 'peer:directoryRequest', requestId } satisfies ExtensionMessage); }, + deliverForeignData(ptyId: string, data: string) { + if (disposed || !subscribedPtyIds.has(ptyId)) return; + void post({ type: 'pty:data', id: ptyId, data } satisfies ExtensionMessage); + }, + deliverForeignExit(ptyId: string, exitCode: number) { + if (disposed || !subscribedPtyIds.has(ptyId)) return; + void post({ type: 'pty:exit', id: ptyId, exitCode } satisfies ExtensionMessage); + }, askSurface( requestId: string, surfaceId: string, diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts new file mode 100644 index 00000000..c713606d --- /dev/null +++ b/vscode-ext/src/peer-link.ts @@ -0,0 +1,443 @@ +/** + * Peer surfaces across VS Code windows (docs/specs/vscode.md → "Peer surfaces + * across windows"). + * + * Within a window the extension host sees every webview, so brokering is a + * function call (`brokerDirectory` / `brokerSurfaceOp`). Across windows there + * is no shared process at all — one extension host each — so the window holding + * the Host lease listens on a local socket and every other window connects to + * it. Because the webview lease is itself gated on the window lease, the broker + * window is always the Host window; the broker never has to relay back out to a + * remote Host, which keeps this one-directional. + * + * Roles follow the lease: acquire it and you become the server, lose it and you + * become a client. The frame shapes, framing, and PTY routing table are in + * `lib/src/lib/vscode-peer-link-protocol.ts`, which is where the fiddly parts + * are tested. + * + * Trust: the socket is a user-owned unix socket (or named pipe) whose path is + * published only in a mode-0600 rendezvous file, and a client must open with a + * token read from that file. That is the same bar as the `dor` control socket. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; +import { createConnection, createServer, type Server, type Socket } from 'node:net'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { watch, type FSWatcher } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type * as vscode from 'vscode'; + +import { + FrameDecoder, + PeerRouteTable, + encodeFrame, + type PeerLinkRequest, + type PeerLinkResponse, +} from '../../lib/src/lib/vscode-peer-link-protocol'; +import { log } from './log'; +import * as ptyManager from './pty-manager'; + +export interface PeerSurfaceResult { + ok: boolean; + ptyId?: string; + cols?: number; + rows?: number; +} + +/** + * 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. + */ +export interface PeerLinkDeps { + /** Fan out to this window's own webviews — never to other windows. */ + brokerDirectory(): Promise; + brokerSurfaceOp( + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, + ): Promise; + deliverRemotePtyData(ptyId: string, data: string): void; + deliverRemotePtyExit(ptyId: string, exitCode: number): void; + onProcessedPtyData(listener: (id: string, data: string) => void): () => void; +} + +let deps: PeerLinkDeps | null = null; + +export function configurePeerLink(next: PeerLinkDeps): void { + deps = next; +} + +const RENDEZVOUS_FILE = 'remote-host.peer.json'; + +/** Matches the in-window fan-out budget; a window that cannot answer is skipped. */ +const PEER_REPLY_BUDGET_MS = 1_000; + +/** Backoff for a client whose broker went away before a new one took the lease. */ +const RECONNECT_MS = 2_000; + +interface Rendezvous { + socketPath: string; + token: string; +} + +let context: vscode.ExtensionContext | null = null; + +export function initPeerLink(ctx: vscode.ExtensionContext): void { + context = ctx; +} + +function rendezvousPath(): string | null { + return context ? join(context.globalStorageUri.fsPath, RENDEZVOUS_FILE) : null; +} + +/** + * Sockets live in the temp dir, not next to the rendezvous file: macOS caps a + * unix socket path near 104 bytes and the extension's globalStorage path is + * most of that on its own. + */ +function newSocketPath(): string { + const id = randomBytes(6).toString('hex'); + return process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-${id}` + : join(tmpdir(), `dormouse-peer-${id}.sock`); +} + +// ---------------------------------------------------------------- server side + +interface PeerClient { + socket: Socket; + decoder: FrameDecoder; + authenticated: boolean; +} + +let server: Server | null = null; +let serverToken = ''; +let serverSocketPath = ''; +const clients = new Set(); +const routes = new PeerRouteTable(); +const pendingRequests = new Map void>(); +let nextRequestId = 0; + +function send(client: PeerClient, frame: PeerLinkRequest): 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: PeerClient, frame: PeerLinkRequest): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingRequests.delete(frame.id); + resolve(null); + }, PEER_REPLY_BUDGET_MS); + pendingRequests.set(frame.id, (response) => { + clearTimeout(timer); + pendingRequests.delete(frame.id); + resolve(response); + }); + send(client, frame); + }); +} + +function authenticatedClients(): PeerClient[] { + return [...clients].filter((client) => client.authenticated); +} + +/** Directory entries from every other window. Empty when nothing is connected. */ +export async function remoteDirectory(): Promise { + const peers = authenticatedClients(); + if (peers.length === 0) return []; + const replies = await Promise.all( + peers.map((client) => ask(client, { kind: 'directory', id: `r${++nextRequestId}` })), + ); + return replies.flatMap((reply) => + reply?.kind === 'directoryResult' ? reply.entries : [], + ); +} + +/** + * Drive a surface owned by another window. The first window to claim it wins; + * the rest own no such id and answer `ok: false`. + */ +export async function remoteSurfaceOp( + surfaceId: string, + op: 'attach' | 'detach' | 'resize', + cols?: number, + rows?: number, +): Promise { + for (const client of authenticatedClients()) { + const reply = await ask(client, { + kind: 'surfaceOp', id: `r${++nextRequestId}`, surfaceId, op, cols, rows, + }); + if (reply?.kind !== 'surfaceResult' || !reply.ok) continue; + // Remember where this PTY lives: a ptyId alone says nothing about which + // window owns it, and input and resizes have to reach that window. + if (reply.ptyId) routes.claim(reply.ptyId, client); + return { ok: true, ptyId: reply.ptyId, cols: reply.cols, rows: reply.rows }; + } + return { ok: false }; +} + +/** Whether this PTY is streaming from another window. */ +export function isRemotePty(ptyId: string): boolean { + return routes.peerFor(ptyId) !== undefined; +} + +export function remoteSubscribe(ptyId: string): void { + const client = routes.peerFor(ptyId); + if (client) send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); +} + +export function remoteUnsubscribe(ptyId: string): void { + const client = routes.peerFor(ptyId); + if (!client) return; + send(client, { kind: 'unsubscribe', id: `r${++nextRequestId}`, ptyId }); + routes.release(ptyId); +} + +export function remoteWrite(ptyId: string, data: string): boolean { + const client = routes.peerFor(ptyId); + if (!client) return false; + send(client, { kind: 'write', id: `r${++nextRequestId}`, ptyId, data }); + return true; +} + +export function remoteResize(ptyId: string, cols: number, rows: number): boolean { + const client = routes.peerFor(ptyId); + if (!client) return false; + send(client, { kind: 'resizePty', id: `r${++nextRequestId}`, ptyId, cols, rows }); + return true; +} + +function dropClient(client: PeerClient): void { + clients.delete(client); + // A window that went away takes its terminals with it; a later write must not + // be routed into a dead socket. + for (const ptyId of routes.forgetPeer(client)) deps?.deliverRemotePtyExit(ptyId, 0); + client.socket.destroy(); +} + +function onServerFrame(client: PeerClient, frame: unknown): void { + const message = frame as (PeerLinkResponse | { kind: 'hello'; token: string }) & { + kind: string; + }; + if (!client.authenticated) { + // First frame must be the hello; anything else is not a peer of ours. + const hello = message as { kind: string; token?: string }; + if (hello.kind !== 'hello' || hello.token !== serverToken) { + log.error('[peer-link] rejected a client with a bad hello'); + dropClient(client); + return; + } + client.authenticated = true; + return; + } + + const response = message as PeerLinkResponse; + if (response.kind === 'data') { + deps?.deliverRemotePtyData(response.ptyId, response.data); + return; + } + if (response.kind === 'exit') { + routes.release(response.ptyId); + deps?.deliverRemotePtyExit(response.ptyId, response.exitCode); + return; + } + if ('id' in response) pendingRequests.get(response.id)?.(response); +} + +async function startServer(): Promise { + const path = rendezvousPath(); + if (!path || server) return; + + serverToken = randomUUID(); + serverSocketPath = newSocketPath(); + await rm(serverSocketPath, { force: true }).catch(() => {}); + + server = createServer((socket) => { + const client: PeerClient = { socket, decoder: new FrameDecoder(), authenticated: false }; + clients.add(client); + 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)); + }); + + await new Promise((resolve) => server!.listen(serverSocketPath, resolve)); + await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); + const rendezvous: Rendezvous = { socketPath: serverSocketPath, token: serverToken }; + await writeFile(path, JSON.stringify(rendezvous), 'utf8'); + // The token is the only thing standing between another local process and this + // window's terminals. + await chmod(path, 0o600).catch(() => {}); + log.info('[peer-link] serving peers'); +} + +async function stopServer(): Promise { + if (!server) return; + const path = rendezvousPath(); + for (const client of [...clients]) dropClient(client); + server.close(); + server = null; + await rm(serverSocketPath, { force: true }).catch(() => {}); + if (path) await rm(path, { force: true }).catch(() => {}); +} + +// ---------------------------------------------------------------- client side + +let client: Socket | null = null; +let clientRetry: ReturnType | null = null; +let rendezvousWatcher: FSWatcher | null = null; +/** PTYs this window is streaming to the broker, and how to stop. */ +const forwarding = new Map void>(); + +function respond(frame: PeerLinkResponse): void { + client?.write(encodeFrame(frame)); +} + +async function onClientFrame(frame: unknown): Promise { + const request = frame as PeerLinkRequest; + switch (request.kind) { + case 'directory': + respond({ kind: 'directoryResult', id: request.id, entries: (await deps?.brokerDirectory()) ?? [] }); + break; + case 'surfaceOp': { + const result = (await deps?.brokerSurfaceOp( + request.surfaceId, request.op, request.cols, request.rows, + )) ?? { ok: false }; + respond({ kind: 'surfaceResult', id: request.id, ...result }); + break; + } + case 'subscribe': { + if (forwarding.has(request.ptyId)) break; + const stop = deps?.onProcessedPtyData((id, data) => { + if (id === request.ptyId) respond({ kind: 'data', ptyId: id, data }); + }); + if (stop) forwarding.set(request.ptyId, stop); + respond({ kind: 'ack', id: request.id }); + break; + } + case 'unsubscribe': + forwarding.get(request.ptyId)?.(); + forwarding.delete(request.ptyId); + respond({ kind: 'ack', id: request.id }); + break; + case 'write': + ptyManager.write(request.ptyId, request.data); + break; + case 'resizePty': + ptyManager.resize(request.ptyId, request.cols, request.rows); + break; + } +} + +function stopForwarding(): void { + for (const stop of forwarding.values()) stop(); + forwarding.clear(); +} + +async function readRendezvous(): Promise { + const path = rendezvousPath(); + if (!path) return null; + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')); + const value = parsed as Rendezvous; + return typeof value?.socketPath === 'string' && typeof value?.token === 'string' + ? value + : null; + } catch { + return null; + } +} + +async function connectClient(): Promise { + if (client || server) return; + const rendezvous = await readRendezvous(); + if (!rendezvous) { + scheduleReconnect(); + return; + } + + const socket = createConnection({ path: rendezvous.socketPath }); + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.on('connect', () => { + socket.write(encodeFrame({ kind: 'hello', token: rendezvous.token })); + log.info('[peer-link] connected to the broker window'); + }); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) void onClientFrame(frame); + }); + const drop = () => { + if (client !== socket) return; + client = null; + stopForwarding(); + scheduleReconnect(); + }; + socket.on('error', drop); + socket.on('close', drop); + client = socket; +} + +function scheduleReconnect(): void { + if (clientRetry || server) return; + clientRetry = setTimeout(() => { + clientRetry = null; + void connectClient(); + }, RECONNECT_MS); +} + +function disconnectClient(): void { + if (clientRetry) { + clearTimeout(clientRetry); + clientRetry = null; + } + stopForwarding(); + client?.destroy(); + client = null; +} + +// ---------------------------------------------------------------- role switch + +/** + * Follow the window lease: the holder serves, everyone else connects to it. + * Called on every lease change, and idempotent for an unchanged role. + */ +export function setPeerLinkRole(isBroker: boolean): void { + if (isBroker) { + disconnectClient(); + void startServer(); + return; + } + void stopServer().then(() => { + // Watch the rendezvous rather than only retrying: when a new window takes + // the lease it publishes a fresh socket path, and polling would make every + // handover wait out the backoff. + if (!rendezvousWatcher && context) { + const dir = context.globalStorageUri.fsPath; + try { + rendezvousWatcher = watch(dir, (_event, filename) => { + if (filename && filename !== RENDEZVOUS_FILE) return; + disconnectClient(); + void connectClient(); + }); + } catch { + // No watcher here: the reconnect timer still converges. + } + } + void connectClient(); + }); +} + +export async function disposePeerLink(): Promise { + rendezvousWatcher?.close(); + rendezvousWatcher = null; + disconnectClient(); + await stopServer(); +} From 5f3ad07e22ac6b97fc4b9b029491b6d30a4abf9a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 12:22:34 -0700 Subject: [PATCH 10/56] Give vscode-ext a test runner, and cover the socket and lease I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features have now landed with their I/O halves untested, on the grounds that `vscode-ext` had no runner. That turned out to be a thin excuse: most modules worth testing import `vscode` as `import type`, which erases, and the only runtime use in that graph is the output channel `log.ts` opens. So vitest plus a four-line stub is the whole setup. `peer-link.ts` did import `pty-manager` — and therefore node-pty — for exactly two calls, while injecting everything else. Those two move into `PeerLinkDeps` with the rest, which removes the last obstacle and drops an inconsistency the module already had. The tests are the ones that need real I/O, since the pure halves are already covered in lib: two lease instances contending over a real directory and handing over on dispose, and a broker and a peer over a real socket covering the rendezvous handshake, PTY routing, streaming, unsubscribe, token rejection, and what a disconnect does to in-flight terminals. One process plays two windows via `vi.resetModules()` and a dynamic import. The socket test immediately earned its keep: `startServer` runs fire-and-forget from the lease callback, so a failed rendezvous write surfaced as an unhandled rejection rather than a logged error. An unwritable globalStorage should mean no peers, not a crashed extension host — it now catches, logs, and tears the half-started server down. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 8 + pnpm-lock.yaml | 3 + vscode-ext/package.json | 5 +- vscode-ext/src/message-router.ts | 2 + vscode-ext/src/peer-link.ts | 31 +-- vscode-ext/test/peer-link.test.ts | 272 +++++++++++++++++++++++++++ vscode-ext/test/vscode-stub.ts | 16 ++ vscode-ext/test/window-lease.test.ts | 115 +++++++++++ vscode-ext/vitest.config.mts | 21 +++ 9 files changed, 460 insertions(+), 13 deletions(-) create mode 100644 vscode-ext/test/peer-link.test.ts create mode 100644 vscode-ext/test/vscode-stub.ts create mode 100644 vscode-ext/test/window-lease.test.ts create mode 100644 vscode-ext/vitest.config.mts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 86a46b08..d263023f 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -314,6 +314,14 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and roles, `lib/s Source of truth: the broker in `vscode-ext/src/message-router.ts` (`peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the responder in `lib/src/remote/host/peer-surfaces.ts`, and the foreign-surface path in `remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.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`: `test/window-lease.test.ts` drives two module instances against a real directory (two windows contending, and a handover on dispose), and `test/peer-link.test.ts` stands up a broker and a peer over a real socket to cover the rendezvous handshake, PTY routing, streaming, token rejection, and what a disconnect does to in-flight terminals. 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/pnpm-lock.yaml b/pnpm-lock.yaml index 116be0aa..b0b31fdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,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: diff --git a/vscode-ext/package.json b/vscode-ext/package.json index d378ef68..ab3cd63c 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -103,7 +103,7 @@ "build:frontend": "vite build --config vite.config.ts", "pretypecheck": "pnpm --filter dor-lib-common build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "pnpm typecheck", + "test": "pnpm typecheck && vitest run", "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 stage:dor-cli && node scripts/esbuild.mjs --watch", @@ -125,6 +125,7 @@ "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/src/message-router.ts b/vscode-ext/src/message-router.ts index c38c3464..f82ef1e1 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -179,6 +179,8 @@ configurePeerLink({ deliverRemotePtyData: (ptyId, data) => deliverRemotePtyData(ptyId, data), deliverRemotePtyExit: (ptyId, code) => deliverRemotePtyExit(ptyId, code), onProcessedPtyData: (listener) => onProcessedPtyData(listener), + writePty: (ptyId, data) => ptyManager.write(ptyId, data), + resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), }); /** diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index c713606d..1b19f7d2 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -37,7 +37,6 @@ import { type PeerLinkResponse, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; -import * as ptyManager from './pty-manager'; export interface PeerSurfaceResult { ok: boolean; @@ -63,6 +62,8 @@ export interface PeerLinkDeps { deliverRemotePtyData(ptyId: string, data: string): void; deliverRemotePtyExit(ptyId: string, exitCode: number): void; onProcessedPtyData(listener: (id: string, data: string) => void): () => void; + writePty(ptyId: string, data: string): void; + resizePty(ptyId: string, cols: number, rows: number): void; } let deps: PeerLinkDeps | null = null; @@ -269,14 +270,22 @@ async function startServer(): Promise { socket.on('close', () => dropClient(client)); }); - await new Promise((resolve) => server!.listen(serverSocketPath, resolve)); - await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); - const rendezvous: Rendezvous = { socketPath: serverSocketPath, token: serverToken }; - await writeFile(path, JSON.stringify(rendezvous), 'utf8'); - // The token is the only thing standing between another local process and this - // window's terminals. - await chmod(path, 0o600).catch(() => {}); - log.info('[peer-link] serving peers'); + try { + await new Promise((resolve) => server!.listen(serverSocketPath, resolve)); + await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); + const rendezvous: Rendezvous = { socketPath: serverSocketPath, token: serverToken }; + await writeFile(path, JSON.stringify(rendezvous), 'utf8'); + // The token is the only thing standing between another local process and + // this window's terminals. + await chmod(path, 0o600).catch(() => {}); + log.info('[peer-link] serving peers'); + } catch (err) { + // Started fire-and-forget from the lease callback, so a rejection here + // would surface as an unhandled one rather than as a broken link. An + // unwritable globalStorage means no peers, not a crashed extension host. + log.error(`[peer-link] could not start serving: ${String(err)}`); + await stopServer(); + } } async function stopServer(): Promise { @@ -329,10 +338,10 @@ async function onClientFrame(frame: unknown): Promise { respond({ kind: 'ack', id: request.id }); break; case 'write': - ptyManager.write(request.ptyId, request.data); + deps?.writePty(request.ptyId, request.data); break; case 'resizePty': - ptyManager.resize(request.ptyId, request.cols, request.rows); + deps?.resizePty(request.ptyId, request.cols, request.rows); break; } } diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts new file mode 100644 index 00000000..28840167 --- /dev/null +++ b/vscode-ext/test/peer-link.test.ts @@ -0,0 +1,272 @@ +/** + * The cross-window link, driven end to end: two independent module instances + * standing in for two VS Code windows, talking over a real socket in a temp + * directory. The frames and the routing table are unit-tested in + * `lib/src/lib/vscode-peer-link-protocol.test.ts`; this covers the parts that + * only exist once there is a socket — the rendezvous handshake, role switching, + * PTY routing, and what happens when a window goes away. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type LinkModule = typeof import('../src/peer-link'); + +let dir: string; +const opened: LinkModule[] = []; + +/** Records what a window was asked to do on its own terminals. */ +function fakeWindow(options: { + entries?: unknown[]; + surfaces?: Record; +} = {}) { + const dataListeners = new Set<(id: string, data: string) => void>(); + return { + entries: options.entries ?? [], + surfaces: options.surfaces ?? {}, + writes: [] as Array<{ ptyId: string; data: string }>, + resizes: [] as Array<{ ptyId: string; cols: number; rows: number }>, + delivered: [] as Array<{ ptyId: string; data: string }>, + exits: [] as Array<{ ptyId: string; exitCode: number }>, + emitData(id: string, data: string) { + for (const listener of dataListeners) listener(id, data); + }, + deps() { + return { + brokerDirectory: async () => this.entries, + brokerSurfaceOp: async (surfaceId: string) => { + const surface = this.surfaces[surfaceId]; + return surface ? { ok: true, ...surface } : { ok: false }; + }, + deliverRemotePtyData: (ptyId: string, data: string) => + void this.delivered.push({ ptyId, data }), + deliverRemotePtyExit: (ptyId: string, exitCode: number) => + void this.exits.push({ ptyId, exitCode }), + onProcessedPtyData: (listener: (id: string, data: string) => void) => { + dataListeners.add(listener); + return () => dataListeners.delete(listener); + }, + writePty: (ptyId: string, data: string) => void this.writes.push({ ptyId, data }), + resizePty: (ptyId: string, cols: number, rows: number) => + void this.resizes.push({ ptyId, cols, rows }), + }; + }, + }; +} + +async function openWindow(deps: ReturnType): Promise { + vi.resetModules(); + const mod: LinkModule = await import('../src/peer-link'); + mod.initPeerLink({ globalStorageUri: { fsPath: dir }, subscriptions: [] } as never); + mod.configurePeerLink(deps.deps()); + opened.push(mod); + return mod; +} + +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, 20)); + } + throw new Error('timed out waiting for the peer link'); +} + +/** + * Start a broker and a peer, and wait until they can actually talk. The peer + * 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 broker = await openWindow(brokerSide); + broker.setPeerLinkRole(true); + await waitFor(async () => { + try { + await access(join(dir, 'remote-host.peer.json')); + return true; + } catch { + return false; + } + }); + + const peer = await openWindow(peerSide); + peer.setPeerLinkRole(false); + // The handshake is asynchronous; the first answered request proves it landed. + await waitFor(async () => (await broker.remoteDirectory()).length > 0); + return { broker, brokerSide, peer, peerSide }; +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dormouse-peer-')); +}); + +afterEach(async () => { + for (const mod of opened) await mod.disposePeerLink(); + opened.length = 0; + await rm(dir, { recursive: true, force: true }); +}); + +describe('peer link between windows', () => { + 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.remoteDirectory()).toEqual([{ surfaceId: 'far-1' }, { surfaceId: 'far-2' }]); + }); + + it('returns nothing when no other window is connected', async () => { + const broker = await openWindow(fakeWindow()); + broker.setPeerLinkRole(true); + await waitFor(async () => { + try { + await access(join(dir, 'remote-host.peer.json')); + return true; + } catch { + return false; + } + }); + expect(await broker.remoteDirectory()).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.remoteSurfaceOp('far-1', 'attach', 100, 30); + expect(result).toEqual({ ok: true, ptyId: 'pty-far', cols: 100, rows: 30 }); + // Input and resizes have to reach that window afterwards. + expect(broker.isRemotePty('pty-far')).toBe(true); + }); + + it('reports a surface nobody owns', async () => { + const { broker } = await linkedPair(fakeWindow(), fakeWindow({ entries: [{ s: 1 }] })); + expect(await broker.remoteSurfaceOp('nobody', 'attach', 80, 24)).toEqual({ ok: false }); + expect(broker.isRemotePty('nobody')).toBe(false); + }); + + it('streams a subscribed PTY from the owning window', async () => { + const peerSide = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + + broker.remoteSubscribe('pty-far'); + await new Promise((resolve) => setTimeout(resolve, 50)); + peerSide.emitData('pty-far', 'output from the other window'); + + await waitFor(() => brokerSide.delivered.length > 0); + expect(brokerSide.delivered).toEqual([{ ptyId: 'pty-far', data: 'output from the other window' }]); + }); + + it('does not stream PTYs it never subscribed to', async () => { + const peerSide = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + + peerSide.emitData('pty-other', 'not subscribed'); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(brokerSide.delivered).toEqual([]); + }); + + it('stops the stream on unsubscribe', async () => { + const peerSide = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + broker.remoteSubscribe('pty-far'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + broker.remoteUnsubscribe('pty-far'); + await new Promise((resolve) => setTimeout(resolve, 50)); + peerSide.emitData('pty-far', 'after unsubscribe'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(brokerSide.delivered).toEqual([]); + // Unsubscribing also forgets the route, so a later write is not misrouted. + expect(broker.isRemotePty('pty-far')).toBe(false); + }); + + it('routes input and resize to the owning window', async () => { + const peerSide = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + const { broker } = await linkedPair(fakeWindow(), peerSide); + await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + + expect(broker.remoteWrite('pty-far', 'ls\r')).toBe(true); + expect(broker.remoteResize('pty-far', 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 = fakeWindow({ + entries: [{ surfaceId: 'far-1' }], + surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, + }); + const { broker, brokerSide, peer } = await linkedPair(fakeWindow(), peerSide); + await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + expect(broker.isRemotePty('pty-far')).toBe(true); + + // 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(() => brokerSide.exits.length > 0); + expect(brokerSide.exits).toEqual([{ ptyId: 'pty-far', exitCode: 0 }]); + expect(broker.isRemotePty('pty-far')).toBe(false); + expect(broker.remoteWrite('pty-far', 'x')).toBe(false); + }); + + it('rejects a client that does not know the token', async () => { + const brokerSide = fakeWindow(); + const broker = await openWindow(brokerSide); + broker.setPeerLinkRole(true); + const rendezvousPath = join(dir, 'remote-host.peer.json'); + await waitFor(async () => { + try { + await access(rendezvousPath); + return true; + } catch { + return false; + } + }); + + const { readFile } = await import('node:fs/promises'); + const { socketPath } = JSON.parse(await readFile(rendezvousPath, 'utf8')); + const { createConnection } = await import('node:net'); + const socket = createConnection({ path: socketPath }); + await new Promise((resolve) => socket.on('connect', resolve)); + socket.write(`${JSON.stringify({ kind: 'hello', token: 'wrong' })}\n`); + + // The server drops it rather than answering anything. + await new Promise((resolve) => socket.on('close', resolve)); + expect(await broker.remoteDirectory()).toEqual([]); + socket.destroy(); + }); +}); 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/test/window-lease.test.ts b/vscode-ext/test/window-lease.test.ts new file mode 100644 index 00000000..d2c44de9 --- /dev/null +++ b/vscode-ext/test/window-lease.test.ts @@ -0,0 +1,115 @@ +/** + * The lease's filesystem half. The rules are unit-tested in + * `lib/src/lib/vscode-window-lease.test.ts`; this drives two independent module + * instances — standing in for two VS Code windows — against a real directory. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type LeaseModule = typeof import('../src/window-lease'); + +let dir: string; +const opened: LeaseModule[] = []; + +/** A separate module instance, so each behaves like its own extension host. */ +async function openWindow(): Promise { + vi.resetModules(); + const mod: LeaseModule = await import('../src/window-lease'); + mod.initWindowLease({ globalStorageUri: { fsPath: dir }, subscriptions: [] } as never); + opened.push(mod); + return mod; +} + +async function waitFor(predicate: () => boolean, budgetMs = 3_000): Promise { + const deadline = Date.now() + budgetMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('timed out waiting for the lease'); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dormouse-lease-')); +}); + +afterEach(async () => { + for (const mod of opened) await mod.disposeWindowLease(); + opened.length = 0; + await rm(dir, { recursive: true, force: true }); +}); + +describe('window lease over a real directory', () => { + it('acquires when nothing holds it, and records an owner', async () => { + const window = await openWindow(); + window.ensureWindowLease(() => {}); + + await waitFor(() => window.holdsWindowLease()); + const record = JSON.parse(await readFile(join(dir, 'remote-host.lease.json'), 'utf8')); + expect(typeof record.owner).toBe('string'); + expect(record.heartbeatAt).toBeGreaterThan(0); + }); + + it('grants the role to exactly one of two windows', async () => { + const first = await openWindow(); + first.ensureWindowLease(() => {}); + await waitFor(() => first.holdsWindowLease()); + + const second = await openWindow(); + second.ensureWindowLease(() => {}); + // Long enough for a claim-and-verify cycle to have run and lost. + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(first.holdsWindowLease()).toBe(true); + expect(second.holdsWindowLease()).toBe(false); + }); + + it('hands the role over when the holder disposes', async () => { + const first = await openWindow(); + first.ensureWindowLease(() => {}); + await waitFor(() => first.holdsWindowLease()); + + const second = await openWindow(); + const changes: boolean[] = []; + second.ensureWindowLease((held) => changes.push(held)); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(second.holdsWindowLease()).toBe(false); + + // Closing the holder must not leave the role stranded until the TTL. + await first.disposeWindowLease(); + await waitFor(() => second.holdsWindowLease()); + expect(changes).toContain(true); + }); + + it('reports the role change to its listener exactly once per transition', async () => { + const window = await openWindow(); + const changes: boolean[] = []; + window.ensureWindowLease((held) => changes.push(held)); + await waitFor(() => window.holdsWindowLease()); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(changes).toEqual([true]); + }); + + it('re-announces the current role to a second caller without restarting', async () => { + const window = await openWindow(); + window.ensureWindowLease(() => {}); + await waitFor(() => window.holdsWindowLease()); + + const seen: boolean[] = []; + window.ensureWindowLease((held) => seen.push(held)); + expect(seen).toEqual([true]); + }); + + it('does nothing before it is told where to store the record', async () => { + vi.resetModules(); + const mod: LeaseModule = await import('../src/window-lease'); + opened.push(mod); + mod.ensureWindowLease(() => {}); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mod.holdsWindowLease()).toBe(false); + }); +}); 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'], + }, +}); From c742e0c0c9b65ab3b22c7a6fd7d20a73364517f9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 12:43:43 -0700 Subject: [PATCH 11/56] Cleanup pass on the lease and peer layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a four-angle review. Two of these are security properties, not tidiness. The peer-link socket guarded its token with `!==`, under a comment claiming parity with the `dor` control socket — which deliberately uses a constant-time compare, because `!==` leaks the token byte-by-byte to a co-resident process that can time the response. It now compares the same way. That file is CommonJS and the shared protocol module has to stay Node-free for the webview, so this is a second copy rather than an import; the comment now says so instead of claiming a reuse that does not exist. The rendezvous file carrying that token was written plain and chmod-ed 0600 afterwards, leaving it world-readable in between, and non-atomically — a reader catching the truncated window fell into the 2s reconnect backoff. It is now written 0600 to a temp file and renamed into place, which fixes both. A surface request that nobody in this window owned always burned the full 1s budget, because non-owners answered by staying silent. Since the common case for a miss is "it lives in another window", that was a second of latency on the path that matters most. Every webview now answers, and the broker settles as soon as they all have — the same shape the directory fan-out already had. A webview disposing mid-fan-out now releases surface requests too, not only directory ones. The window lease's watcher fired on the holder's own heartbeat, so the holder re-ticked on every write it made. Re-ticking there is wrong regardless of how much it actually costs: only a window waiting for the lease needs the accelerator. Overlapping ticks also shared one temp filename per window, so a collision failed the rename and dropped the role; writes are now uniquely named and cycles cannot overlap. A test pins the holder to its heartbeat rate. Honest note: that test passes against the old code too — the loop did not reproduce here — so this is a correctness fix, not a measured one. Deduplication the three reviewers agreed on: `PeerSurfaceResult` and the attach/detach/resize union were declared three times and inlined four more, and the reply budget twice with a comment asking to keep them in sync. All now live once in the protocol module, which the webview can import because it has no Node dependencies. `PeerRouteTable` was a Map with four delegating methods; only `forgetPeerRoutes` had behavior, so that is all that remains. Also: dead `resetWindowLeaseForTest`, an `ack` frame nothing correlated, an unused `LeaseState.dir`, a `stopped` flag that duplicated `state !== current`, five needless exports and five identity-arrow wrappers, a doubled size read in `#beginAttach`, a rendezvous watcher left running after a window became the broker, and a nested role switch flattened. The two new extension suites now share their fixtures instead of each defining `waitFor` and a temp dir. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/lib/platform/types.ts | 15 +- lib/src/lib/platform/vscode-adapter.ts | 21 ++- lib/src/lib/vscode-peer-link-protocol.test.ts | 54 ++----- lib/src/lib/vscode-peer-link-protocol.ts | 84 +++++------ lib/src/remote/host/remote-api.ts | 3 +- vscode-ext/src/message-router.ts | 60 ++++---- vscode-ext/src/peer-link.ts | 137 ++++++++++-------- vscode-ext/src/window-lease.ts | 38 +++-- vscode-ext/test/helpers.ts | 61 ++++++++ vscode-ext/test/peer-link.test.ts | 92 ++++-------- vscode-ext/test/window-lease.test.ts | 47 +++--- 11 files changed, 324 insertions(+), 288 deletions(-) create mode 100644 vscode-ext/test/helpers.ts diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index f792098f..e89f1002 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -4,6 +4,7 @@ import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; // Defined in its own dependency-free file so the Node proxy in lib/src/host can // share it without pulling this browser-typed module into a Node tsconfig. import type { IframeProxyResult } from './iframe-proxy-types'; +import type { PeerSurfaceOp, PeerSurfaceResult } from '../vscode-peer-link-protocol'; export interface PtyInfo { id: string; @@ -112,14 +113,6 @@ export interface AgentBrowserPopResult { error?: string; } -/** What a peer webview reports back about a surface it owns. */ -export interface PeerSurfaceResult { - ok: boolean; - ptyId?: string; - cols?: number; - rows?: number; -} - /** * Reach terminals that belong to another webview of the same host window. * @@ -129,13 +122,15 @@ export interface PeerSurfaceResult { * process brokers, and this is the webview end of that. See * docs/specs/vscode.md → "Peer surfaces". */ +export type { PeerSurfaceOp, PeerSurfaceResult }; + export interface PeerBridge { /** Directory entries contributed by every other webview in this window. */ directory(): Promise; /** Drive a surface owned by another webview; `ok: false` if nobody owns it. */ surfaceOp( surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, ): Promise; @@ -147,7 +142,7 @@ export interface PeerBridge { directory: () => unknown[]; surfaceOp: ( surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, ) => PeerSurfaceResult; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index b6de05ef..09051c1f 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -15,6 +15,7 @@ import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; import type { PeerBridge } from './types'; +import { PEER_REQUEST_TIMEOUT_MS } from '../vscode-peer-link-protocol'; import { setJsonStoreBackend } from '../local-json-store'; /** @@ -24,12 +25,7 @@ import { setJsonStoreBackend } from '../local-json-store'; */ const HOST_STORE_READ_TIMEOUT_MS = 10_000; -/** - * Budget for a peer round trip. Comfortably above the broker's own - * `PEER_REPLY_BUDGET_MS`, so a slow sibling shows up as an incomplete directory - * rather than as a timeout on this side. - */ -const PEER_REQUEST_TIMEOUT_MS = 3_000; + export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; @@ -191,12 +187,15 @@ export class VSCodeAdapter implements PlatformAdapter { entries: this.peerHandlers?.directory() ?? [], }); } else if (msg.type === 'peer:surfaceRequest') { - // Only the owner answers; a miss stays silent so it cannot beat the - // real owner's reply to the broker. + // Answer either way: the broker settles once every webview has replied, + // so staying silent on a miss would make it wait out the full budget. + // Only an `ok` claims the surface, so a miss cannot beat the owner. const result = this.peerHandlers?.surfaceOp(msg.surfaceId, msg.op, msg.cols, msg.rows); - if (result?.ok) { - this.vscode.postMessage({ type: 'peer:surfaceResult', requestId: msg.requestId, ...result }); - } + this.vscode.postMessage({ + type: 'peer:surfaceResult', + requestId: msg.requestId, + ...(result ?? { ok: false }), + }); } }); } diff --git a/lib/src/lib/vscode-peer-link-protocol.test.ts b/lib/src/lib/vscode-peer-link-protocol.test.ts index a2ebd413..adba4d5b 100644 --- a/lib/src/lib/vscode-peer-link-protocol.test.ts +++ b/lib/src/lib/vscode-peer-link-protocol.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - FrameDecoder, - PeerRouteTable, - encodeFrame, -} from './vscode-peer-link-protocol'; +import { FrameDecoder, encodeFrame, forgetPeerRoutes } from './vscode-peer-link-protocol'; describe('FrameDecoder', () => { it('reads one frame per line', () => { @@ -54,42 +50,24 @@ describe('FrameDecoder', () => { }); }); -describe('PeerRouteTable', () => { - it('routes a pty to the peer that claimed it', () => { - const table = new PeerRouteTable(); - table.claim('pty-1', 'window-a'); - table.claim('pty-2', 'window-b'); - - expect(table.peerFor('pty-1')).toBe('window-a'); - expect(table.peerFor('pty-2')).toBe('window-b'); - expect(table.peerFor('pty-3')).toBeUndefined(); - }); - - it('releases a single pty', () => { - const table = new PeerRouteTable(); - table.claim('pty-1', 'window-a'); - table.release('pty-1'); - expect(table.peerFor('pty-1')).toBeUndefined(); - }); - - it('forgets every pty behind a peer that disconnected', () => { - const table = new PeerRouteTable(); - table.claim('pty-1', 'window-a'); - table.claim('pty-2', 'window-a'); - table.claim('pty-3', 'window-b'); +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(table.forgetPeer('window-a').sort()).toEqual(['pty-1', 'pty-2']); - expect(table.peerFor('pty-1')).toBeUndefined(); - expect(table.peerFor('pty-3')).toBe('window-b'); - expect(table.size).toBe(1); + 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('re-claiming moves a pty to the newer peer', () => { - const table = new PeerRouteTable(); - table.claim('pty-1', 'window-a'); - table.claim('pty-1', 'window-b'); - expect(table.peerFor('pty-1')).toBe('window-b'); - expect(table.forgetPeer('window-a')).toEqual([]); + 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); }); }); diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index ed48f035..7277ea56 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -9,10 +9,32 @@ * sockets in it: the frame shapes, the newline-delimited framing, and the table * that remembers which window a streaming PTY came from. * - * Kept pure so the protocol's edge cases (a split frame, a peer that vanishes - * mid-attach) are testable without spawning processes. + * Kept free of sockets — and of Node imports, so the webview side can share + * its types and budgets — meaning the protocol's edge cases (a split frame, a peer + * that vanishes mid-attach) are testable without spawning processes. */ +/** What the broker can ask a window to do with one of its surfaces. */ +export type PeerSurfaceOp = 'attach' | 'detach' | 'resize'; + +/** What a window reports back about a surface it owns. */ +export interface PeerSurfaceResult { + ok: boolean; + ptyId?: string; + cols?: number; + rows?: number; +} + +/** How long the broker waits for a window to answer before giving up on it. */ +export const PEER_REPLY_BUDGET_MS = 1_000; + +/** + * The webview's budget for a round trip through the broker. Must exceed + * {@link PEER_REPLY_BUDGET_MS}, or a slow sibling shows up as a timeout on the + * asking side instead of as an incomplete answer. + */ +export const PEER_REQUEST_TIMEOUT_MS = 3_000; + /** Broker → peer window. */ export type PeerLinkRequest = | { kind: 'directory'; id: string } @@ -20,7 +42,7 @@ export type PeerLinkRequest = kind: 'surfaceOp'; id: string; surfaceId: string; - op: 'attach' | 'detach' | 'resize'; + op: PeerSurfaceOp; cols?: number; rows?: number; } @@ -32,15 +54,7 @@ export type PeerLinkRequest = /** Peer window → broker. */ export type PeerLinkResponse = | { kind: 'directoryResult'; id: string; entries: unknown[] } - | { - kind: 'surfaceResult'; - id: string; - ok: boolean; - ptyId?: string; - cols?: number; - rows?: number; - } - | { kind: 'ack'; id: string } + | ({ kind: 'surfaceResult'; id: string } & PeerSurfaceResult) /** Unsolicited: bytes from a PTY the broker subscribed to. */ | { kind: 'data'; ptyId: string; data: string } /** Unsolicited: that PTY ended. */ @@ -100,40 +114,20 @@ export class FrameDecoder { } /** - * Which peer a streaming PTY belongs to. + * Drop every PTY routed to `peer`, and report what was dropped. * - * The broker learns this when an attach succeeds, and needs it afterwards to - * send input and resizes to the right window — a `ptyId` alone says nothing - * about where it lives. Entries are dropped when the peer disconnects so a - * later attach cannot be routed into a dead socket. + * 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 class PeerRouteTable { - readonly #byPty = new Map(); - - claim(ptyId: string, peer: T): void { - this.#byPty.set(ptyId, peer); - } - - release(ptyId: string): void { - this.#byPty.delete(ptyId); - } - - peerFor(ptyId: string): T | undefined { - return this.#byPty.get(ptyId); - } - - /** Forget everything routed to `peer`, and report what was dropped. */ - forgetPeer(peer: T): string[] { - const dropped: string[] = []; - for (const [ptyId, owner] of this.#byPty) { - if (owner !== peer) continue; - dropped.push(ptyId); - this.#byPty.delete(ptyId); - } - return dropped; - } - - get size(): number { - return this.#byPty.size; +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/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index dcb597f4..35015a72 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -337,8 +337,7 @@ export class RemoteApiSession { // stream is subscribed first because some PTYs repaint synchronously. // A peer owner already applied the size in its own xterm before replying, // so only the local path still has a resize to perform here. - const sized = targetSize(target); - if (sized.cols !== cols || sized.rows !== rows) { + if (current.cols !== cols || current.rows !== rows) { if (target.kind === 'local') target.entry.terminal.resize(cols, rows); else void this.#resizePeer(target, cols, rows); } else { diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index f82ef1e1..98ce098c 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -22,6 +22,11 @@ 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 { readStore, writeStore } from './remote-host-store'; +import { + PEER_REPLY_BUDGET_MS, + type PeerSurfaceOp, + type PeerSurfaceResult, +} from '../../lib/src/lib/vscode-peer-link-protocol'; import { ensureWindowLease } from './window-lease'; import { configurePeerLink, @@ -130,7 +135,7 @@ interface ActiveRouter { askSurface( requestId: string, surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, ): void; @@ -146,7 +151,6 @@ interface ActiveRouter { * host is the only party that can ask, so it brokers. See docs/specs/vscode.md * → "Peer surfaces". */ -const PEER_REPLY_BUDGET_MS = 1_000; let nextBrokerRequestId = 0; @@ -159,26 +163,21 @@ interface PendingDirectory { const peerDirectoryRequests = new Map(); interface PendingSurface { + /** Answers still outstanding, so a miss settles as fast as a hit. */ + pending: Set; settle: (result: PeerSurfaceResult) => void; timer: ReturnType; } const peerSurfaceRequests = new Map(); -export interface PeerSurfaceResult { - ok: boolean; - ptyId?: string; - cols?: number; - rows?: number; -} - // 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 brokers. configurePeerLink({ - brokerDirectory: () => brokerDirectory(), - brokerSurfaceOp: (surfaceId, op, cols, rows) => brokerSurfaceOp(surfaceId, op, cols, rows), - deliverRemotePtyData: (ptyId, data) => deliverRemotePtyData(ptyId, data), - deliverRemotePtyExit: (ptyId, code) => deliverRemotePtyExit(ptyId, code), - onProcessedPtyData: (listener) => onProcessedPtyData(listener), + brokerDirectory, + brokerSurfaceOp, + deliverRemotePtyData, + deliverRemotePtyExit, + onProcessedPtyData, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), }); @@ -191,7 +190,7 @@ configurePeerLink({ * from a webview request (the Host asking) and from a peer window's socket * (tier 2), which is why it is a plain promise rather than message plumbing. */ -export function brokerDirectory(exclude?: ActiveRouter): Promise { +function brokerDirectory(exclude?: ActiveRouter): Promise { const peers = [...activeRouters].filter((router) => router !== exclude); if (peers.length === 0) return Promise.resolve([]); @@ -220,9 +219,9 @@ export function brokerDirectory(exclude?: ActiveRouter): Promise { * Broadcast rather than tracking surfaceId ownership: a window holds a handful * of webviews, and the owner is the only one that can act anyway. */ -export function brokerSurfaceOp( +function brokerSurfaceOp( surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, exclude?: ActiveRouter, @@ -240,6 +239,7 @@ export function brokerSurfaceOp( resolve(result); }; peerSurfaceRequests.set(requestId, { + pending: new Set(peers), settle, timer: setTimeout(() => settle({ ok: false }), PEER_REPLY_BUDGET_MS), }); @@ -255,12 +255,12 @@ export function brokerSurfaceOp( * route the subscriber already expects. Only webviews that asked for this PTY * receive it, exactly as with a local subscription. */ -export function deliverRemotePtyData(ptyId: string, data: string): void { +function deliverRemotePtyData(ptyId: string, data: string): void { for (const router of activeRouters) router.deliverForeignData(ptyId, data); } /** As {@link deliverRemotePtyData}, for that PTY ending. */ -export function deliverRemotePtyExit(ptyId: string, exitCode: number): void { +function deliverRemotePtyExit(ptyId: string, exitCode: number): void { for (const router of activeRouters) router.deliverForeignExit(ptyId, exitCode); } @@ -793,11 +793,17 @@ export function attachRouter( break; } case 'peer:surfaceResult': { - // Only the owner replies `ok`; a miss from a non-owner is not an answer. - if (!msg.ok) break; - peerSurfaceRequests.get(msg.requestId)?.settle({ - ok: true, ptyId: msg.ptyId, cols: msg.cols, rows: msg.rows, - }); + const request = peerSurfaceRequests.get(msg.requestId); + if (!request) break; + if (msg.ok) { + request.settle({ ok: true, ptyId: msg.ptyId, cols: msg.cols, rows: msg.rows }); + break; + } + // Every webview answers, so "nobody owns it" settles immediately + // instead of waiting out the budget — which is the common case when the + // surface actually lives in another window. + request.pending.delete(router); + if (request.pending.size === 0) request.settle({ ok: false }); break; } case 'singleton:claim': @@ -1014,7 +1020,7 @@ export function attachRouter( askSurface( requestId: string, surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, ) { @@ -1032,6 +1038,10 @@ export function attachRouter( if (!request.pending.delete(router)) continue; if (request.pending.size === 0) request.settle(); } + for (const request of peerSurfaceRequests.values()) { + if (!request.pending.delete(router)) continue; + if (request.pending.size === 0) request.settle({ ok: false }); + } subscribedPtyIds.clear(); releaseSingletons(claimant); removeWatchedCommandListener(); diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 1b19f7d2..5fda92eb 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -20,9 +20,9 @@ * token read from that file. That is the same bar as the `dor` control socket. */ -import { randomBytes, randomUUID } from 'node:crypto'; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { createConnection, createServer, type Server, type Socket } from 'node:net'; -import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { watch, type FSWatcher } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -31,20 +31,17 @@ import type * as vscode from 'vscode'; import { FrameDecoder, - PeerRouteTable, + PEER_REPLY_BUDGET_MS, encodeFrame, + forgetPeerRoutes, + type PeerLinkHello, type PeerLinkRequest, type PeerLinkResponse, + type PeerSurfaceOp, + type PeerSurfaceResult, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; -export interface PeerSurfaceResult { - ok: boolean; - ptyId?: string; - cols?: number; - rows?: number; -} - /** * 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 @@ -55,7 +52,7 @@ export interface PeerLinkDeps { brokerDirectory(): Promise; brokerSurfaceOp( surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, ): Promise; @@ -74,8 +71,19 @@ export function configurePeerLink(next: PeerLinkDeps): void { const RENDEZVOUS_FILE = 'remote-host.peer.json'; -/** Matches the in-window fan-out budget; a window that cannot answer is skipped. */ -const PEER_REPLY_BUDGET_MS = 1_000; +/** + * Constant-time token compare, mirroring `tokenMatches` in + * `standalone/sidecar/dor-control-server.js`. That module is CommonJS and the + * shared protocol module must stay Node-free for the webview, so this is a + * deliberate second copy — but the property cannot differ: `!==` leaks the + * token byte-by-byte to a co-resident local process that can time the response. + */ +function tokenMatches(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); +} /** Backoff for a client whose broker went away before a new one took the lease. */ const RECONNECT_MS = 2_000; @@ -116,10 +124,10 @@ interface PeerClient { } let server: Server | null = null; -let serverToken = ''; -let serverSocketPath = ''; +/** Set exactly while `server` is listening; the two move together. */ +let rendezvous: Rendezvous | null = null; const clients = new Set(); -const routes = new PeerRouteTable(); +const routes = new Map(); const pendingRequests = new Map void>(); let nextRequestId = 0; @@ -166,7 +174,7 @@ export async function remoteDirectory(): Promise { */ export async function remoteSurfaceOp( surfaceId: string, - op: 'attach' | 'detach' | 'resize', + op: PeerSurfaceOp, cols?: number, rows?: number, ): Promise { @@ -177,7 +185,7 @@ export async function remoteSurfaceOp( if (reply?.kind !== 'surfaceResult' || !reply.ok) continue; // Remember where this PTY lives: a ptyId alone says nothing about which // window owns it, and input and resizes have to reach that window. - if (reply.ptyId) routes.claim(reply.ptyId, client); + if (reply.ptyId) routes.set(reply.ptyId, client); return { ok: true, ptyId: reply.ptyId, cols: reply.cols, rows: reply.rows }; } return { ok: false }; @@ -185,30 +193,30 @@ export async function remoteSurfaceOp( /** Whether this PTY is streaming from another window. */ export function isRemotePty(ptyId: string): boolean { - return routes.peerFor(ptyId) !== undefined; + return routes.get(ptyId) !== undefined; } export function remoteSubscribe(ptyId: string): void { - const client = routes.peerFor(ptyId); + const client = routes.get(ptyId); if (client) send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); } export function remoteUnsubscribe(ptyId: string): void { - const client = routes.peerFor(ptyId); + const client = routes.get(ptyId); if (!client) return; send(client, { kind: 'unsubscribe', id: `r${++nextRequestId}`, ptyId }); - routes.release(ptyId); + routes.delete(ptyId); } export function remoteWrite(ptyId: string, data: string): boolean { - const client = routes.peerFor(ptyId); + const client = routes.get(ptyId); if (!client) return false; send(client, { kind: 'write', id: `r${++nextRequestId}`, ptyId, data }); return true; } export function remoteResize(ptyId: string, cols: number, rows: number): boolean { - const client = routes.peerFor(ptyId); + const client = routes.get(ptyId); if (!client) return false; send(client, { kind: 'resizePty', id: `r${++nextRequestId}`, ptyId, cols, rows }); return true; @@ -218,7 +226,7 @@ function dropClient(client: PeerClient): void { clients.delete(client); // A window that went away takes its terminals with it; a later write must not // be routed into a dead socket. - for (const ptyId of routes.forgetPeer(client)) deps?.deliverRemotePtyExit(ptyId, 0); + for (const ptyId of forgetPeerRoutes(routes, client)) deps?.deliverRemotePtyExit(ptyId, 0); client.socket.destroy(); } @@ -228,8 +236,8 @@ function onServerFrame(client: PeerClient, frame: unknown): void { }; if (!client.authenticated) { // First frame must be the hello; anything else is not a peer of ours. - const hello = message as { kind: string; token?: string }; - if (hello.kind !== 'hello' || hello.token !== serverToken) { + const hello = message as Partial; + if (hello.kind !== 'hello' || !rendezvous || !tokenMatches(hello.token, rendezvous.token)) { log.error('[peer-link] rejected a client with a bad hello'); dropClient(client); return; @@ -244,7 +252,7 @@ function onServerFrame(client: PeerClient, frame: unknown): void { return; } if (response.kind === 'exit') { - routes.release(response.ptyId); + routes.delete(response.ptyId); deps?.deliverRemotePtyExit(response.ptyId, response.exitCode); return; } @@ -255,9 +263,8 @@ async function startServer(): Promise { const path = rendezvousPath(); if (!path || server) return; - serverToken = randomUUID(); - serverSocketPath = newSocketPath(); - await rm(serverSocketPath, { force: true }).catch(() => {}); + const next: Rendezvous = { socketPath: newSocketPath(), token: randomUUID() }; + await rm(next.socketPath, { force: true }).catch(() => {}); server = createServer((socket) => { const client: PeerClient = { socket, decoder: new FrameDecoder(), authenticated: false }; @@ -271,13 +278,16 @@ async function startServer(): Promise { }); try { - await new Promise((resolve) => server!.listen(serverSocketPath, resolve)); + rendezvous = next; + await new Promise((resolve) => server!.listen(next.socketPath, resolve)); await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); - const rendezvous: Rendezvous = { socketPath: serverSocketPath, token: serverToken }; - await writeFile(path, JSON.stringify(rendezvous), 'utf8'); // The token is the only thing standing between another local process and - // this window's terminals. - await chmod(path, 0o600).catch(() => {}); + // this window's terminals, so it is never briefly world-readable: written + // 0600 to a temp file and renamed into place, which also means a reader + // never sees a half-written rendezvous and falls into the retry backoff. + const temp = `${path}.${randomUUID()}.tmp`; + await writeFile(temp, JSON.stringify(next), { encoding: 'utf8', mode: 0o600 }); + await rename(temp, path); log.info('[peer-link] serving peers'); } catch (err) { // Started fire-and-forget from the lease callback, so a rejection here @@ -291,10 +301,12 @@ async function startServer(): Promise { async function stopServer(): Promise { if (!server) return; const path = rendezvousPath(); + const socketPath = rendezvous?.socketPath; for (const client of [...clients]) dropClient(client); server.close(); server = null; - await rm(serverSocketPath, { force: true }).catch(() => {}); + rendezvous = null; + if (socketPath) await rm(socketPath, { force: true }).catch(() => {}); if (path) await rm(path, { force: true }).catch(() => {}); } @@ -329,13 +341,11 @@ async function onClientFrame(frame: unknown): Promise { if (id === request.ptyId) respond({ kind: 'data', ptyId: id, data }); }); if (stop) forwarding.set(request.ptyId, stop); - respond({ kind: 'ack', id: request.id }); break; } case 'unsubscribe': forwarding.get(request.ptyId)?.(); forwarding.delete(request.ptyId); - respond({ kind: 'ack', id: request.id }); break; case 'write': deps?.writePty(request.ptyId, request.data); @@ -421,32 +431,43 @@ function disconnectClient(): void { export function setPeerLinkRole(isBroker: boolean): void { if (isBroker) { disconnectClient(); + stopWatchingRendezvous(); void startServer(); return; } - void stopServer().then(() => { - // Watch the rendezvous rather than only retrying: when a new window takes - // the lease it publishes a fresh socket path, and polling would make every - // handover wait out the backoff. - if (!rendezvousWatcher && context) { - const dir = context.globalStorageUri.fsPath; - try { - rendezvousWatcher = watch(dir, (_event, filename) => { - if (filename && filename !== RENDEZVOUS_FILE) return; - disconnectClient(); - void connectClient(); - }); - } catch { - // No watcher here: the reconnect timer still converges. - } - } - void connectClient(); - }); + void (async () => { + await stopServer(); + watchRendezvous(); + await connectClient(); + })(); } -export async function disposePeerLink(): Promise { +/** + * Watch the rendezvous rather than only retrying: a new broker publishes a + * fresh socket path, and polling alone would make every handover wait out the + * backoff. Only a client needs it — a broker watching would wake on its own + * writes. + */ +function watchRendezvous(): void { + if (rendezvousWatcher || !context) return; + try { + rendezvousWatcher = watch(context.globalStorageUri.fsPath, (_event, filename) => { + if (filename && filename !== RENDEZVOUS_FILE) return; + disconnectClient(); + void connectClient(); + }); + } catch { + // No watcher here: the reconnect timer still converges. + } +} + +function stopWatchingRendezvous(): void { rendezvousWatcher?.close(); rendezvousWatcher = null; +} + +export async function disposePeerLink(): Promise { + stopWatchingRendezvous(); disconnectClient(); await stopServer(); } diff --git a/vscode-ext/src/window-lease.ts b/vscode-ext/src/window-lease.ts index 163799af..b2411548 100644 --- a/vscode-ext/src/window-lease.ts +++ b/vscode-ext/src/window-lease.ts @@ -38,14 +38,14 @@ const LEASE_FILE = 'remote-host.lease.json'; const CLAIM_VERIFY_MS = 250; interface LeaseState { - dir: string; file: string; selfId: string; held: boolean; timer: ReturnType | null; watcher: FSWatcher | null; onChange: (held: boolean) => void; - stopped: boolean; + /** A cycle is in flight; overlapping them races their temp files. */ + ticking: boolean; } let state: LeaseState | null = null; @@ -70,22 +70,29 @@ async function readRecord(file: string): Promise { } } -/** Write via temp + rename so a reader never sees a half-written record. */ +/** + * Write via temp + rename so a reader never sees a half-written record. The + * temp name is unique per write, not per window: two overlapping writes sharing + * one name make the second rename fail with ENOENT. + */ async function writeRecord(current: LeaseState, record: WindowLeaseRecord): Promise { - const temp = `${current.file}.${current.selfId}.tmp`; + const temp = `${current.file}.${randomUUID()}.tmp`; await writeFile(temp, JSON.stringify(record), 'utf8'); await rename(temp, current.file); } function setHeld(current: LeaseState, held: boolean): void { - if (current.held === held || current.stopped) return; + if (current.held === held || state !== current) return; current.held = held; log.info(`[window-lease] ${held ? 'acquired' : 'released'} the remote-host role`); current.onChange(held); } async function tick(current: LeaseState): Promise { - if (current.stopped) return; + // `state !== current` is how a disposed lease stops; a separate flag would be + // a second copy of the same fact. + if (state !== current || current.ticking) return; + current.ticking = true; try { const held = await runWindowLeaseCycle( { @@ -102,6 +109,8 @@ async function tick(current: LeaseState): Promise { // run a Host this window may not own. log.error(`[window-lease] cycle failed: ${String(err)}`); setHeld(current, false); + } finally { + current.ticking = false; } } @@ -120,21 +129,20 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { const dir = context.globalStorageUri.fsPath; const current: LeaseState = { - dir, file: join(dir, LEASE_FILE), selfId: randomUUID(), held: false, timer: null, watcher: null, onChange, - stopped: false, + ticking: false, }; state = current; void (async () => { // VS Code does not create globalStorageUri until something writes to it. await mkdir(dir, { recursive: true }).catch(() => {}); - if (current.stopped) return; + if (state !== current) return; await tick(current); current.timer = setInterval(() => void tick(current), LEASE_RENEW_MS); @@ -144,6 +152,12 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { // an accelerator — correctness is the timer's job. current.watcher = watch(dir, (_event, filename) => { if (filename && filename !== LEASE_FILE) return; + // The holder's own heartbeat lands here too, and re-ticking on it turns + // the heartbeat into a write loop that re-arms itself — ~50x the + // intended I/O, with overlapping writes colliding and each failure + // dropping the role. Only a window waiting for the lease needs the + // accelerator. + if (current.held) return; void tick(current); }); } catch { @@ -167,7 +181,6 @@ export async function disposeWindowLease(): Promise { const current = state; if (!current) return; state = null; - current.stopped = true; if (current.timer) clearInterval(current.timer); current.watcher?.close(); @@ -177,8 +190,3 @@ export async function disposeWindowLease(): Promise { await unlink(current.file).catch(() => {}); } -/** Test seam: forget any running lease without touching the filesystem. */ -export function resetWindowLeaseForTest(): void { - state = null; - extensionContext = null; -} diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts new file mode 100644 index 00000000..c74cd6f8 --- /dev/null +++ b/vscode-ext/test/helpers.ts @@ -0,0 +1,61 @@ +/** + * 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 { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export async function tempStorageDir(): Promise { + return mkdtemp(join(tmpdir(), 'dormouse-ext-')); +} + +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; +} diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 28840167..272eb7b9 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -7,10 +7,9 @@ * PTY routing, and what happens when a window goes away. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { access, mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { join } from 'node:path'; +import { fakeContext, freshModule, removeDir, tempStorageDir, tick, waitFor, waitForFile } from './helpers'; type LinkModule = typeof import('../src/peer-link'); @@ -57,22 +56,21 @@ function fakeWindow(options: { } async function openWindow(deps: ReturnType): Promise { - vi.resetModules(); - const mod: LinkModule = await import('../src/peer-link'); - mod.initPeerLink({ globalStorageUri: { fsPath: dir }, subscriptions: [] } as never); + const mod = await freshModule(() => import('../src/peer-link')); + mod.initPeerLink(fakeContext(dir)); mod.configurePeerLink(deps.deps()); opened.push(mod); return mod; } -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, 20)); - } - throw new Error('timed out waiting for the peer link'); -} +const waitForRendezvous = () => waitForFile(join(dir, 'remote-host.peer.json')); + +/** 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 a broker and a peer, and wait until they can actually talk. The peer @@ -85,14 +83,7 @@ async function linkedPair( ) { const broker = await openWindow(brokerSide); broker.setPeerLinkRole(true); - await waitFor(async () => { - try { - await access(join(dir, 'remote-host.peer.json')); - return true; - } catch { - return false; - } - }); + await waitForRendezvous(); const peer = await openWindow(peerSide); peer.setPeerLinkRole(false); @@ -102,13 +93,13 @@ async function linkedPair( } beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'dormouse-peer-')); + dir = await tempStorageDir(); }); afterEach(async () => { for (const mod of opened) await mod.disposePeerLink(); opened.length = 0; - await rm(dir, { recursive: true, force: true }); + await removeDir(dir); }); describe('peer link between windows', () => { @@ -122,14 +113,7 @@ describe('peer link between windows', () => { it('returns nothing when no other window is connected', async () => { const broker = await openWindow(fakeWindow()); broker.setPeerLinkRole(true); - await waitFor(async () => { - try { - await access(join(dir, 'remote-host.peer.json')); - return true; - } catch { - return false; - } - }); + await waitForRendezvous(); expect(await broker.remoteDirectory()).toEqual([]); }); @@ -153,15 +137,12 @@ describe('peer link between windows', () => { }); it('streams a subscribed PTY from the owning window', async () => { - const peerSide = fakeWindow({ - entries: [{ surfaceId: 'far-1' }], - surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, - }); + const peerSide = farWindow(); const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); broker.remoteSubscribe('pty-far'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await tick(); peerSide.emitData('pty-far', 'output from the other window'); await waitFor(() => brokerSide.delivered.length > 0); @@ -169,32 +150,26 @@ describe('peer link between windows', () => { }); it('does not stream PTYs it never subscribed to', async () => { - const peerSide = fakeWindow({ - entries: [{ surfaceId: 'far-1' }], - surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, - }); + const peerSide = farWindow(); const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); peerSide.emitData('pty-other', 'not subscribed'); - await new Promise((resolve) => setTimeout(resolve, 100)); + await tick(100); expect(brokerSide.delivered).toEqual([]); }); it('stops the stream on unsubscribe', async () => { - const peerSide = fakeWindow({ - entries: [{ surfaceId: 'far-1' }], - surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, - }); + const peerSide = farWindow(); const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); broker.remoteSubscribe('pty-far'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await tick(); broker.remoteUnsubscribe('pty-far'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await tick(); peerSide.emitData('pty-far', 'after unsubscribe'); - await new Promise((resolve) => setTimeout(resolve, 100)); + await tick(100); expect(brokerSide.delivered).toEqual([]); // Unsubscribing also forgets the route, so a later write is not misrouted. @@ -202,10 +177,7 @@ describe('peer link between windows', () => { }); it('routes input and resize to the owning window', async () => { - const peerSide = fakeWindow({ - entries: [{ surfaceId: 'far-1' }], - surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, - }); + const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); @@ -225,10 +197,7 @@ describe('peer link between windows', () => { }); it('reports terminals as exited when their window disconnects', async () => { - const peerSide = fakeWindow({ - entries: [{ surfaceId: 'far-1' }], - surfaces: { 'far-1': { ptyId: 'pty-far', cols: 80, rows: 24 } }, - }); + const peerSide = farWindow(); const { broker, brokerSide, peer } = await linkedPair(fakeWindow(), peerSide); await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); expect(broker.isRemotePty('pty-far')).toBe(true); @@ -248,14 +217,7 @@ describe('peer link between windows', () => { const broker = await openWindow(brokerSide); broker.setPeerLinkRole(true); const rendezvousPath = join(dir, 'remote-host.peer.json'); - await waitFor(async () => { - try { - await access(rendezvousPath); - return true; - } catch { - return false; - } - }); + await waitForRendezvous(); const { readFile } = await import('node:fs/promises'); const { socketPath } = JSON.parse(await readFile(rendezvousPath, 'utf8')); diff --git a/vscode-ext/test/window-lease.test.ts b/vscode-ext/test/window-lease.test.ts index d2c44de9..87e4e116 100644 --- a/vscode-ext/test/window-lease.test.ts +++ b/vscode-ext/test/window-lease.test.ts @@ -4,10 +4,10 @@ * instances — standing in for two VS Code windows — against a real directory. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { fakeContext, freshModule, removeDir, tempStorageDir, waitFor } from './helpers'; type LeaseModule = typeof import('../src/window-lease'); @@ -16,30 +16,20 @@ const opened: LeaseModule[] = []; /** A separate module instance, so each behaves like its own extension host. */ async function openWindow(): Promise { - vi.resetModules(); - const mod: LeaseModule = await import('../src/window-lease'); - mod.initWindowLease({ globalStorageUri: { fsPath: dir }, subscriptions: [] } as never); + const mod = await freshModule(() => import('../src/window-lease')); + mod.initWindowLease(fakeContext(dir)); opened.push(mod); return mod; } -async function waitFor(predicate: () => boolean, budgetMs = 3_000): Promise { - const deadline = Date.now() + budgetMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error('timed out waiting for the lease'); -} - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'dormouse-lease-')); + dir = await tempStorageDir(); }); afterEach(async () => { for (const mod of opened) await mod.disposeWindowLease(); opened.length = 0; - await rm(dir, { recursive: true, force: true }); + await removeDir(dir); }); describe('window lease over a real directory', () => { @@ -104,9 +94,28 @@ describe('window lease over a real directory', () => { expect(seen).toEqual([true]); }); + it('does not let its own heartbeat re-trigger itself', async () => { + const window = await openWindow(); + window.ensureWindowLease(() => {}); + await waitFor(() => window.holdsWindowLease()); + + // The directory watcher sees the holder's own rename. Re-ticking on that + // turns the 5s heartbeat into a write loop that re-arms itself, and the + // colliding writes drop the role on each failure. + const stamps = new Set(); + for (let i = 0; i < 15; i++) { + const record = JSON.parse(await readFile(join(dir, 'remote-host.lease.json'), 'utf8')); + stamps.add(record.heartbeatAt); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // 1.5s at a 5s renew is one write, maybe two across a boundary. + expect(stamps.size).toBeLessThanOrEqual(2); + expect(window.holdsWindowLease()).toBe(true); + }); + it('does nothing before it is told where to store the record', async () => { - vi.resetModules(); - const mod: LeaseModule = await import('../src/window-lease'); + const mod = await freshModule(() => import('../src/window-lease')); opened.push(mod); mod.ensureWindowLease(() => {}); await new Promise((resolve) => setTimeout(resolve, 100)); From 23a7bb3b614d7acb756ed95564269b89a92cad98 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:01:39 -0700 Subject: [PATCH 12/56] Collapse the peer RPC into one generic seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `directory` and `surfaceOp` were each declared three times — in `PeerBridge`, in the webview↔extension message union, and in the cross-window frames — and implemented six. Adding a third peer operation meant editing nine files, none of it about the new operation. The tell was `PeerBridge.directory(): Promise< unknown[]>`: the platform layer was transporting a `DirectoryEntry[]` it refused to name, because naming it would have proved the operation does not belong there. There is now one operation: `(op, params)` in, zero or more results back. `op` is opaque to the adapter, to the extension-host broker, and to the socket, because *what* a peer may be asked belongs to the remote Host and not to any transport. The map with the real types — `directory` and `surfaceOp`, their params and results — lives in `remote/host/peer-surfaces.ts` next to the responder that answers them. A new operation is one entry there plus its caller. Absence is the miss. A webview that owns nothing the request named answers with no results, which deletes the `ok` flag from three layers and makes every field of a result that does arrive required, instead of an optional the caller had to `?? 0` its way past. The broker gains one fan-out rule where it had two, and the in-window and cross-window tiers are now asked at once rather than in series: what is asked about lives in exactly one place, so serial asking only meant paying a hung tier's budget before reaching the tier that owns the answer. The one thing a transport still reads out of an answer is a `ptyId`, and that is named as such (`routedPtyId`) rather than left implicit in a surface-op branch: an answer claiming a PTY is the only way the cross-window broker can learn which window that PTY lives in, and every later write, resize, and subscribe depends on knowing. `claimSingleton` and `peers` also collapse into one optional member. They carried near-identical doc comments — "only hosts that can show several webviews over one backend" — because they are two facets of one precondition, and nothing stopped a host implementing half of it. Now a host either has peers to elect among and ask, or it has neither. Subscribing returns its own unsubscribe, so a caller cannot leak a stream by losing the id it opened one with. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/transport.md | 2 +- docs/specs/vscode.md | 22 +- lib/src/lib/platform/types.ts | 80 ++++---- lib/src/lib/platform/vscode-adapter.ts | 101 +++++---- lib/src/lib/vscode-peer-link-protocol.test.ts | 42 +++- lib/src/lib/vscode-peer-link-protocol.ts | 54 ++--- lib/src/remote/host/activation.test.ts | 5 +- lib/src/remote/host/activation.ts | 16 +- lib/src/remote/host/peer-surfaces.test.ts | 27 ++- lib/src/remote/host/peer-surfaces.ts | 101 +++++++-- lib/src/remote/host/remote-api.ts | 46 +++-- vscode-ext/src/message-router.ts | 193 +++++------------- vscode-ext/src/message-types.ts | 15 +- vscode-ext/src/peer-link.ts | 92 ++++----- vscode-ext/test/peer-link.test.ts | 45 ++-- 15 files changed, 436 insertions(+), 405 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index dc2aa33e..47584bf1 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -95,7 +95,7 @@ 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. -Host-owned storage and single-instance roles are VS Code-only additions to the adapter surface, both optional on `PlatformAdapter`. `hydrateScopedStore(prefix)` (`store:read` → `store:entries`, then fire-and-forget `store:write`) moves every key under one prefix into extension-host storage and installs a synchronous write-through cache over it, because `local-json-store` is synchronous by contract and the remote Host's bearer credential must not sit in webview `localStorage`. `claimSingleton(name, onChange)` (`singleton:claim` → `singleton:lease`) asks the host to arbitrate a role that at most one webview may hold, since only the extension host sees every webview. Adapters that omit either are single-instance with local storage, which is correct for standalone and the website. Both are prefix/name gated on the host side — the webview names the key, so the host decides what that name may reach. See `docs/specs/vscode.md` → "Remote Host: store and lease". +Host-owned storage and peer coordination are VS Code-only additions to the adapter surface, both optional on `PlatformAdapter`. `hydrateScopedStore(prefix)` (`store:read` → `store:entries`, then fire-and-forget `store:write`) moves every key under one prefix into extension-host storage and installs a synchronous write-through cache over it, because `local-json-store` is synchronous by contract and the remote Host's bearer credential must not sit in webview `localStorage`. `peers` is present only on a host that can show several webviews over one backend, and carries both halves of that condition: `claimSingleton(name, onChange)` (`singleton:claim` → `singleton:lease`) asks the host to arbitrate a role that at most one webview may hold, since only the extension host sees every webview, and a generic `request` / `respond` / `streamPty` seam reaches surfaces the other webviews own (`docs/specs/vscode.md` → "Peer surfaces"). Adapters that omit either are single-instance with local storage, which is correct for standalone and the website. Both are prefix/name gated on the host side — the webview names the key, so the host decides what that name may reach. See `docs/specs/vscode.md` → "Remote Host: store and lease". 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`). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index d263023f..f6c289a3 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -262,7 +262,7 @@ Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vs **The lease.** A window can show a `WebviewView` and any number of `WebviewPanel`s at once. Each mounts the same Wall, so each would start its own `RemoteHost` against the same enrollment — they would displace each other on the single `/ws/host` socket (`server/test/relay-displaced.test.mjs`) and each would arm its own alarm push. The extension host arbitrates instead, because it is the only party that sees every webview and outlives each one: `message-router.ts` grants the named role `remote-host` to the first claimant and re-offers it when the holder is disposed, so closing the Dormouse view hands the Host to another open one rather than dropping it until a reload. -On the webview side `activation.ts` starts un-owned whenever the adapter offers `claimSingleton`, so two webviews racing to mount cannot both activate before the first answer arrives. Adapters without the hook (standalone, the website) are single-instance and stay owned from the start. +On the webview side `activation.ts` starts un-owned whenever the adapter offers `peers`, so two webviews racing to mount cannot both activate before the first answer arrives. Adapters without it (standalone, the website) are single-instance and stay owned from the start. Having peers at all is exactly the condition that needs arbitrating, which is why the role lease and the sibling RPC hang off one optional member (`PeerBridge`) rather than two that a host could implement half of. **Across windows.** The election above is per-window, because the extension host is — but the enrollment it guards is machine-wide, so window-local arbitration alone is not enough. Left there, every window would elect its own Host, all of them would connect `/ws/host` with the same enrollment, 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. @@ -276,7 +276,7 @@ Nothing here starts until a webview first claims `remote-host`, so a user who ne Source of truth: the rules and the cycle in `lib/src/lib/vscode-window-lease.ts` (tested in `lib/src/lib/vscode-window-lease.test.ts`), the filesystem and timers around them in `vscode-ext/src/window-lease.ts`, and `windowLeaseHeld` gating `electSingleton` in `vscode-ext/src/message-router.ts`. -Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PlatformAdapter.claimSingleton`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. +Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PeerBridge.claimSingleton` in `lib/src/lib/platform/types.ts`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. **Lifetime.** The Host lives as long as a Dormouse webview exists in the window. `retainContextWhenHidden: true` is set on both hosting modes, so hiding the panel keeps it connected; only disposing every Dormouse view, or closing the window, takes it offline. @@ -292,9 +292,17 @@ The extension host brokers, since it is the only party that can see every webvie Every webview installs a responder (`lib/src/remote/host/peer-surfaces.ts`) whether or not it is the Host, so its terminals are reachable from whichever one is. It carries none of the relay, enrollment, or pairing machinery — a registry lookup, the directory collector, and a resize. +**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. + +Absence *is* the miss: 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, which is what lets the broker settle a fan-out as fast on a miss as on a hit; it settles when all of them have replied or a 1s budget expires, so a webview with no live content cannot hang the picker. + +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. + `attach` and `resize` on a foreign surface go to the owner rather than to the PTY, because attach-is-the-resize has to drive the live xterm or the owning pane's own view drifts from the size the phone set. The owner replies with the size it settled at and the `ptyId`; the Host then subscribes and streams. `detach` has nothing to undo on the owner — the Host stops streaming and the pane keeps its size, which is what last-attach-wins means. -The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. The broker settles a fan-out when every webview has replied or a 1s budget expires, so a webview with no live content cannot hang the picker. +Subscribing is a subscription, not a pair of calls: `peers.streamPty(ptyId)` returns its own unsubscribe, so a caller cannot leak a stream by losing track of the id it opened it with. + +The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. ### Peer surfaces across windows @@ -304,15 +312,17 @@ The lease makes this one-directional. Because the webview lease is gated on the Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. -A peer window answers a `directory` or `surfaceOp` frame by running its **own in-window** fan-out — never the cross-window one, or a request would loop back out. That is why `configurePeerLink` is handed only `brokerDirectory` / `brokerSurfaceOp`, and why the link is injected with what it needs rather than importing the router (which imports the link). +A peer 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 `configurePeerLink` is handed only `brokerRequest`, and why the link is injected with what it needs rather than importing the router (which imports the link). + +Both tiers are asked at once rather than one after the other: what is asked about lives in exactly one webview of one window, and asking in series would pay a whole tier's budget — or a hung window's — before reaching the tier that owns it. -Once an attach succeeds the broker records which window owns that `ptyId`, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `pty:input` and `pty:resize` consult that table first and fall back to the local `ptyManager`; `pty:subscribe` asks the owning window to start streaming, and its bytes are injected into the subscriber's normal `pty:data` path, so the Host webview cannot tell a remote terminal from a local one. When a peer disconnects, every PTY routed to it is dropped and reported as exited — a terminal in a closed window is gone, and a later write must not be posted into a dead socket. +Once an answer names a `ptyId` the broker records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `pty:input` and `pty:resize` consult that table first and fall back to the local `ptyManager`; `pty:subscribe` asks the owning window to start streaming, and its bytes are injected into the subscriber's normal `pty:data` path, so the Host webview cannot tell a remote terminal from a local one. When a peer disconnects, every PTY routed to it is dropped and reported as exited — a terminal in a closed window is gone, and a later write must not be posted into a dead socket. Trust: the socket is user-owned, its path is published only in a mode-0600 file, and a client's first frame must carry the token from that file — the same bar as the `dor` control socket. Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and roles, `lib/src/lib/vscode-peer-link-protocol.ts` for the frames, framing, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`), and the `remote*` calls in `vscode-ext/src/message-router.ts`. -Source of truth: the broker in `vscode-ext/src/message-router.ts` (`peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the responder in `lib/src/remote/host/peer-surfaces.ts`, and the foreign-surface path in `remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. +Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, and the foreign-surface path in `remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. ### Testing the extension host diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index e89f1002..3e13944d 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -4,7 +4,6 @@ import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; // Defined in its own dependency-free file so the Node proxy in lib/src/host can // share it without pulling this browser-typed module into a Node tsconfig. import type { IframeProxyResult } from './iframe-proxy-types'; -import type { PeerSurfaceOp, PeerSurfaceResult } from '../vscode-peer-link-protocol'; export interface PtyInfo { id: string; @@ -114,39 +113,50 @@ export interface AgentBrowserPopResult { } /** - * Reach terminals that belong to another webview of the same host window. + * Coordination between the several webviews one host backend can show + * (docs/specs/vscode.md → "Peer surfaces"). * * The remote Host runs in exactly one webview, but a window's terminals are * spread across all of them and each webview has its own xterm registry. The * Host therefore cannot list or drive a sibling's pane directly; the host - * process brokers, and this is the webview end of that. See - * docs/specs/vscode.md → "Peer surfaces". + * process brokers, and this is the webview end of that. + * + * Arbitrating a single-holder role and asking a sibling a question are two + * facets of one precondition — being able to show more than one webview over + * one backend — so they sit behind one optional member rather than two. A host + * either has peers to elect among and ask, or it has neither: standalone and + * the website are one webview per app, so they omit this and callers treat + * themselves as the only instance. + * + * `op` is deliberately opaque here. *What* a peer can be asked is a property of + * the remote Host, not of the platform, so the operation map and its real types + * live in `lib/src/remote/host/peer-surfaces.ts`; this layer, the extension-host + * broker, and the cross-window link only carry the bytes. */ -export type { PeerSurfaceOp, PeerSurfaceResult }; - export interface PeerBridge { - /** Directory entries contributed by every other webview in this window. */ - directory(): Promise; - /** Drive a surface owned by another webview; `ok: false` if nobody owns it. */ - surfaceOp( - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, - ): Promise; - /** Start/stop receiving `pty:data` for a PTY this webview does not own. */ - subscribePty(id: string): void; - unsubscribePty(id: string): void; - /** Answer the broker on behalf of this webview's own surfaces. */ - serve(handlers: { - directory: () => unknown[]; - surfaceOp: ( - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, - ) => PeerSurfaceResult; - }): void; + /** + * Claim a named role that at most one webview may hold, and be told whenever + * the claim is granted or revoked. The host arbitrates, because it is the + * only party that sees every webview and outlives each one. + */ + claimSingleton(name: string, onChange: (held: boolean) => void): void; + + /** + * Put `op` to every peer and collect what they answer. Each peer contributes + * zero or more results, so an empty array means nobody owned what was asked + * about — there is no separate miss signal. + */ + request(op: 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; + + /** + * Start receiving `pty:data` / `pty:exit` for a PTY this webview does not + * own, and return the unsubscribe. A subscription, not a pair of calls, so + * the caller cannot leak one by forgetting the id it used. + */ + streamPty(ptyId: string): () => void; } export interface PlatformAdapter { @@ -166,16 +176,10 @@ export interface PlatformAdapter { hydrateScopedStore?(prefix: string): Promise; /** - * Claim a named role that at most one app instance may hold, and be told - * whenever the claim is granted or revoked. Optional: only hosts that can - * show several webviews over one backend implement it (VS Code). Adapters - * that omit it are single-instance, so callers treat the role as held. - */ - claimSingleton?(name: string, onChange: (held: boolean) => void): void; - - /** - * Reach surfaces owned by sibling webviews. Optional: only a host that can - * show several webviews over one backend has peers at all. + * Elect among, and reach surfaces owned by, sibling webviews. Optional: only + * a host that can show several webviews over one backend has peers at all + * (VS Code). Adapters that omit it are single-instance, so callers hold every + * role and have nobody to ask. */ peers?: PeerBridge; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 09051c1f..596c53de 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -178,23 +178,15 @@ export class VSCodeAdapter implements PlatformAdapter { this.singletonHandlers.get(msg.name)?.(!!msg.held); } else if (msg.type === 'store:changed') { this.applyStoreChange(msg.key, msg.value ?? null); - } else if (msg.type === 'peer:directoryRequest') { - // Answer even with no handler installed: the broker waits for every - // webview, so silence would stall the asker until its budget expires. + } else if (msg.type === 'peer:ask') { + // Answer even with no responder installed, and even to say nothing: the + // broker settles once every webview 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. this.vscode.postMessage({ - type: 'peer:directoryEntries', + type: 'peer:answer', requestId: msg.requestId, - entries: this.peerHandlers?.directory() ?? [], - }); - } else if (msg.type === 'peer:surfaceRequest') { - // Answer either way: the broker settles once every webview has replied, - // so staying silent on a miss would make it wait out the full budget. - // Only an `ok` claims the surface, so a miss cannot beat the owner. - const result = this.peerHandlers?.surfaceOp(msg.surfaceId, msg.op, msg.cols, msg.rows); - this.vscode.postMessage({ - type: 'peer:surfaceResult', - requestId: msg.requestId, - ...(result ?? { ok: false }), + results: this.peerResponders.get(msg.op)?.(msg.params) ?? [], }); } }); @@ -236,55 +228,54 @@ export class VSCodeAdapter implements PlatformAdapter { } /** - * Ask the extension host for a named single-instance role and report every - * grant/revoke. The extension host is the arbiter because it is the only - * thing that outlives and sees all of this window's webviews; it re-offers - * the role when the holder is disposed, so closing the Dormouse view hands - * the Host to another open one rather than dropping it until reload. - */ - /** - * Reach terminals owned by sibling webviews, brokered by the extension host - * (docs/specs/vscode.md → "Peer surfaces"). Present unconditionally: every - * webview both asks (when it is the Host) and answers (for its own panes). + * Elect among, and reach terminals owned by, sibling webviews — both brokered + * by the extension host (docs/specs/vscode.md → "Peer surfaces"). Present + * unconditionally: every webview both asks (when it is the Host) and answers + * (for its own panes). */ readonly peers: PeerBridge = { - directory: async () => { - const entries = await this.requestResponse( - 'peer:directory', - 'peer:directoryResult', - {}, - (msg) => msg.entries as unknown[], - PEER_REQUEST_TIMEOUT_MS, - ); - return entries ?? []; + /** + * Ask the extension host for a named single-instance role and report every + * grant/revoke. The extension host is the arbiter because it is the only + * thing that outlives and sees all of this window's webviews; it re-offers + * the role when the holder is disposed, so closing the Dormouse view hands + * the Host to another open one rather than dropping it until reload. + */ + claimSingleton: (name, onChange) => { + // One entry per role, dispatched from the constructor's authenticated + // listener: re-claiming (a React effect remounting, StrictMode's double + // mount) replaces the handler instead of stacking another listener on the + // busiest message path in the app. + this.singletonHandlers.set(name, onChange); + this.vscode.postMessage({ type: 'singleton:claim', name }); }, - surfaceOp: async (surfaceId, op, cols, rows) => { - const result = await this.requestResponse( - 'peer:surfaceOp', - 'peer:surfaceOpResult', - { surfaceId, op, cols, rows }, - (msg) => ({ ok: !!msg.ok, ptyId: msg.ptyId, cols: msg.cols, rows: msg.rows }), + request: async (op, params) => { + const results = await this.requestResponse( + 'peer:request', + 'peer:results', + { op, params }, + (msg) => msg.results as unknown[], PEER_REQUEST_TIMEOUT_MS, ); - return result ?? { ok: false }; + // A timeout reads as "nobody answered", which is what a miss looks like + // anyway — the caller has no repair to make either way. + return results ?? []; + }, + respond: (op, handler) => { + this.peerResponders.set(op, handler); }, - subscribePty: (id) => this.vscode.postMessage({ type: 'pty:subscribe', id }), - unsubscribePty: (id) => this.vscode.postMessage({ type: 'pty:unsubscribe', id }), - serve: (handlers) => { - this.peerHandlers = handlers; + streamPty: (ptyId) => { + this.vscode.postMessage({ type: 'pty:subscribe', id: ptyId }); + let live = true; + return () => { + if (!live) return; + live = false; + this.vscode.postMessage({ type: 'pty:unsubscribe', id: ptyId }); + }; }, }; - private peerHandlers: Parameters[0] | null = null; - - claimSingleton(name: string, onChange: (held: boolean) => void): void { - // One entry per role, dispatched from the constructor's authenticated - // listener: re-claiming (a React effect remounting, StrictMode's double - // mount) replaces the handler instead of stacking another listener on the - // busiest message path in the app. - this.singletonHandlers.set(name, onChange); - this.vscode.postMessage({ type: 'singleton:claim', name }); - } + private peerResponders = new Map unknown[]>(); /** * Pull every `prefix`-scoped value out of extension-host storage and install diff --git a/lib/src/lib/vscode-peer-link-protocol.test.ts b/lib/src/lib/vscode-peer-link-protocol.test.ts index adba4d5b..22745ec2 100644 --- a/lib/src/lib/vscode-peer-link-protocol.test.ts +++ b/lib/src/lib/vscode-peer-link-protocol.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { FrameDecoder, encodeFrame, forgetPeerRoutes } from './vscode-peer-link-protocol'; +import { FrameDecoder, encodeFrame, forgetPeerRoutes, routedPtyId } from './vscode-peer-link-protocol'; describe('FrameDecoder', () => { it('reads one frame per line', () => { const decoder = new FrameDecoder(); const frames = decoder.push( - encodeFrame({ kind: 'directory', id: 'a' }) + encodeFrame({ kind: 'ack', id: 'b' }), + encodeFrame({ kind: 'request', id: 'a', op: 'directory', params: {} }) + + encodeFrame({ kind: 'result', id: 'b', results: [] }), ); expect(frames).toEqual([ - { kind: 'directory', id: 'a' }, - { kind: 'ack', id: 'b' }, + { kind: 'request', id: 'a', op: 'directory', params: {} }, + { kind: 'result', id: 'b', results: [] }, ]); }); @@ -26,15 +27,19 @@ describe('FrameDecoder', () => { it('holds a trailing partial frame until its newline arrives', () => { const decoder = new FrameDecoder(); - const whole = encodeFrame({ kind: 'ack', id: 'a' }); - expect(decoder.push(`${whole}{"kind":"ack","id":`)).toEqual([{ kind: 'ack', id: 'a' }]); - expect(decoder.push('"b"}\n')).toEqual([{ kind: 'ack', id: 'b' }]); + 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: 'ack', id: 'a' })}`); - expect(frames).toEqual([{ kind: 'ack', id: 'a' }]); + 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', () => { @@ -46,7 +51,24 @@ describe('FrameDecoder', () => { const decoder = new FrameDecoder(64); expect(decoder.push('x'.repeat(100))).toEqual([]); // The buffer was reset, so a well-formed frame still gets through after. - expect(decoder.push(encodeFrame({ kind: 'ack', id: 'a' }))).toEqual([{ kind: 'ack', id: 'a' }]); + 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(); }); }); diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index 7277ea56..24e7a4bc 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -14,17 +14,6 @@ * that vanishes mid-attach) are testable without spawning processes. */ -/** What the broker can ask a window to do with one of its surfaces. */ -export type PeerSurfaceOp = 'attach' | 'detach' | 'resize'; - -/** What a window reports back about a surface it owns. */ -export interface PeerSurfaceResult { - ok: boolean; - ptyId?: string; - cols?: number; - rows?: number; -} - /** How long the broker waits for a window to answer before giving up on it. */ export const PEER_REPLY_BUDGET_MS = 1_000; @@ -35,17 +24,17 @@ export const PEER_REPLY_BUDGET_MS = 1_000; */ export const PEER_REQUEST_TIMEOUT_MS = 3_000; -/** Broker → peer window. */ +/** + * 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. + */ export type PeerLinkRequest = - | { kind: 'directory'; id: string } - | { - kind: 'surfaceOp'; - id: string; - surfaceId: string; - op: PeerSurfaceOp; - cols?: number; - rows?: number; - } + | { kind: 'request'; id: string; op: string; params: unknown } | { kind: 'subscribe'; id: string; ptyId: string } | { kind: 'unsubscribe'; id: string; ptyId: string } | { kind: 'write'; id: string; ptyId: string; data: string } @@ -53,8 +42,12 @@ export type PeerLinkRequest = /** Peer window → broker. */ export type PeerLinkResponse = - | { kind: 'directoryResult'; id: string; entries: unknown[] } - | ({ kind: 'surfaceResult'; id: string } & PeerSurfaceResult) + /** + * 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[] } /** Unsolicited: bytes from a PTY the broker subscribed to. */ | { kind: 'data'; ptyId: string; data: string } /** Unsolicited: that PTY ended. */ @@ -113,6 +106,21 @@ export class FrameDecoder { } } +/** + * 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. * diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index a3e90163..f8b4f580 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -45,8 +45,11 @@ vi.mock('./enrollment', () => ({ })); let claimSingleton: ((name: string, onChange: (held: boolean) => void) => void) | undefined; +// A host with peers is exactly a host that arbitrates the role, so the lease +// arrives through the same optional member (`PeerBridge`); no peers means +// single-instance. vi.mock('../../lib/platform', () => ({ - getPlatform: () => ({ claimSingleton }), + getPlatform: () => ({ peers: claimSingleton ? { claimSingleton } : undefined }), })); async function freshModule() { diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 1dcc7d98..d68362b7 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -121,15 +121,15 @@ function remoteHostStatus(): RemoteHostConsoleStatus { /** Install the `window.dormouseRemoteHost` console hook and activate. Idempotent. */ export function installRemoteHostConsoleHook(): void { - // A host that can show several webviews arbitrates which one is the Host. - // Start un-owned so two webviews racing to mount cannot both activate before - // the first lease answer arrives, and let the grant do the activating. - // Called through the platform object, never a detached reference — the - // adapter's methods are `this`-bound to their message channel. - const platform = getPlatform(); - if (platform.claimSingleton) { + // A host that can show several webviews arbitrates which one is the Host — + // having peers at all is exactly the condition that needs arbitrating, which + // is why one member answers both. Start un-owned so two webviews racing to + // mount cannot both activate before the first lease answer arrives, and let + // the grant do the activating. + const peers = getPlatform().peers; + if (peers) { owned = false; - platform.claimSingleton('remote-host', setRemoteHostOwnership); + peers.claimSingleton('remote-host', setRemoteHostOwnership); } else { activateRemoteHost(); } diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 24e1e4a2..457874e9 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -36,21 +36,30 @@ class PeerPlatform { peerSurfaces = new Map(); peerEntries: unknown[] = []; + /** + * One generic seam: `op` is opaque to the adapter, and a peer answers with + * zero or more results — none of them meaning nobody owns it. + */ readonly peers = { - directory: async () => this.peerEntries, - surfaceOp: async (surfaceId: string, op: 'attach' | 'detach' | 'resize', cols?: number, rows?: number) => { - this.ops.push({ surfaceId, op, cols, rows }); + claimSingleton: () => {}, + request: async (op: string, params: unknown) => { + if (op === 'directory') return this.peerEntries; + const { surfaceId, op: surfaceOp, cols, rows } = + params as { surfaceId: string; op: string; cols?: number; rows?: number }; + this.ops.push({ surfaceId, op: surfaceOp, cols, rows }); const surface = this.peerSurfaces.get(surfaceId); - if (!surface) return { ok: false }; - if (op !== 'detach' && cols && rows) { + if (!surface) return []; + if (surfaceOp !== 'detach' && cols && rows) { surface.cols = cols; surface.rows = rows; } - return { ok: true, ptyId: surface.ptyId, cols: surface.cols, rows: surface.rows }; + return [{ ptyId: surface.ptyId, cols: surface.cols, rows: surface.rows }]; + }, + respond: () => {}, + streamPty: (id: string) => { + this.subscribed.push(id); + return () => void this.unsubscribed.push(id); }, - subscribePty: (id: string) => void this.subscribed.push(id), - unsubscribePty: (id: string) => void this.unsubscribed.push(id), - serve: () => {}, }; onPtyData(handler: DataHandler): void { diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index dcb337e8..274ccfaa 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -1,10 +1,19 @@ /** - * The responder half of peer surfaces (docs/specs/vscode.md → "Peer surfaces"). + * What one webview may ask its peers, and the answers it gives back + * (docs/specs/vscode.md → "Peer surfaces"). * * The remote Host runs in one webview, but a window's terminals are spread * across all of them and each webview has its own xterm registry. So *every* - * webview installs this, not just the Host's: it answers the broker's questions - * about the panes this webview owns, and drives them when the Host asks. + * webview installs the responder here, not just the Host's: it answers the + * broker's questions about the panes this webview owns, and drives them when + * the Host asks. + * + * This is also the one place the peer operations have real types. The platform + * adapter, the extension-host broker, and the cross-window socket all treat + * `op` as opaque, because *what* a peer 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 @@ -12,12 +21,75 @@ * reachable from one that is. */ -import { clampTerminalDimension } from 'server-lib-common'; +import { clampTerminalDimension, type DirectoryEntry } from 'server-lib-common'; import { getPlatform } from '../../lib/platform'; -import type { PeerSurfaceResult } from '../../lib/platform/types'; import { registry } from '../../lib/terminal-store'; import { collectDirectorySnapshot } from './directory-collect'; +/** What the Host can ask the owner of a surface to do with it. */ +export type PeerSurfaceOp = 'attach' | 'detach' | '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 `lib/src/lib/vscode-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 }; +} + +/** Put `op` to every peer and collect their answers; empty means nobody owns it. */ +async function askPeers( + op: K, + params: PeerOps[K]['params'], +): Promise { + const peers = getPlatform().peers; + if (!peers) return []; + return (await peers.request(op, params)) as PeerOps[K]['result'][]; +} + +/** Answer `op` for this webview's own surfaces. No-op where there are no peers. */ +function answerPeers( + op: K, + handler: (params: PeerOps[K]['params']) => PeerOps[K]['result'][], +): void { + getPlatform().peers?.respond(op, (params) => handler(params as PeerOps[K]['params'])); +} + +/** Directory entries contributed by every other webview and window. */ +export function peerDirectory(): Promise { + return askPeers('directory', {}); +} + +/** Drive a surface someone else owns; `null` if nobody does. */ +export async function peerSurfaceOp(params: PeerSurfaceParams): Promise { + // Surface ids are unique across webviews, so at most one peer answers. + const [owner] = await askPeers('surfaceOp', params); + return owner ?? null; +} + /** * Drive one of this webview's own surfaces on the Host's behalf. * @@ -28,18 +100,13 @@ import { collectDirectorySnapshot } from './directory-collect'; * streaming on its side, and the pane keeps whatever size it was left at, which * is what last-attach-wins means. */ -function surfaceOp( - surfaceId: string, - op: 'attach' | 'detach' | 'resize', - cols?: number, - rows?: number, -): PeerSurfaceResult { +function driveOwnSurface({ surfaceId, op, cols, rows }: PeerSurfaceParams): PeerSurfaceResult[] { const entry = registry.get(surfaceId); - if (!entry) return { ok: false }; + if (!entry) return []; const term = entry.terminal; if (op === 'detach') { - return { ok: true, ptyId: entry.ptyId, cols: term.cols, rows: term.rows }; + return [{ ptyId: entry.ptyId, cols: term.cols, rows: term.rows }]; } const nextCols = clampTerminalDimension(cols, term.cols); @@ -47,7 +114,7 @@ function surfaceOp( if (term.cols !== nextCols || term.rows !== nextRows) { term.resize(nextCols, nextRows); } - return { ok: true, ptyId: entry.ptyId, cols: term.cols, rows: term.rows }; + return [{ ptyId: entry.ptyId, cols: term.cols, rows: term.rows }]; } /** @@ -55,8 +122,6 @@ function surfaceOp( * Idempotent, and a no-op on hosts with no peers (standalone, the website). */ export function installPeerSurfaceResponder(): void { - getPlatform().peers?.serve({ - directory: () => collectDirectorySnapshot(), - surfaceOp, - }); + answerPeers('directory', () => collectDirectorySnapshot()); + answerPeers('surfaceOp', driveOwnSurface); } diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 35015a72..50561a21 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -40,6 +40,7 @@ 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 { peerDirectory, peerSurfaceOp } from './peer-surfaces'; /** Coalesce window for directory re-snapshots (remote-api.md: "Host coalesces"). */ const DIRECTORY_DEBOUNCE_MS = 150; @@ -72,6 +73,8 @@ interface Attachment { subId: string; onData: (detail: { id: string; data: string }) => void; onExit: (detail: { id: string; exitCode: number }) => void; + /** Stops the peer stream; absent for a pane this webview owns. */ + stopStream: (() => void) | null; /** Pending same-size repaint bounce (see FORCE_REPAINT_BOUNCE_MS), if any. */ bounceTimer: ReturnType | null; } @@ -215,8 +218,7 @@ export class RemoteApiSession { if (this.#directorySubId === null) return; const subId = this.#directorySubId; const local = collectDirectorySnapshot(); - const peers = getPlatform().peers; - if (!peers) { + if (!getPlatform().peers) { this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: local }); return; } @@ -225,7 +227,7 @@ export class RemoteApiSession { // surfaces"). Emit twice rather than delaying the local panes behind a // round trip: the phone renders what is here immediately, then fills in. this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: local }); - void peers.directory().then((remote) => { + void peerDirectory().then((remote) => { // The subscription may have been replaced or torn down while we waited. if (this.#directorySubId !== subId || remote.length === 0) return; this.#event(subId, REMOTE_EVENTS.directorySnapshot, { @@ -243,7 +245,7 @@ export class RemoteApiSession { const entry = registry.get(params.surfaceId); if (entry) { - this.#beginAttach(request, params, { kind: 'local', entry }, entry.ptyId); + this.#beginAttach(request, params, { kind: 'local', entry }, entry.ptyId, null); return; } @@ -255,22 +257,23 @@ export class RemoteApiSession { this.#fail(request, `no such surface: ${params.surfaceId}`); return; } - void peers.surfaceOp(params.surfaceId, 'attach', params.cols, params.rows).then((result) => { - if (!result.ok || !result.ptyId) { + void peerSurfaceOp({ + surfaceId: params.surfaceId, + op: 'attach', + cols: params.cols, + rows: params.rows, + }).then((owner) => { + if (!owner) { this.#fail(request, `no such surface: ${params.surfaceId}`); return; } - peers.subscribePty(result.ptyId); + const stopStream = peers.streamPty(owner.ptyId); this.#beginAttach( request, params, - { - kind: 'peer', - surfaceId: params.surfaceId, - cols: result.cols ?? 0, - rows: result.rows ?? 0, - }, - result.ptyId, + { kind: 'peer', surfaceId: params.surfaceId, cols: owner.cols, rows: owner.rows }, + owner.ptyId, + stopStream, ); }); } @@ -280,6 +283,7 @@ export class RemoteApiSession { params: AttachParams, target: SurfaceTarget, ptyId: string, + stopStream: (() => void) | null, ): void { // v1: one attachment per session — replace any prior stream. this.#teardownAttachment(); @@ -327,6 +331,7 @@ export class RemoteApiSession { subId, onData, onExit, + stopStream, bounceTimer: null, }; this.#attachment = attachment; @@ -419,11 +424,10 @@ export class RemoteApiSession { cols: number, rows: number, ): Promise<{ cols: number; rows: number }> { - const peers = getPlatform().peers; - const result = await peers?.surfaceOp(target.surfaceId, 'resize', cols, rows); - if (result?.ok) { - target.cols = result.cols ?? cols; - target.rows = result.rows ?? rows; + const owner = await peerSurfaceOp({ surfaceId: target.surfaceId, op: 'resize', cols, rows }); + if (owner) { + target.cols = owner.cols; + target.rows = owner.rows; } return { cols: target.cols, rows: target.rows }; } @@ -438,9 +442,7 @@ export class RemoteApiSession { platform.offPtyData(this.#attachment.onData); platform.offPtyExit(this.#attachment.onExit); // Stop the host forwarding a PTY this webview never owned. - if (this.#attachment.target.kind === 'peer') { - platform.peers?.unsubscribePty(this.#attachment.ptyId); - } + this.#attachment.stopStream?.(); this.#attachment = null; } } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 98ce098c..c9eacf04 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -22,19 +22,14 @@ 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 { readStore, writeStore } from './remote-host-store'; -import { - PEER_REPLY_BUDGET_MS, - type PeerSurfaceOp, - type PeerSurfaceResult, -} from '../../lib/src/lib/vscode-peer-link-protocol'; +import { PEER_REPLY_BUDGET_MS } from '../../lib/src/lib/vscode-peer-link-protocol'; import { ensureWindowLease } from './window-lease'; import { configurePeerLink, isRemotePty, - remoteDirectory, + remoteRequest, remoteResize, remoteSubscribe, - remoteSurfaceOp, remoteUnsubscribe, remoteWrite, setPeerLinkRole, @@ -129,52 +124,26 @@ interface ActiveRouter { ownsPty(id: string): boolean; forwardDorControlRequest(request: DorControlRequest): void; notifyStoreChanged(key: string, value: string | null): void; - askDirectory(requestId: string): void; deliverForeignData(ptyId: string, data: string): void; deliverForeignExit(ptyId: string, exitCode: number): void; - askSurface( - requestId: string, - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, - ): void; + ask(requestId: string, op: string, params: unknown): void; } -/** - * Ask every *other* webview in this window to answer a peer request, and settle - * once they all have (or the budget runs out). - * - * The remote Host runs in one webview, but a window's terminals are spread - * across all of them — each webview has its own xterm registry, so the Host can - * neither list nor attach to a sibling's pane without asking. The extension - * host is the only party that can ask, so it brokers. See docs/specs/vscode.md - * → "Peer surfaces". - */ - let nextBrokerRequestId = 0; -interface PendingDirectory { - pending: Set; - entries: unknown[]; - settle: () => void; - timer: ReturnType; -} -const peerDirectoryRequests = new Map(); - -interface PendingSurface { +interface PendingRequest { /** Answers still outstanding, so a miss settles as fast as a hit. */ pending: Set; - settle: (result: PeerSurfaceResult) => void; + results: unknown[]; + settle: () => void; timer: ReturnType; } -const peerSurfaceRequests = new Map(); +const peerRequests = new Map(); // 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 brokers. +// would reach them again, so it only ever gets the in-window broker. configurePeerLink({ - brokerDirectory, - brokerSurfaceOp, + brokerRequest, deliverRemotePtyData, deliverRemotePtyExit, onProcessedPtyData, @@ -183,67 +152,44 @@ configurePeerLink({ }); /** - * Collect directory entries from every webview in this window except `exclude`. + * Put one peer request to every webview in this window except `exclude`, and + * settle with everything they answered. + * + * The remote Host runs in one webview, but a window's terminals are spread + * across all of them — each webview has its own xterm registry, so the Host can + * neither list nor attach to a sibling's pane without asking. The extension + * host is the only party that can ask, so it brokers. See docs/specs/vscode.md + * → "Peer surfaces". * - * Settles when they have all answered or the budget expires: a webview with no - * live content never replies, and must not hang the phone's picker. Callable - * from a webview request (the Host asking) and from a peer window's socket - * (tier 2), which is why it is a plain promise rather than message plumbing. + * `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. Callable from a webview request (the Host asking) and from a peer + * window's socket (tier 2), which is why it is a plain promise rather than + * message plumbing. */ -function brokerDirectory(exclude?: ActiveRouter): Promise { +function brokerRequest(op: string, params: unknown, exclude?: ActiveRouter): Promise { const peers = [...activeRouters].filter((router) => router !== exclude); if (peers.length === 0) return Promise.resolve([]); - const requestId = `broker-dir-${++nextBrokerRequestId}`; + const requestId = `broker-${++nextBrokerRequestId}`; return new Promise((resolve) => { const settle = () => { - const request = peerDirectoryRequests.get(requestId); + const request = peerRequests.get(requestId); if (!request) return; - peerDirectoryRequests.delete(requestId); + peerRequests.delete(requestId); clearTimeout(request.timer); - resolve(request.entries); + resolve(request.results); }; - peerDirectoryRequests.set(requestId, { + peerRequests.set(requestId, { pending: new Set(peers), - entries: [], + results: [], settle, timer: setTimeout(settle, PEER_REPLY_BUDGET_MS), }); - for (const peer of peers) peer.askDirectory(requestId); - }); -} - -/** - * Ask every webview except `exclude` to drive a surface; only its owner answers. - * - * Broadcast rather than tracking surfaceId ownership: a window holds a handful - * of webviews, and the owner is the only one that can act anyway. - */ -function brokerSurfaceOp( - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, - exclude?: ActiveRouter, -): Promise { - const peers = [...activeRouters].filter((router) => router !== exclude); - if (peers.length === 0) return Promise.resolve({ ok: false }); - - const requestId = `broker-surface-${++nextBrokerRequestId}`; - return new Promise((resolve) => { - const settle = (result: PeerSurfaceResult) => { - const request = peerSurfaceRequests.get(requestId); - if (!request) return; - peerSurfaceRequests.delete(requestId); - clearTimeout(request.timer); - resolve(result); - }; - peerSurfaceRequests.set(requestId, { - pending: new Set(peers), - settle, - timer: setTimeout(() => settle({ ok: false }), PEER_REPLY_BUDGET_MS), - }); - for (const peer of peers) peer.askSurface(requestId, surfaceId, op, cols, rows); + for (const peer of peers) peer.ask(requestId, op, params); }); } @@ -763,49 +709,32 @@ export function attachRouter( subscribedPtyIds.delete(msg.id); if (isRemotePty(msg.id)) remoteUnsubscribe(msg.id); break; - case 'peer:directory': { - // This window's other webviews, plus every window reporting to us. + case 'peer:request': { + // This window's other webviews, plus every window reporting to us. Both + // at once rather than falling through: what is asked about lives in + // exactly one of them, and asking in series would pay a whole tier's + // budget before reaching the tier that owns it. const requestId = msg.requestId; - void Promise.all([brokerDirectory(router), remoteDirectory()]).then(([here, elsewhere]) => - post({ - type: 'peer:directoryResult', requestId, entries: [...here, ...elsewhere], - } satisfies ExtensionMessage), + const { op, params } = msg; + void Promise.all([brokerRequest(op, params, router), remoteRequest(op, params)]).then( + ([here, elsewhere]) => + post({ + type: 'peer:results', requestId, results: [...here, ...elsewhere], + } satisfies ExtensionMessage), ); break; } - case 'peer:directoryEntries': { - const request = peerDirectoryRequests.get(msg.requestId); + 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) break; - if (Array.isArray(msg.entries)) request.entries.push(...msg.entries); + if (Array.isArray(msg.results)) request.results.push(...msg.results); request.pending.delete(router); if (request.pending.size === 0) request.settle(); break; } - case 'peer:surfaceOp': { - const requestId = msg.requestId; - const { surfaceId, op, cols, rows } = msg; - void brokerSurfaceOp(surfaceId, op, cols, rows, router) - // Nobody here owns it — try the windows reporting to us. - .then((result) => (result.ok ? result : remoteSurfaceOp(surfaceId, op, cols, rows))) - .then((result) => - post({ type: 'peer:surfaceOpResult', requestId, ...result } satisfies ExtensionMessage), - ); - break; - } - case 'peer:surfaceResult': { - const request = peerSurfaceRequests.get(msg.requestId); - if (!request) break; - if (msg.ok) { - request.settle({ ok: true, ptyId: msg.ptyId, cols: msg.cols, rows: msg.rows }); - break; - } - // Every webview answers, so "nobody owns it" settles immediately - // instead of waiting out the budget — which is the common case when the - // surface actually lives in another window. - request.pending.delete(router); - if (request.pending.size === 0) request.settle({ ok: false }); - break; - } case 'singleton:claim': // `WebviewMessage` is a claim about the sender, not a runtime check. if (typeof msg.name !== 'string') break; @@ -1005,9 +934,9 @@ export function attachRouter( if (disposed) return; void post({ type: 'store:changed', key, value } satisfies ExtensionMessage); }, - askDirectory(requestId: string) { + ask(requestId: string, op: string, params: unknown) { if (disposed) return; - void post({ type: 'peer:directoryRequest', requestId } satisfies ExtensionMessage); + void post({ type: 'peer:ask', requestId, op, params } satisfies ExtensionMessage); }, deliverForeignData(ptyId: string, data: string) { if (disposed || !subscribedPtyIds.has(ptyId)) return; @@ -1017,31 +946,15 @@ export function attachRouter( if (disposed || !subscribedPtyIds.has(ptyId)) return; void post({ type: 'pty:exit', id: ptyId, exitCode } satisfies ExtensionMessage); }, - askSurface( - requestId: string, - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, - ) { - if (disposed) return; - void post({ - type: 'peer:surfaceRequest', requestId, surfaceId, op, cols, rows, - } satisfies ExtensionMessage); - }, dispose() { if (disposed) return; disposed = true; activeRouters.delete(router); // A webview that goes away mid-fan-out must not hold the answer open. - for (const request of peerDirectoryRequests.values()) { + for (const request of peerRequests.values()) { if (!request.pending.delete(router)) continue; if (request.pending.size === 0) request.settle(); } - for (const request of peerSurfaceRequests.values()) { - if (!request.pending.delete(router)) continue; - if (request.pending.size === 0) request.settle({ ok: false }); - } subscribedPtyIds.clear(); releaseSingletons(claimant); removeWatchedCommandListener(); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 0e01ffd1..1f65ad0a 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -31,12 +31,13 @@ export type WebviewMessage = | { type: 'singleton:claim'; name: string } // Peer surfaces: one webview is the remote 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: 'pty:subscribe'; id: string } | { type: 'pty:unsubscribe'; id: string } - | { type: 'peer:directory'; requestId: string } - | { type: 'peer:directoryEntries'; requestId: string; entries: unknown[] } - | { type: 'peer:surfaceOp'; requestId: string; surfaceId: string; op: 'attach' | 'detach' | 'resize'; cols?: number; rows?: number } - | { type: 'peer:surfaceResult'; requestId: string; ok: boolean; ptyId?: string; cols?: number; rows?: number } + | { type: 'peer:request'; requestId: string; op: string; params: unknown } + | { type: 'peer:answer'; requestId: string; results: unknown[] } | { type: 'store:read'; prefix: string; requestId: string } | { type: 'store:write'; key: string; value: string | null } | { type: 'dormouse:init' } @@ -87,10 +88,8 @@ export type ExtensionMessage = | { type: 'store:entries'; requestId: string; entries: Record } | { type: 'singleton:lease'; name: string; held: boolean } | { type: 'store:changed'; key: string; value: string | null } - | { type: 'peer:directoryRequest'; requestId: string } - | { type: 'peer:directoryResult'; requestId: string; entries: unknown[] } - | { type: 'peer:surfaceRequest'; requestId: string; surfaceId: string; op: 'attach' | 'detach' | 'resize'; cols?: number; rows?: number } - | { type: 'peer:surfaceOpResult'; requestId: string; ok: boolean; ptyId?: string; cols?: number; rows?: number } + | { type: 'peer:ask'; requestId: string; op: string; params: unknown } + | { type: 'peer:results'; requestId: string; results: unknown[] } | { type: 'dormouse:newTerminal'; shell?: string; diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 5fda92eb..3b101ef4 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -3,12 +3,12 @@ * across windows"). * * Within a window the extension host sees every webview, so brokering is a - * function call (`brokerDirectory` / `brokerSurfaceOp`). Across windows there - * is no shared process at all — one extension host each — so the window holding - * the Host lease listens on a local socket and every other window connects to - * it. Because the webview lease is itself gated on the window lease, the broker - * window is always the Host window; the broker never has to relay back out to a - * remote Host, which keeps this one-directional. + * function call (`brokerRequest`). Across windows there is no shared process at + * all — one extension host each — so the window holding the Host lease listens + * on a local socket and every other window connects to it. Because the webview + * lease is itself gated on the window lease, the broker window is always the + * Host window; the broker never has to relay back out to a remote Host, which + * keeps this one-directional. * * Roles follow the lease: acquire it and you become the server, lose it and you * become a client. The frame shapes, framing, and PTY routing table are in @@ -34,11 +34,10 @@ import { PEER_REPLY_BUDGET_MS, encodeFrame, forgetPeerRoutes, + routedPtyId, type PeerLinkHello, type PeerLinkRequest, type PeerLinkResponse, - type PeerSurfaceOp, - type PeerSurfaceResult, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; @@ -49,13 +48,7 @@ import { log } from './log'; */ export interface PeerLinkDeps { /** Fan out to this window's own webviews — never to other windows. */ - brokerDirectory(): Promise; - brokerSurfaceOp( - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, - ): Promise; + brokerRequest(op: string, params: unknown): Promise; deliverRemotePtyData(ptyId: string, data: string): void; deliverRemotePtyExit(ptyId: string, exitCode: number): void; onProcessedPtyData(listener: (id: string, data: string) => void): () => void; @@ -156,39 +149,39 @@ function authenticatedClients(): PeerClient[] { return [...clients].filter((client) => client.authenticated); } -/** Directory entries from every other window. Empty when nothing is connected. */ -export async function remoteDirectory(): Promise { +/** + * Put one peer request to every other window and collect what they answer. + * 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): Promise { const peers = authenticatedClients(); if (peers.length === 0) return []; const replies = await Promise.all( - peers.map((client) => ask(client, { kind: 'directory', id: `r${++nextRequestId}` })), + peers.map(async (client) => + [client, await ask(client, { kind: 'request', id: `r${++nextRequestId}`, op, params })] as const, + ), ); - return replies.flatMap((reply) => - reply?.kind === 'directoryResult' ? reply.entries : [], - ); -} -/** - * Drive a surface owned by another window. The first window to claim it wins; - * the rest own no such id and answer `ok: false`. - */ -export async function remoteSurfaceOp( - surfaceId: string, - op: PeerSurfaceOp, - cols?: number, - rows?: number, -): Promise { - for (const client of authenticatedClients()) { - const reply = await ask(client, { - kind: 'surfaceOp', id: `r${++nextRequestId}`, surfaceId, op, cols, rows, - }); - if (reply?.kind !== 'surfaceResult' || !reply.ok) continue; - // Remember where this PTY lives: a ptyId alone says nothing about which - // window owns it, and input and resizes have to reach that window. - if (reply.ptyId) routes.set(reply.ptyId, client); - return { ok: true, ptyId: reply.ptyId, cols: reply.cols, rows: reply.rows }; + 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) routes.set(ptyId, client); + results.push(result); + } } - return { ok: false }; + return results; } /** Whether this PTY is streaming from another window. */ @@ -325,16 +318,13 @@ function respond(frame: PeerLinkResponse): void { async function onClientFrame(frame: unknown): Promise { const request = frame as PeerLinkRequest; switch (request.kind) { - case 'directory': - respond({ kind: 'directoryResult', id: request.id, entries: (await deps?.brokerDirectory()) ?? [] }); - break; - case 'surfaceOp': { - const result = (await deps?.brokerSurfaceOp( - request.surfaceId, request.op, request.cols, request.rows, - )) ?? { ok: false }; - respond({ kind: 'surfaceResult', id: request.id, ...result }); + case 'request': + respond({ + kind: 'result', + id: request.id, + results: (await deps?.brokerRequest(request.op, request.params)) ?? [], + }); break; - } case 'subscribe': { if (forwarding.has(request.ptyId)) break; const stop = deps?.onProcessedPtyData((id, data) => { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 272eb7b9..5324aae9 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -34,10 +34,13 @@ function fakeWindow(options: { }, deps() { return { - brokerDirectory: async () => this.entries, - brokerSurfaceOp: async (surfaceId: string) => { + // 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: string, params: unknown) => { + if (op === 'directory') return this.entries; + const { surfaceId } = params as { surfaceId: string }; const surface = this.surfaces[surfaceId]; - return surface ? { ok: true, ...surface } : { ok: false }; + return surface ? [surface] : []; }, deliverRemotePtyData: (ptyId: string, data: string) => void this.delivered.push({ ptyId, data }), @@ -65,6 +68,10 @@ async function openWindow(deps: ReturnType): Promise waitForFile(join(dir, 'remote-host.peer.json')); +/** Attach to the terminal {@link farWindow} owns, which is what places its route. */ +const attachFar = (broker: LinkModule) => + broker.remoteRequest('surfaceOp', { surfaceId: 'far-1', op: 'attach', cols: 80, rows: 24 }); + /** A window owning one terminal, which is what most of these tests need. */ const farWindow = () => fakeWindow({ @@ -88,7 +95,7 @@ async function linkedPair( const peer = await openWindow(peerSide); peer.setPeerLinkRole(false); // The handshake is asynchronous; the first answered request proves it landed. - await waitFor(async () => (await broker.remoteDirectory()).length > 0); + await waitFor(async () => (await broker.remoteRequest('directory', {})).length > 0); return { broker, brokerSide, peer, peerSide }; } @@ -107,14 +114,17 @@ describe('peer link between windows', () => { const peerSide = fakeWindow({ entries: [{ surfaceId: 'far-1' }, { surfaceId: 'far-2' }] }); const { broker } = await linkedPair(fakeWindow(), peerSide); - expect(await broker.remoteDirectory()).toEqual([{ surfaceId: 'far-1' }, { surfaceId: 'far-2' }]); + expect(await broker.remoteRequest('directory', {})).toEqual([ + { surfaceId: 'far-1' }, + { surfaceId: 'far-2' }, + ]); }); it('returns nothing when no other window is connected', async () => { const broker = await openWindow(fakeWindow()); broker.setPeerLinkRole(true); await waitForRendezvous(); - expect(await broker.remoteDirectory()).toEqual([]); + expect(await broker.remoteRequest('directory', {})).toEqual([]); }); it('drives a surface owned by the other window and remembers where it lives', async () => { @@ -124,22 +134,27 @@ describe('peer link between windows', () => { }); const { broker } = await linkedPair(fakeWindow(), peerSide); - const result = await broker.remoteSurfaceOp('far-1', 'attach', 100, 30); - expect(result).toEqual({ ok: true, ptyId: 'pty-far', cols: 100, rows: 30 }); + const results = await broker.remoteRequest('surfaceOp', { + surfaceId: 'far-1', op: 'attach', cols: 100, rows: 30, + }); + expect(results).toEqual([{ ptyId: 'pty-far', cols: 100, rows: 30 }]); // Input and resizes have to reach that window afterwards. expect(broker.isRemotePty('pty-far')).toBe(true); }); it('reports a surface nobody owns', async () => { const { broker } = await linkedPair(fakeWindow(), fakeWindow({ entries: [{ s: 1 }] })); - expect(await broker.remoteSurfaceOp('nobody', 'attach', 80, 24)).toEqual({ ok: false }); + // 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, brokerSide } = await linkedPair(fakeWindow(), peerSide); - await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + await attachFar(broker); broker.remoteSubscribe('pty-far'); await tick(); @@ -152,7 +167,7 @@ describe('peer link between windows', () => { it('does not stream PTYs it never subscribed to', async () => { const peerSide = farWindow(); const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); - await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + await attachFar(broker); peerSide.emitData('pty-other', 'not subscribed'); await tick(100); @@ -162,7 +177,7 @@ describe('peer link between windows', () => { it('stops the stream on unsubscribe', async () => { const peerSide = farWindow(); const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); - await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + await attachFar(broker); broker.remoteSubscribe('pty-far'); await tick(); @@ -179,7 +194,7 @@ describe('peer link between windows', () => { it('routes input and resize to the owning window', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); - await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + await attachFar(broker); expect(broker.remoteWrite('pty-far', 'ls\r')).toBe(true); expect(broker.remoteResize('pty-far', 120, 40)).toBe(true); @@ -199,7 +214,7 @@ describe('peer link between windows', () => { it('reports terminals as exited when their window disconnects', async () => { const peerSide = farWindow(); const { broker, brokerSide, peer } = await linkedPair(fakeWindow(), peerSide); - await broker.remoteSurfaceOp('far-1', 'attach', 80, 24); + await attachFar(broker); expect(broker.isRemotePty('pty-far')).toBe(true); // The window was closed: its terminals are gone, and a later write must not @@ -228,7 +243,7 @@ describe('peer link between windows', () => { // The server drops it rather than answering anything. await new Promise((resolve) => socket.on('close', resolve)); - expect(await broker.remoteDirectory()).toEqual([]); + expect(await broker.remoteRequest('directory', {})).toEqual([]); socket.destroy(); }); }); From f25125dfba3dd946b4a1a30432ab23ac24c5a9c7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:06:27 -0700 Subject: [PATCH 13/56] Resolve a surface to a handle instead of branching on where it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remote-api.ts` carried a `SurfaceTarget` union, a `targetSize`, a `#resizePeer`, and peer branches in `#attach`, `#beginAttach`, `#resize`, and `#teardownAttachment` — all to answer "is this pane in my webview or a sibling's". That is a fact about VS Code webview hosting, and it is not a protocol-v1 concept: `docs/specs/remote-api.md` does not mention webviews at all. The asymmetry was the tell. The same feature already makes a foreign PTY *transparent* for everything else: `pty:data` from another window is injected into the ordinary data path, and `pty:input` / `pty:resize` route by table before falling back to the local manager, so `#write` has zero branches. Only surface resolution had been pushed up into the protocol layer. `resolveSurface(surfaceId, size)` now answers with a `SurfaceHandle` — its `ptyId`, the size it stands at, a `resize`, a `release` — or `null` if nobody owns it. `#attach` is one `await`, `#resize` is `handle.resize(...)`, teardown is `handle.release()`, and the word "peer" no longer appears in the protocol layer's code. The size travels with the resolve because attach-is-the-resize: a sibling has to apply it inside the attach round trip, since there is no reaching into its xterm afterwards without a second one. A local pane is left alone there and resized by the caller, which subscribes to the PTY first so a synchronous repaint is not lost — the handle reports the size as it stands and the caller reconciles, so both paths keep exactly the sequence of round trips they had. This makes local attach asynchronous too, which is the honest shape: a pane in another window *is* a round trip away, and the alternative was one path that answered synchronously and one that did not. The suite that pinned the synchronous shape now awaits; nothing else about it changed, which is the point. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 6 +- lib/src/remote/host/remote-api.test.ts | 50 +++++--- lib/src/remote/host/remote-api.ts | 158 +++++++------------------ lib/src/remote/host/surface-resolve.ts | 104 ++++++++++++++++ 4 files changed, 186 insertions(+), 132 deletions(-) create mode 100644 lib/src/remote/host/surface-resolve.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index f6c289a3..b6152451 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -300,6 +300,10 @@ The one field the transport itself reads out of an answer is a reserved `ptyId` `attach` and `resize` on a foreign surface go to the owner rather than to the PTY, because attach-is-the-resize has to drive the live xterm or the owning pane's own view drifts from the size the phone set. The owner replies with the size it settled at and the `ptyId`; the Host then subscribes and streams. `detach` has nothing to undo on the owner — the Host stops streaming and the pane keeps its size, which is what last-attach-wins means. +**Which webview owns a pane never reaches the protocol layer.** `resolveSurface(surfaceId, size)` answers with a `SurfaceHandle` — `ptyId`, the size it stands at, `resize`, `release` — or `null` if nobody owns it, and `remote-api.ts` holds one of those per attachment. That is the same trick the rest of the feature already plays: foreign `pty:data` is injected into the ordinary data path and `pty:input` / `pty:resize` route by table before falling back to the local manager, so `terminal.write` has no branch either. It makes local attach asynchronous too, which is the honest shape — a pane in another window *is* a round trip away, and the alternative was one path that answered synchronously and one that did not. + +Resolving a peer's surface *is* the attach: the requested size travels with it, because the owner has to apply it inside that round trip — there is no reaching into its xterm afterwards without a second one. A local pane is left alone at resolve and resized by the caller, which subscribes to the PTY first so a synchronous repaint is not lost. Either way the handle reports the size as it stands and the caller reconciles, which is why the same-size repaint bounce fires for a peer attach (its owner already applied the size) and the resize path fires for a local one. + Subscribing is a subscription, not a pair of calls: `peers.streamPty(ptyId)` returns its own unsubscribe, so a caller cannot leak a stream by losing track of the id it opened it with. The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. @@ -322,7 +326,7 @@ Trust: the socket is user-owned, its path is published only in a mode-0600 file, Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and roles, `lib/src/lib/vscode-peer-link-protocol.ts` for the frames, framing, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`), and the `remote*` calls in `vscode-ext/src/message-router.ts`. -Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, and the foreign-surface path in `remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. +Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, the resolver in `lib/src/remote/host/surface-resolve.ts`, and the attachment it backs in `lib/src/remote/host/remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. ### Testing the extension host diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 3e15bfe1..a089f7a1 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -81,12 +81,26 @@ function registerSurface( } as unknown as TerminalEntry); } -function attach(session: RemoteApiSession, cols: number, rows: number, surfaceId = 'surface-1'): void { +/** + * Resolving a surface is a promise now — a pane in a sibling webview is a round + * trip away, and the local path takes the same seam rather than a second one + * (`surface-resolve.ts`) — so an attach lands a microtask later even here. + * `terminal.resize` on a resolved pane is still synchronous, so everything the + * attach does still happens in one go once it starts. + */ +async function attach(session: RemoteApiSession, cols: number, rows: number, surfaceId = 'surface-1'): Promise { session.handle({ requestId: 'attach-1', method: REMOTE_METHODS.surfaceAttach, params: { surfaceId, cols, rows }, }); + await settle(); +} + +/** Let a promise-tailed handler run; microtasks are unaffected by fake timers. */ +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); } function decodeTerminalData(payload: SentPayload): string { @@ -101,14 +115,14 @@ describe('RemoteApiSession surface.attach', () => { setPlatform(new FakePtyAdapter()); }); - it('keeps synchronous repaint data from terminal resize', () => { + it('keeps synchronous repaint data from terminal resize', async () => { const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); registerSurface(platform, 80, 24); const sent: SentPayload[] = []; const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); - attach(session, 100, 30); + await attach(session, 100, 30); expect(sent[0]).toMatchObject({ requestId: 'attach-1', @@ -122,7 +136,7 @@ describe('RemoteApiSession surface.attach', () => { expect(decodeTerminalData(sent[1]!)).toBe('terminal-resize:100x30'); }); - it('keeps synchronous repaint data from the same-size PTY bounce', () => { + it('keeps synchronous repaint data from the same-size PTY bounce', async () => { vi.useFakeTimers(); const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); @@ -130,7 +144,7 @@ describe('RemoteApiSession surface.attach', () => { const sent: SentPayload[] = []; const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); - attach(session, 80, 24); + await attach(session, 80, 24); expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); expect(sent[0]).toMatchObject({ @@ -148,7 +162,7 @@ describe('RemoteApiSession surface.attach', () => { expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-1', 80, 24); }); - it('does not fire the same-size bounce restore after detaching', () => { + it('does not fire the same-size bounce restore after detaching', async () => { vi.useFakeTimers(); const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); @@ -156,7 +170,7 @@ describe('RemoteApiSession surface.attach', () => { const sent: SentPayload[] = []; const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); - attach(session, 80, 24); + 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); @@ -176,7 +190,7 @@ describe('RemoteApiSession surface.attach', () => { expect(platform.resizePty).not.toHaveBeenCalledWith('pty-1', 80, 24); }); - 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()); @@ -186,12 +200,12 @@ describe('RemoteApiSession surface.attach', () => { const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); // First attach schedules a restore bounce for pty-1. - attach(session, 80, 24, 'surface-1'); + await attach(session, 80, 24, 'surface-1'); expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); // Re-attaching to a different surface replaces the attachment (last-attach-wins) // and must cancel the prior pty-1 restore. - attach(session, 80, 24, 'surface-2'); + await attach(session, 80, 24, 'surface-2'); expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-2', 80, 23); vi.advanceTimersByTime(60); @@ -202,7 +216,7 @@ describe('RemoteApiSession surface.attach', () => { expect(platform.resizePty).not.toHaveBeenCalledWith('pty-1', 80, 24); }); - it('rejects write and resize unless the surface is the current attachment', () => { + it('rejects write and resize unless the surface is the current attachment', async () => { const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); @@ -210,7 +224,7 @@ describe('RemoteApiSession surface.attach', () => { const sent: SentPayload[] = []; const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); - attach(session, 80, 24, 'surface-1'); + await attach(session, 80, 24, 'surface-1'); sent.length = 0; session.handle({ @@ -262,7 +276,7 @@ describe('RemoteApiSession surface.attach', () => { ]); }); - it('keeps write and resize pinned to the attached terminal after pane swaps', () => { + it('keeps write and resize pinned to the attached terminal after pane swaps', async () => { const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); @@ -270,7 +284,7 @@ describe('RemoteApiSession surface.attach', () => { const sent: SentPayload[] = []; const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); - attach(session, 90, 25, 'surface-1'); + await attach(session, 90, 25, 'surface-1'); const attachedEntry = registry.get('surface-1')!; const swappedInEntry = registry.get('surface-2')!; registry.set('surface-1', swappedInEntry); @@ -292,10 +306,14 @@ describe('RemoteApiSession surface.attach', () => { params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, }); + // The xterm resize is synchronous; only the reply waits on the handle, + // which for a sibling's pane is a round trip. 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); + + await settle(); expect(sent).toEqual([ { requestId: 'write-after-swap', @@ -315,14 +333,14 @@ describe('RemoteApiSession surface.attach', () => { ]); }); - it('tears down the attachment when the attached PTY exits', () => { + it('tears down the attachment when the attached PTY exits', async () => { const platform = new RepaintOnResizePlatform(); setPlatform(platform.asAdapter()); registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); const sent: SentPayload[] = []; const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); - attach(session, 100, 30, 'surface-1'); + await attach(session, 100, 30, 'surface-1'); sent.length = 0; // The attached PTY exits (process death, or the pane disposed on the Host). diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 50561a21..da4601ea 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -35,12 +35,11 @@ import { 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 { peerDirectory, peerSurfaceOp } from './peer-surfaces'; +import { peerDirectory } from './peer-surfaces'; +import { resolveSurface, type SurfaceHandle } from './surface-resolve'; /** Coalesce window for directory re-snapshots (remote-api.md: "Host coalesces"). */ const DIRECTORY_DEBOUNCE_MS = 150; @@ -50,31 +49,18 @@ const DIRECTORY_DEBOUNCE_MS = 150; */ const FORCE_REPAINT_BOUNCE_MS = 60; -/** - * Where an attached surface lives. A window's terminals are spread across its - * webviews and only one of them is the Host, so an attachment is either to a - * pane in this webview's registry or to one a sibling owns, driven through the - * peer bridge (docs/specs/vscode.md → "Peer surfaces"). - */ -type SurfaceTarget = - | { kind: 'local'; entry: TerminalEntry } - | { kind: 'peer'; surfaceId: string; cols: number; rows: number }; - -function targetSize(target: SurfaceTarget): { cols: number; rows: number } { - return target.kind === 'local' - ? { cols: target.entry.terminal.cols, rows: target.entry.terminal.rows } - : { cols: target.cols, rows: target.rows }; -} - interface Attachment { surfaceId: string; - ptyId: string; - target: SurfaceTarget; + /** + * 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 whether the pane is this webview's or a sibling's + * (`surface-resolve.ts`). + */ + handle: SurfaceHandle; subId: string; onData: (detail: { id: string; data: string }) => void; onExit: (detail: { id: string; exitCode: number }) => void; - /** Stops the peer stream; absent for a pane this webview owns. */ - stopStream: (() => void) | null; /** Pending same-size repaint bounce (see FORCE_REPAINT_BOUNCE_MS), if any. */ bounceTimer: ReturnType | null; } @@ -217,18 +203,16 @@ export class RemoteApiSession { #emitDirectory(): void { if (this.#directorySubId === null) return; const subId = this.#directorySubId; - const local = collectDirectorySnapshot(); - if (!getPlatform().peers) { - this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: local }); - return; - } - // A window's terminals are spread across its webviews, and only this one is - // the Host — the rest have to be asked (docs/specs/vscode.md → "Peer - // surfaces"). Emit twice rather than delaying the local panes behind a - // round trip: the phone renders what is here immediately, then fills in. - this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: local }); + // A window's terminals may be spread across several webviews with only this + // one as the Host, so the rest have to be asked (docs/specs/vscode.md → + // "Peer surfaces"). Emit twice rather than delaying the local panes behind + // a round trip: the phone renders what is here immediately, then fills in. + this.#event(subId, REMOTE_EVENTS.directorySnapshot, { + entries: collectDirectorySnapshot(), + }); void peerDirectory().then((remote) => { - // The subscription may have been replaced or torn down while we waited. + // Nothing to fill in on a host with no peers, and the subscription may + // have been replaced or torn down while we waited. if (this.#directorySubId !== subId || remote.length === 0) return; this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries: [...collectDirectorySnapshot(), ...remote], @@ -243,54 +227,26 @@ export class RemoteApiSession { return; } - const entry = registry.get(params.surfaceId); - if (entry) { - this.#beginAttach(request, params, { kind: 'local', entry }, entry.ptyId, null); - return; - } - - // Not ours: ask the other webviews of this window. The owner resizes its - // own xterm — attach-is-the-resize has to go through the live terminal, not - // the PTY, or the owning pane's view drifts from the size the phone set. - const peers = getPlatform().peers; - if (!peers) { - this.#fail(request, `no such surface: ${params.surfaceId}`); - return; - } - void peerSurfaceOp({ - surfaceId: params.surfaceId, - op: 'attach', - cols: params.cols, - rows: params.rows, - }).then((owner) => { - if (!owner) { + // Where the pane lives — this webview's registry or a sibling's — is a fact + // about VS Code webview hosting, not a protocol concept, so it is settled + // below this line and never seen here (`surface-resolve.ts`). + void resolveSurface(params.surfaceId, params).then((handle) => { + if (!handle) { this.#fail(request, `no such surface: ${params.surfaceId}`); return; } - const stopStream = peers.streamPty(owner.ptyId); - this.#beginAttach( - request, - params, - { kind: 'peer', surfaceId: params.surfaceId, cols: owner.cols, rows: owner.rows }, - owner.ptyId, - stopStream, - ); + this.#beginAttach(request, params, handle); }); } - #beginAttach( - request: RemoteRequest, - params: AttachParams, - target: SurfaceTarget, - ptyId: string, - stopStream: (() => void) | null, - ): void { + #beginAttach(request: RemoteRequest, params: AttachParams, handle: SurfaceHandle): void { // v1: one attachment per session — replace any prior stream. this.#teardownAttachment(); - const current = targetSize(target); - const cols = clampTerminalDimension(params.cols, current.cols); - const rows = clampTerminalDimension(params.rows, current.rows); + 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 platform = getPlatform(); const subId = request.requestId; const pendingEvents: Array<{ event: string; data: unknown }> = []; @@ -326,12 +282,10 @@ export class RemoteApiSession { platform.onPtyExit(onExit); const attachment: Attachment = { surfaceId: params.surfaceId, - ptyId, - target, + handle, subId, onData, onExit, - stopStream, bounceTimer: null, }; this.#attachment = attachment; @@ -340,11 +294,10 @@ export class RemoteApiSession { // 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. - // A peer owner already applied the size in its own xterm before replying, - // so only the local path still has a resize to perform here. - if (current.cols !== cols || current.rows !== rows) { - if (target.kind === 'local') target.entry.terminal.resize(cols, rows); - else void this.#resizePeer(target, cols, rows); + // A sibling's owner already applied the size inside the attach round trip, + // so its handle resolves at the requested size and takes the bounce below. + if (!sameSize) { + void handle.resize(cols, rows); } else { // Same size: force one repaint with a quick rows bounce on the PTY only, // leaving the already-correct local xterm buffer untouched. Bounce away @@ -365,8 +318,7 @@ export class RemoteApiSession { }, FORCE_REPAINT_BOUNCE_MS); } - const settled = targetSize(target); - const result: TerminalAttachResult = { cols: settled.cols, rows: settled.rows }; + const result: TerminalAttachResult = { cols: handle.cols, rows: handle.rows }; this.#ok(request, result); streaming = true; for (const event of pendingEvents) { @@ -390,7 +342,7 @@ export class RemoteApiSession { 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))); + getPlatform().writePty(attachment.handle.ptyId, utf8Decode(fromBase64Url(params.bytes))); this.#ok(request, {}); } @@ -398,40 +350,15 @@ export class RemoteApiSession { const resolved = this.#attachedParams(request); if (!resolved) return; const { params, attachment } = resolved; - const target = attachment.target; - const current = targetSize(target); - const cols = clampTerminalDimension(params.cols, current.cols); - const rows = clampTerminalDimension(params.rows, current.rows); - - if (target.kind === 'local') { - const term = target.entry.terminal; - if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); - this.#ok(request, { cols: term.cols, rows: term.rows } satisfies TerminalAttachResult); - return; - } + const handle = attachment.handle; + const cols = clampTerminalDimension(params.cols, handle.cols); + const rows = clampTerminalDimension(params.rows, handle.rows); - void this.#resizePeer(target, cols, rows).then((size) => { + void handle.resize(cols, rows).then((size) => { this.#ok(request, { cols: size.cols, rows: size.rows } satisfies TerminalAttachResult); }); } - /** - * Resize a sibling-owned surface and record the size it settled at, so - * `targetSize` keeps answering for the pane we cannot read directly. - */ - async #resizePeer( - target: Extract, - cols: number, - rows: number, - ): Promise<{ cols: number; rows: number }> { - const owner = await peerSurfaceOp({ surfaceId: target.surfaceId, op: 'resize', cols, rows }); - if (owner) { - target.cols = owner.cols; - target.rows = owner.rows; - } - return { cols: target.cols, rows: target.rows }; - } - #teardownAttachment(): void { if (!this.#attachment) return; if (this.#attachment.bounceTimer) { @@ -441,8 +368,9 @@ export class RemoteApiSession { const platform = getPlatform(); platform.offPtyData(this.#attachment.onData); platform.offPtyExit(this.#attachment.onExit); - // Stop the host forwarding a PTY this webview never owned. - this.#attachment.stopStream?.(); + // Stops the host forwarding a PTY this webview never owned; nothing to undo + // for one it does. + this.#attachment.handle.release(); this.#attachment = null; } } diff --git a/lib/src/remote/host/surface-resolve.ts b/lib/src/remote/host/surface-resolve.ts new file mode 100644 index 00000000..02781538 --- /dev/null +++ b/lib/src/remote/host/surface-resolve.ts @@ -0,0 +1,104 @@ +/** + * Take hold of a surface by id, wherever it lives. + * + * A window's terminals are spread across its webviews and only one of them is + * the Host, so a pane the phone names is either in this webview's registry or + * in a sibling's (docs/specs/vscode.md → "Peer surfaces"). Which one is a fact + * about VS Code webview hosting; it is not a protocol-v1 concept + * (docs/specs/remote-api.md), so it is answered here and never seen above. + * + * The rest of the feature already works this way — `pty:data` from another + * window is injected into the ordinary data path, and `pty:input` / `pty:resize` + * route by table before falling back to the local manager — so `terminal.write` + * has no idea either. This closes the last gap. + */ + +import { getPlatform } from '../../lib/platform'; +import { registry } from '../../lib/terminal-store'; +import { peerSurfaceOp } from './peer-surfaces'; + +export interface SurfaceHandle { + 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; +} + +/** + * Resolve `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): a sibling has to apply it inside the attach round + * trip, since there is no way to reach into its xterm afterwards without a + * second one. A local pane 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. + */ +export async function resolveSurface( + surfaceId: string, + size: { cols?: number; rows?: number }, +): Promise { + const entry = registry.get(surfaceId); + if (entry) { + const term = entry.terminal; + return { + ptyId: entry.ptyId, + get cols() { + return term.cols; + }, + get rows() { + return term.rows; + }, + // Pinned to the terminal resolved here, not re-read from the registry: a + // pane swap must not move an attachment onto a different terminal. + resize: async (cols, rows) => { + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); + return { cols: term.cols, rows: term.rows }; + }, + release: () => {}, + }; + } + + // Not ours: ask the other webviews of this window, and the other windows. + // The owner resizes its own xterm — attach-is-the-resize has to go through + // the live terminal, not the PTY, or the owning pane's view drifts from the + // size the phone set. + const peers = getPlatform().peers; + if (!peers) return null; + const owner = await peerSurfaceOp({ surfaceId, op: 'attach', cols: size.cols, rows: size.rows }); + if (!owner) return null; + + const stopStream = peers.streamPty(owner.ptyId); + 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 peerSurfaceOp({ + surfaceId, + op: 'resize', + cols: nextCols, + rows: nextRows, + }); + if (settled) { + cols = settled.cols; + rows = settled.rows; + } + return { cols, rows }; + }, + release: stopStream, + }; +} From 76feed06222487947cce88f8295805c3bd3b39a9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:49:07 -0700 Subject: [PATCH 14/56] Initialize non-holder peer clients --- docs/specs/vscode.md | 4 ++++ vscode-ext/src/message-router.ts | 4 ++-- vscode-ext/src/window-lease.ts | 14 +++++++------- vscode-ext/test/window-lease.test.ts | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index b6152451..79cbc725 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -316,6 +316,10 @@ The lease makes this one-directional. Because the webview lease is gated on the Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. +The first arbitration result is a role transition even when it is `false`: a +window that starts while another owns the lease immediately enters the client +role and watches/connects to that broker. + A peer 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 `configurePeerLink` is handed only `brokerRequest`, and why the link is injected with what it needs rather than importing the router (which imports the link). Both tiers are asked at once rather than one after the other: what is asked about lives in exactly one webview of one window, and asking in series would pay a whole tier's budget — or a hung window's — before reaching the tier that owns it. diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index c9eacf04..f1811c5c 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -73,7 +73,7 @@ const singletonHolders = new Map(); * arbitrates across windows on shared storage; nothing is granted here until it * says this window won. */ -let windowLeaseHeld = false; +let windowLeaseHeld: boolean | null = null; function wantedSingletonNames(): Set { const names = new Set(); @@ -99,7 +99,7 @@ function onWindowLeaseChange(held: boolean): void { } function electSingleton(name: string): void { - if (!windowLeaseHeld) return; + if (windowLeaseHeld !== true) return; let holder = singletonHolders.get(name); if (!holder) { holder = [...singletonClaimants].find((claimant) => claimant.wants.has(name)); diff --git a/vscode-ext/src/window-lease.ts b/vscode-ext/src/window-lease.ts index b2411548..90c08dd0 100644 --- a/vscode-ext/src/window-lease.ts +++ b/vscode-ext/src/window-lease.ts @@ -40,7 +40,8 @@ const CLAIM_VERIFY_MS = 250; interface LeaseState { file: string; selfId: string; - held: boolean; + /** Null until the first arbitration cycle reports this window's role. */ + held: boolean | null; timer: ReturnType | null; watcher: FSWatcher | null; onChange: (held: boolean) => void; @@ -121,7 +122,7 @@ async function tick(current: LeaseState): Promise { */ export function ensureWindowLease(onChange: (held: boolean) => void): void { if (state) { - onChange(state.held); + onChange(state.held ?? false); return; } const context = extensionContext; @@ -131,7 +132,7 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { const current: LeaseState = { file: join(dir, LEASE_FILE), selfId: randomUUID(), - held: false, + held: null, timer: null, watcher: null, onChange, @@ -157,7 +158,7 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { // intended I/O, with overlapping writes colliding and each failure // dropping the role. Only a window waiting for the lease needs the // accelerator. - if (current.held) return; + if (current.held === true) return; void tick(current); }); } catch { @@ -170,7 +171,7 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { /** Whether this window currently owns the Host role. */ export function holdsWindowLease(): boolean { - return state?.held ?? false; + return state?.held === true; } /** @@ -184,9 +185,8 @@ export async function disposeWindowLease(): Promise { if (current.timer) clearInterval(current.timer); current.watcher?.close(); - if (!current.held) return; + if (current.held !== true) return; const record = await readRecord(current.file); if (record?.owner !== current.selfId) return; await unlink(current.file).catch(() => {}); } - diff --git a/vscode-ext/test/window-lease.test.ts b/vscode-ext/test/window-lease.test.ts index 87e4e116..65e3eedb 100644 --- a/vscode-ext/test/window-lease.test.ts +++ b/vscode-ext/test/window-lease.test.ts @@ -57,6 +57,20 @@ describe('window lease over a real directory', () => { expect(second.holdsWindowLease()).toBe(false); }); + it('reports an initial non-holder result', async () => { + const first = await openWindow(); + first.ensureWindowLease(() => {}); + await waitFor(() => first.holdsWindowLease()); + + const second = await openWindow(); + const changes: boolean[] = []; + second.ensureWindowLease((held) => changes.push(held)); + await waitFor(() => changes.length > 0); + + expect(changes).toEqual([false]); + expect(second.holdsWindowLease()).toBe(false); + }); + it('hands the role over when the holder disposes', async () => { const first = await openWindow(); first.ensureWindowLease(() => {}); From 9d446a3bb3a35a07277d4f35019f9b0715537acf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:50:31 -0700 Subject: [PATCH 15/56] Refresh Host storage before lease handoff --- docs/specs/vscode.md | 2 +- lib/src/lib/platform/vscode-adapter.test.ts | 32 +++++++++++++++++++++ lib/src/lib/platform/vscode-adapter.ts | 25 +++++++++++++++- vscode-ext/src/message-router.ts | 29 +++++++++++++++++-- vscode-ext/src/message-types.ts | 1 + 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 79cbc725..46140da0 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -256,7 +256,7 @@ A broadcast that lands while a webview is still hydrating is buffered and applie Both sides gate on the prefix. The webview names the keys, so `remote-host-store.ts` refuses any key outside the Host namespace and caps values at 64 KiB; a compromised webview can neither read nor write unrelated extension state. -A boot-time snapshot alone would be wrong, because the lease hands the Host between webviews: a webview that hydrated before another approved a pairing would later take the lease, read its stale ACL, and write that back — dropping the pairing permanently. So a committed write is broadcast to every webview (`store:changed`) and applied to each cache. The broadcast goes to the writer too; re-applying your own write is a no-op, and skipping self would mean identifying it. Only writes that actually happened are announced, which is why `writeStore` returns whether it wrote. +A boot-time snapshot alone would be wrong, because the lease hands the Host between webviews and windows: a webview that hydrated before another approved a pairing could later take the lease, read its stale ACL, and write that back — dropping the pairing permanently. A committed write is therefore broadcast to every webview in its window (`store:changed`) and applied to each cache. Before a newly elected window grants the webview-level Host role, it also rereads the whole prefix and sends every webview a replacement `store:snapshot`; the snapshot is ordered before the `singleton:lease { held: true }` grant and clears keys deleted by the previous holder. The per-write broadcast goes to the writer too; re-applying your own write is a no-op, and skipping self would mean identifying it. Only writes that actually happened are announced, which is why `writeStore` returns whether it wrote. Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vscode-adapter.ts` (`hydrateScopedStore`), `lib/src/lib/local-json-store.ts` (prefix claims), `lib/src/remote/host/store.ts` (the shared prefix). diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 911e4df8..aa124ce2 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -448,6 +448,38 @@ describe('VSCodeAdapter host store', () => { expect(loadJson(KEY, null)).toBeNull(); }); + it('replaces stale cached keys from a lease-handoff snapshot', async () => { + const enrollmentKey = `${PREFIX}enrollment`; + await hydrated({ + [KEY]: JSON.stringify([{ id: 'old' }]), + [enrollmentKey]: JSON.stringify({ hostId: 'host-1' }), + }); + + windowTarget.dispatchEvent(hostMessage({ + type: 'store:snapshot', + prefix: PREFIX, + entries: { [KEY]: JSON.stringify([{ id: 'new' }]) }, + })); + + expect(loadJson(KEY, [])).toEqual([{ id: 'new' }]); + expect(loadJson(enrollmentKey, null)).toBeNull(); + }); + + it('uses a lease-handoff snapshot that arrives during hydration', async () => { + const adapter = new VSCodeAdapter(); + const done = adapter.hydrateScopedStore(PREFIX); + + windowTarget.dispatchEvent(hostMessage({ + type: 'store:snapshot', + prefix: PREFIX, + entries: { [KEY]: JSON.stringify([{ id: 'fresh' }]) }, + })); + answerRead({ [KEY]: JSON.stringify([{ id: 'stale' }]) }); + await done; + + expect(loadJson(KEY, [])).toEqual([{ id: 'fresh' }]); + }); + it('ignores an unauthenticated broadcast', async () => { await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 596c53de..c52a4551 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -53,6 +53,8 @@ export class VSCodeAdapter implements PlatformAdapter { * value, not just the key, because a deletion has to survive too. */ private pendingStoreChanges = new Map(); + /** Fresh lease-handoff snapshots that arrived before boot hydration finished. */ + private pendingStoreSnapshots = new Map>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -178,6 +180,8 @@ export class VSCodeAdapter implements PlatformAdapter { this.singletonHandlers.get(msg.name)?.(!!msg.held); } else if (msg.type === 'store:changed') { this.applyStoreChange(msg.key, msg.value ?? null); + } else if (msg.type === 'store:snapshot') { + this.applyStoreSnapshot(msg.prefix, msg.entries ?? {}); } else if (msg.type === 'peer:ask') { // Answer even with no responder installed, and even to say nothing: the // broker settles once every webview has replied, so silence would make @@ -305,7 +309,9 @@ export class VSCodeAdapter implements PlatformAdapter { 'continuing without it. A remote Host enrollment will read as absent.', ); } - const cache = new Map(Object.entries(entries ?? {})); + const refreshed = this.pendingStoreSnapshots.get(prefix); + this.pendingStoreSnapshots.delete(prefix); + const cache = new Map(Object.entries(refreshed ?? entries ?? {})); // Anything committed while the read was in flight is newer than the // snapshot, so it is applied on top of it before the cache goes live. for (const [key, pending] of this.pendingStoreChanges) { @@ -347,6 +353,23 @@ export class VSCodeAdapter implements PlatformAdapter { this.pendingStoreChanges.set(key, value); } + /** Replace a whole prefix before a newly elected window starts its Host. */ + private applyStoreSnapshot(prefix: string, entries: Record): void { + const cache = this.scopedCaches.get(prefix); + if (cache) { + cache.clear(); + for (const [key, value] of Object.entries(entries)) cache.set(key, value); + return; + } + + // The snapshot is newer than any earlier per-key broadcast. Later changes + // remain buffered and are applied on top when hydration completes. + for (const key of this.pendingStoreChanges.keys()) { + if (key.startsWith(prefix)) this.pendingStoreChanges.delete(key); + } + this.pendingStoreSnapshots.set(prefix, entries); + } + shutdown(): void { // No-op — the extension host handles cleanup } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index f1811c5c..8845b8c3 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -21,7 +21,7 @@ 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 { readStore, writeStore } from './remote-host-store'; +import { readStore, REMOTE_HOST_STORE_PREFIX, writeStore } from './remote-host-store'; import { PEER_REPLY_BUDGET_MS } from '../../lib/src/lib/vscode-peer-link-protocol'; import { ensureWindowLease } from './window-lease'; import { @@ -74,6 +74,7 @@ const singletonHolders = new Map(); * says this window won. */ let windowLeaseHeld: boolean | null = null; +let storeReadyForWindowLease = false; function wantedSingletonNames(): Set { const names = new Set(); @@ -86,10 +87,18 @@ function wantedSingletonNames(): Set { function onWindowLeaseChange(held: boolean): void { if (windowLeaseHeld === held) return; windowLeaseHeld = held; + storeReadyForWindowLease = false; // The holder is the Host, so it is also the window every other one reports to. setPeerLinkRole(held); if (held) { - for (const name of wantedSingletonNames()) electSingleton(name); + // A different window may have committed ACL/enrollment writes since these + // webviews hydrated. Replace their caches before granting the Host role so + // the new holder cannot authorize from, or write back, a stale snapshot. + void refreshStoreCachesForLease().then(() => { + if (windowLeaseHeld !== true) return; + storeReadyForWindowLease = true; + for (const name of wantedSingletonNames()) electSingleton(name); + }); return; } // Lost across windows: whoever held it here must stop, not merely stop being @@ -99,7 +108,7 @@ function onWindowLeaseChange(held: boolean): void { } function electSingleton(name: string): void { - if (windowLeaseHeld !== true) return; + if (windowLeaseHeld !== true || !storeReadyForWindowLease) return; let holder = singletonHolders.get(name); if (!holder) { holder = [...singletonClaimants].find((claimant) => claimant.wants.has(name)); @@ -124,6 +133,7 @@ interface ActiveRouter { ownsPty(id: string): boolean; forwardDorControlRequest(request: DorControlRequest): void; notifyStoreChanged(key: string, value: string | null): void; + notifyStoreSnapshot(prefix: string, entries: Record): Thenable; deliverForeignData(ptyId: string, data: string): void; deliverForeignExit(ptyId: string, exitCode: number): void; ask(requestId: string, op: string, params: unknown): void; @@ -224,6 +234,16 @@ function broadcastStoreChange(key: string, value: string | null): void { for (const router of activeRouters) router.notifyStoreChanged(key, value); } +async function refreshStoreCachesForLease(): Promise { + const entries = await readStore(REMOTE_HOST_STORE_PREFIX).catch(() => ({})); + if (windowLeaseHeld !== true) return; + await Promise.all( + [...activeRouters].map((router) => + Promise.resolve(router.notifyStoreSnapshot(REMOTE_HOST_STORE_PREFIX, entries)), + ), + ); +} + const activeRouters = new Set(); let nextFlushRequestId = 0; const ALLOWED_WORKBENCH_COMMANDS = new Set(VSCODE_WORKBENCH_COMMANDS); @@ -934,6 +954,9 @@ export function attachRouter( if (disposed) return; void post({ type: 'store:changed', key, value } satisfies ExtensionMessage); }, + notifyStoreSnapshot(prefix: string, entries: Record) { + return post({ type: 'store:snapshot', prefix, entries } satisfies ExtensionMessage); + }, ask(requestId: string, op: string, params: unknown) { if (disposed) return; void post({ type: 'peer:ask', requestId, op, params } satisfies ExtensionMessage); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 1f65ad0a..011f654d 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -88,6 +88,7 @@ export type ExtensionMessage = | { type: 'store:entries'; requestId: string; entries: Record } | { type: 'singleton:lease'; name: string; held: boolean } | { type: 'store:changed'; key: string; value: string | null } + | { type: 'store:snapshot'; prefix: string; entries: Record } | { type: 'peer:ask'; requestId: string; op: string; params: unknown } | { type: 'peer:results'; requestId: string; results: unknown[] } | { From 1266f979ab8aae4f4032a08d68d27321ba924cfa Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:58:45 -0700 Subject: [PATCH 16/56] Invalidate remote directories on peer changes --- docs/specs/remote-api.md | 5 ++++ docs/specs/vscode.md | 6 +++++ lib/src/lib/platform/types.ts | 6 +++++ lib/src/lib/platform/vscode-adapter.ts | 24 ++++++++++++++++++ lib/src/lib/vscode-peer-link-protocol.ts | 4 ++- lib/src/remote/host/peer-surfaces.test.ts | 31 +++++++++++++++++++++++ lib/src/remote/host/peer-surfaces.ts | 12 +++++++++ lib/src/remote/host/remote-api.ts | 2 ++ vscode-ext/src/message-router.ts | 26 +++++++++++++++++++ vscode-ext/src/message-types.ts | 2 ++ vscode-ext/src/peer-link.ts | 24 +++++++++++++++++- vscode-ext/test/peer-link.test.ts | 12 +++++++++ 12 files changed, 152 insertions(+), 2 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 183735d8..3d450299 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -164,6 +164,11 @@ 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. +In VS Code, peer webviews and windows signal directory invalidation whenever +their pane state, activity, focus, or membership changes. The Host feeds that +signal through the same coalescer and re-queries all peers 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 diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 46140da0..277877ad 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -298,6 +298,12 @@ Absence *is* the miss: a webview that owns nothing the request named answers wit 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. +Peer query results are snapshots, so the same bridge carries generic topic +invalidations. Every webview announces `directory` when local pane state, +activity, or focus changes; webview/window membership changes invalidate all +topics. The Host subscribes to that topic and coalesces a fresh fan-out rather +than retaining the old directory indefinitely. + `attach` and `resize` on a foreign surface go to the owner rather than to the PTY, because attach-is-the-resize has to drive the live xterm or the owning pane's own view drifts from the size the phone set. The owner replies with the size it settled at and the `ptyId`; the Host then subscribes and streams. `detach` has nothing to undo on the owner — the Host stops streaming and the pane keeps its size, which is what last-attach-wins means. **Which webview owns a pane never reaches the protocol layer.** `resolveSurface(surfaceId, size)` answers with a `SurfaceHandle` — `ptyId`, the size it stands at, `resize`, `release` — or `null` if nobody owns it, and `remote-api.ts` holds one of those per attachment. That is the same trick the rest of the feature already plays: foreign `pty:data` is injected into the ordinary data path and `pty:input` / `pty:resize` route by table before falling back to the local manager, so `terminal.write` has no branch either. It makes local attach asynchronous too, which is the honest shape — a pane in another window *is* a round trip away, and the alternative was one path that answered synchronously and one that did not. diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 3e13944d..a232b88e 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -151,6 +151,12 @@ export interface PeerBridge { /** 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 for `topic` may differ. */ + notify(topic: string): void; + + /** Re-run a peer-backed subscription after another webview announces `topic`. */ + subscribe(topic: string, listener: () => void): () => void; + /** * Start receiving `pty:data` / `pty:exit` for a PTY this webview does not * own, and return the unsubscribe. A subscription, not a pair of calls, so diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index c52a4551..abd59cc3 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -43,6 +43,7 @@ export class VSCodeAdapter implements PlatformAdapter { private watchedCommandHandlers = new Set<(names: string[]) => void>(); private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>(); private singletonHandlers = new Map void>(); + private peerChangeHandlers = new Map void>>(); /** Hydrated host-store caches, by claimed prefix — see `hydrateScopedStore`. */ private scopedCaches = new Map>(); /** @@ -192,6 +193,14 @@ export class VSCodeAdapter implements PlatformAdapter { requestId: msg.requestId, results: this.peerResponders.get(msg.op)?.(msg.params) ?? [], }); + } else if (msg.type === 'peer:changed') { + if (msg.topic === null) { + for (const handlers of this.peerChangeHandlers.values()) { + for (const handler of handlers) handler(); + } + } else { + for (const handler of this.peerChangeHandlers.get(msg.topic) ?? []) handler(); + } } }); } @@ -268,6 +277,21 @@ export class VSCodeAdapter implements PlatformAdapter { respond: (op, handler) => { this.peerResponders.set(op, handler); }, + notify: (topic) => { + this.vscode.postMessage({ type: 'peer:notify', topic }); + }, + subscribe: (topic, listener) => { + let handlers = this.peerChangeHandlers.get(topic); + if (!handlers) { + handlers = new Set(); + this.peerChangeHandlers.set(topic, handlers); + } + handlers.add(listener); + return () => { + handlers!.delete(listener); + if (handlers!.size === 0) this.peerChangeHandlers.delete(topic); + }; + }, streamPty: (ptyId) => { this.vscode.postMessage({ type: 'pty:subscribe', id: ptyId }); let live = true; diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index 24e7a4bc..b780cad6 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -51,7 +51,9 @@ export type PeerLinkResponse = /** 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 }; + | { kind: 'exit'; ptyId: string; exitCode: number } + /** Unsolicited: future peer-query answers for this topic may differ. */ + | { kind: 'notify'; topic: string | null }; export type PeerLinkFrame = PeerLinkRequest | PeerLinkResponse; diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 457874e9..f5444484 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -31,6 +31,7 @@ class PeerPlatform { readonly subscribed: string[] = []; readonly unsubscribed: string[] = []; readonly ops: Array<{ surfaceId: string; op: string; cols?: number; rows?: number }> = []; + readonly peerChangeHandlers = new Map void>>(); /** Surfaces the imaginary sibling webview owns. */ peerSurfaces = new Map(); @@ -56,6 +57,16 @@ class PeerPlatform { return [{ ptyId: surface.ptyId, cols: surface.cols, rows: surface.rows }]; }, respond: () => {}, + notify: () => {}, + subscribe: (topic: string, listener: () => void) => { + let handlers = this.peerChangeHandlers.get(topic); + if (!handlers) { + handlers = new Set(); + this.peerChangeHandlers.set(topic, handlers); + } + handlers.add(listener); + return () => void handlers!.delete(listener); + }, streamPty: (id: string) => { this.subscribed.push(id); return () => void this.unsubscribed.push(id); @@ -77,6 +88,9 @@ class PeerPlatform { emitData(id: string, data: string): void { for (const handler of this.dataHandlers) handler({ id, data }); } + emitPeerChange(topic: string): void { + for (const handler of this.peerChangeHandlers.get(topic) ?? []) handler(); + } asAdapter(): PlatformAdapter { return this as unknown as PlatformAdapter; } @@ -254,4 +268,21 @@ describe('remote-api peer surfaces', () => { expect(snapshots.length).toBe(2); expect(snapshots[1]).toEqual([{ surfaceId: 'surface-far', title: 'other webview' }]); }); + + it('resnapshots when a peer directory changes', async () => { + const platform = new PeerPlatform(); + platform.peerEntries = [{ surfaceId: 'surface-far', title: 'before' }]; + const { api, sent } = session(platform); + api.handle({ requestId: 'dir-1', method: REMOTE_METHODS.directoryWatch, params: {} }); + await settle(); + + platform.peerEntries = [{ surfaceId: 'surface-far', title: 'after' }]; + platform.emitPeerChange('directory'); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const snapshots = sent + .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.directorySnapshot) + .map((p) => ((p as RemoteEventMsg).data as { entries: unknown[] }).entries); + expect(snapshots.at(-1)).toEqual([{ surfaceId: 'surface-far', title: 'after' }]); + }); }); diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index 274ccfaa..f45138ee 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -23,7 +23,9 @@ import { clampTerminalDimension, type DirectoryEntry } from 'server-lib-common'; import { getPlatform } from '../../lib/platform'; +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'; /** What the Host can ask the owner of a surface to do with it. */ @@ -124,4 +126,14 @@ function driveOwnSurface({ surfaceId, op, cols, rows }: PeerSurfaceParams): Peer export function installPeerSurfaceResponder(): void { answerPeers('directory', () => collectDirectorySnapshot()); answerPeers('surfaceOp', driveOwnSurface); + + const peers = getPlatform().peers; + if (!peers) return; + const notifyDirectory = () => peers.notify('directory'); + subscribeToTerminalPaneState(notifyDirectory); + subscribeToActivity(notifyDirectory); + if (typeof document !== 'undefined') { + document.addEventListener('focusin', notifyDirectory); + document.addEventListener('focusout', notifyDirectory); + } } diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index da4601ea..bd4ab8ff 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -177,6 +177,7 @@ export class RemoteApiSession { const trigger = () => this.#scheduleDirectory(); const unsubPane = subscribeToTerminalPaneState(trigger); const unsubActivity = subscribeToActivity(trigger); + const unsubPeers = getPlatform().peers?.subscribe('directory', trigger); const hasDocument = typeof document !== 'undefined'; if (hasDocument) { document.addEventListener('focusin', trigger); @@ -185,6 +186,7 @@ export class RemoteApiSession { this.#unsubDirectory = () => { unsubPane(); unsubActivity(); + unsubPeers?.(); if (hasDocument) { document.removeEventListener('focusin', trigger); document.removeEventListener('focusout', trigger); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 8845b8c3..e2542f2a 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -27,6 +27,7 @@ import { ensureWindowLease } from './window-lease'; import { configurePeerLink, isRemotePty, + remoteNotifyPeerChange, remoteRequest, remoteResize, remoteSubscribe, @@ -134,6 +135,7 @@ interface ActiveRouter { forwardDorControlRequest(request: DorControlRequest): void; notifyStoreChanged(key: string, value: string | null): void; notifyStoreSnapshot(prefix: string, entries: Record): Thenable; + notifyPeerChanged(topic: string | null): void; deliverForeignData(ptyId: string, data: string): void; deliverForeignExit(ptyId: string, exitCode: number): void; ask(requestId: string, op: string, params: unknown): void; @@ -154,6 +156,7 @@ const peerRequests = new Map(); // would reach them again, so it only ever gets the in-window broker. configurePeerLink({ brokerRequest, + deliverRemotePeerChange, deliverRemotePtyData, deliverRemotePtyExit, onProcessedPtyData, @@ -220,6 +223,16 @@ function deliverRemotePtyExit(ptyId: string, exitCode: number): void { for (const router of activeRouters) router.deliverForeignExit(ptyId, exitCode); } +function deliverRemotePeerChange(topic: string | null): void { + broadcastPeerChange(topic); +} + +function broadcastPeerChange(topic: string | null, exclude?: ActiveRouter): void { + for (const router of activeRouters) { + if (router !== exclude) router.notifyPeerChanged(topic); + } +} + /** * Tell every webview about a committed Host-store write. * @@ -755,6 +768,11 @@ export function attachRouter( if (request.pending.size === 0) request.settle(); break; } + case 'peer:notify': + if (typeof msg.topic !== 'string') break; + broadcastPeerChange(msg.topic, router); + remoteNotifyPeerChange(msg.topic); + break; case 'singleton:claim': // `WebviewMessage` is a claim about the sender, not a runtime check. if (typeof msg.name !== 'string') break; @@ -957,6 +975,10 @@ export function attachRouter( notifyStoreSnapshot(prefix: string, entries: Record) { return post({ type: 'store:snapshot', prefix, entries } satisfies ExtensionMessage); }, + notifyPeerChanged(topic: string | null) { + if (disposed) return; + void post({ type: 'peer:changed', topic } satisfies ExtensionMessage); + }, ask(requestId: string, op: string, params: unknown) { if (disposed) return; void post({ type: 'peer:ask', requestId, op, params } satisfies ExtensionMessage); @@ -973,6 +995,8 @@ export function attachRouter( if (disposed) return; disposed = true; activeRouters.delete(router); + broadcastPeerChange(null); + remoteNotifyPeerChange(null); // 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; @@ -998,5 +1022,7 @@ export function attachRouter( }; activeRouters.add(router); + broadcastPeerChange(null, router); + remoteNotifyPeerChange(null); return router; } diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 011f654d..adad45c6 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -38,6 +38,7 @@ export type WebviewMessage = | { type: 'pty:unsubscribe'; id: string } | { type: 'peer:request'; requestId: string; op: string; params: unknown } | { type: 'peer:answer'; requestId: string; results: unknown[] } + | { type: 'peer:notify'; topic: string } | { type: 'store:read'; prefix: string; requestId: string } | { type: 'store:write'; key: string; value: string | null } | { type: 'dormouse:init' } @@ -91,6 +92,7 @@ export type ExtensionMessage = | { type: 'store:snapshot'; prefix: string; entries: Record } | { type: 'peer:ask'; requestId: string; op: string; params: unknown } | { type: 'peer:results'; requestId: string; results: unknown[] } + | { type: 'peer:changed'; topic: string | null } | { type: 'dormouse:newTerminal'; shell?: string; diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 3b101ef4..9c66ec23 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -51,6 +51,7 @@ export interface PeerLinkDeps { brokerRequest(op: string, params: unknown): Promise; deliverRemotePtyData(ptyId: string, data: string): void; deliverRemotePtyExit(ptyId: string, exitCode: number): void; + deliverRemotePeerChange(topic: string | null): void; onProcessedPtyData(listener: (id: string, data: string) => void): () => void; writePty(ptyId: string, data: string): void; resizePty(ptyId: string, cols: number, rows: number): void; @@ -216,10 +217,11 @@ export function remoteResize(ptyId: string, cols: number, rows: number): boolean } function dropClient(client: PeerClient): void { - clients.delete(client); + 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 ptyId of forgetPeerRoutes(routes, client)) deps?.deliverRemotePtyExit(ptyId, 0); + if (wasAuthenticated) deps?.deliverRemotePeerChange(null); client.socket.destroy(); } @@ -236,6 +238,9 @@ function onServerFrame(client: PeerClient, frame: unknown): void { return; } client.authenticated = true; + // Joining changes the answer set even if no surface changed while the + // socket was down, so every peer-backed snapshot must be reconsidered. + deps?.deliverRemotePeerChange(null); return; } @@ -249,6 +254,10 @@ function onServerFrame(client: PeerClient, frame: unknown): void { deps?.deliverRemotePtyExit(response.ptyId, response.exitCode); return; } + if (response.kind === 'notify') { + deps?.deliverRemotePeerChange(response.topic); + return; + } if ('id' in response) pendingRequests.get(response.id)?.(response); } @@ -308,6 +317,7 @@ async function stopServer(): Promise { let client: Socket | null = null; let clientRetry: ReturnType | null = null; let rendezvousWatcher: FSWatcher | null = null; +const pendingNotifications = new Set(); /** PTYs this window is streaming to the broker, and how to stop. */ const forwarding = new Map void>(); @@ -315,6 +325,16 @@ function respond(frame: PeerLinkResponse): void { client?.write(encodeFrame(frame)); } +export function remoteNotifyPeerChange(topic: string | null): void { + // The broker is the destination; its in-window routers were notified directly. + if (server) return; + if (!client || client.destroyed) { + pendingNotifications.add(topic); + return; + } + respond({ kind: 'notify', topic }); +} + async function onClientFrame(frame: unknown): Promise { const request = frame as PeerLinkRequest; switch (request.kind) { @@ -378,6 +398,8 @@ async function connectClient(): Promise { socket.setEncoding('utf8'); socket.on('connect', () => { socket.write(encodeFrame({ kind: 'hello', token: rendezvous.token })); + for (const topic of pendingNotifications) socket.write(encodeFrame({ kind: 'notify', topic })); + pendingNotifications.clear(); log.info('[peer-link] connected to the broker window'); }); socket.on('data', (chunk: string) => { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 5324aae9..e632e313 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -29,6 +29,7 @@ function fakeWindow(options: { resizes: [] as Array<{ ptyId: string; cols: number; rows: number }>, delivered: [] as Array<{ ptyId: string; data: string }>, exits: [] as Array<{ ptyId: string; exitCode: number }>, + peerChanges: [] as Array, emitData(id: string, data: string) { for (const listener of dataListeners) listener(id, data); }, @@ -46,6 +47,7 @@ function fakeWindow(options: { void this.delivered.push({ ptyId, data }), deliverRemotePtyExit: (ptyId: string, exitCode: number) => void this.exits.push({ ptyId, exitCode }), + deliverRemotePeerChange: (topic: string | null) => void this.peerChanges.push(topic), onProcessedPtyData: (listener: (id: string, data: string) => void) => { dataListeners.add(listener); return () => dataListeners.delete(listener); @@ -120,6 +122,16 @@ describe('peer link between windows', () => { ]); }); + it('forwards peer change notifications to the broker window', async () => { + const { brokerSide, peer } = await linkedPair(); + brokerSide.peerChanges.length = 0; + + peer.remoteNotifyPeerChange('directory'); + + await waitFor(() => brokerSide.peerChanges.length > 0); + expect(brokerSide.peerChanges).toEqual(['directory']); + }); + it('returns nothing when no other window is connected', async () => { const broker = await openWindow(fakeWindow()); broker.setPeerLinkRole(true); From 72f5186bf7e53aa88e10fbee4e5ddcfbcd0d4de1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:59:50 -0700 Subject: [PATCH 17/56] Forward peer PTY exit events --- docs/specs/vscode.md | 2 +- vscode-ext/src/message-router.ts | 20 +++++++++++++------- vscode-ext/src/peer-link.ts | 18 +++++++++++++++--- vscode-ext/test/peer-link.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 277877ad..e6e45253 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -288,7 +288,7 @@ The extension host brokers, since it is the only party that can see every webvie - **PTY input and resize are not ownership-gated.** `pty:input` and `pty:resize` go straight to `ptyManager`, so the Host webview can already drive a sibling's PTY. - **Pane ids are unique across webviews.** They are minted `pane--` (`lib/src/components/Wall.tsx`), so surface ids need no namespacing to be routed. -- **Streaming needed one change.** `pty:data` was delivered only to the owning webview; a webview may now also `pty:subscribe` to a PTY it does not own. Subscriptions are tracked separately from `ownedPtyIds`, so they never affect Workspace union status, `killOnDispose`, or who the host considers the owner. Semantic events stay owner-only — they drive the owner's pane state, and a subscriber is streaming bytes, not keeping a second copy of that state. +- **Streaming needed one change.** `pty:data` and `pty:exit` were delivered only to the owning webview; a webview may now also `pty:subscribe` to a PTY it does not own. Subscriptions are tracked separately from `ownedPtyIds`, so they never affect Workspace union status, `killOnDispose`, or who the host considers the owner. Semantic events stay owner-only — they drive the owner's pane state, and a subscriber is streaming bytes plus process lifetime, not keeping a second copy of that state. Every webview installs a responder (`lib/src/remote/host/peer-surfaces.ts`) whether or not it is the Host, so its terminals are reachable from whichever one is. It carries none of the relay, enrollment, or pairing machinery — a registry lookup, the directory collector, and a resize. diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index e2542f2a..6db64c3d 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -160,6 +160,7 @@ configurePeerLink({ deliverRemotePtyData, deliverRemotePtyExit, onProcessedPtyData, + onProcessedPtyExit, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), }); @@ -280,6 +281,8 @@ 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(); @@ -288,6 +291,11 @@ export function onProcessedPtyData(listener: ProcessedDataListener): () => void 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); }; @@ -326,6 +334,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); }, }); @@ -526,12 +535,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) && !subscribedPtyIds.has(id)) return; + post({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); }); const removeAlertListener = alertManager.onStateChange((id, state) => { @@ -551,7 +557,7 @@ export function attachRouter( return () => { removeProcessedListener(); removeSemanticListener(); - removePtyCallbacks(); + removeExitListener(); removeAlertListener(); }; } diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 9c66ec23..f6959ede 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -53,6 +53,7 @@ export interface PeerLinkDeps { deliverRemotePtyExit(ptyId: string, exitCode: number): void; deliverRemotePeerChange(topic: string | null): void; onProcessedPtyData(listener: (id: string, data: string) => void): () => void; + onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => void; writePty(ptyId: string, data: string): void; resizePty(ptyId: string, cols: number, rows: number): void; } @@ -347,10 +348,21 @@ async function onClientFrame(frame: unknown): Promise { break; case 'subscribe': { if (forwarding.has(request.ptyId)) break; - const stop = deps?.onProcessedPtyData((id, data) => { + if (!deps) break; + const stops: Array<() => void> = []; + const stop = () => { + for (const dispose of stops) dispose(); + }; + stops.push(deps.onProcessedPtyData((id, data) => { if (id === request.ptyId) respond({ kind: 'data', ptyId: id, data }); - }); - if (stop) forwarding.set(request.ptyId, stop); + })); + stops.push(deps.onProcessedPtyExit((id, exitCode) => { + if (id !== request.ptyId) return; + respond({ kind: 'exit', ptyId: id, exitCode }); + stop(); + forwarding.delete(request.ptyId); + })); + forwarding.set(request.ptyId, stop); break; } case 'unsubscribe': diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index e632e313..520eed7f 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -22,6 +22,7 @@ function fakeWindow(options: { surfaces?: Record; } = {}) { const dataListeners = new Set<(id: string, data: string) => void>(); + const exitListeners = new Set<(id: string, exitCode: number) => void>(); return { entries: options.entries ?? [], surfaces: options.surfaces ?? {}, @@ -33,6 +34,9 @@ function fakeWindow(options: { emitData(id: string, data: string) { for (const listener of dataListeners) listener(id, data); }, + emitExit(id: string, exitCode: number) { + for (const listener of exitListeners) listener(id, exitCode); + }, deps() { return { // One generic fan-out covers every peer operation; `op` is opaque to @@ -52,6 +56,10 @@ function fakeWindow(options: { dataListeners.add(listener); return () => dataListeners.delete(listener); }, + onProcessedPtyExit: (listener: (id: string, exitCode: number) => void) => { + exitListeners.add(listener); + return () => exitListeners.delete(listener); + }, writePty: (ptyId: string, data: string) => void this.writes.push({ ptyId, data }), resizePty: (ptyId: string, cols: number, rows: number) => void this.resizes.push({ ptyId, cols, rows }), @@ -186,6 +194,20 @@ describe('peer link between windows', () => { expect(brokerSide.delivered).toEqual([]); }); + it('forwards a subscribed PTY exit and forgets its route', async () => { + const peerSide = farWindow(); + const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + await attachFar(broker); + broker.remoteSubscribe('pty-far'); + await tick(); + + peerSide.emitExit('pty-far', 17); + + await waitFor(() => brokerSide.exits.length > 0); + expect(brokerSide.exits).toEqual([{ ptyId: 'pty-far', exitCode: 17 }]); + expect(broker.isRemotePty('pty-far')).toBe(false); + }); + it('stops the stream on unsubscribe', async () => { const peerSide = farWindow(); const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); From 10455239e8dff13bde9941d3be93ebba161bfb04 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:00:31 -0700 Subject: [PATCH 18/56] Reference-count peer PTY subscriptions --- docs/specs/vscode.md | 3 ++ vscode-ext/src/message-router.ts | 7 +++-- vscode-ext/src/pty-subscriptions.ts | 35 +++++++++++++++++++++++ vscode-ext/test/pty-subscriptions.test.ts | 23 +++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 vscode-ext/src/pty-subscriptions.ts create mode 100644 vscode-ext/test/pty-subscriptions.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index e6e45253..22512a16 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -311,6 +311,9 @@ than retaining the old directory indefinitely. Resolving a peer's surface *is* the attach: the requested size travels with it, because the owner has to apply it inside that round trip — there is no reaching into its xterm afterwards without a second one. A local pane is left alone at resolve and resized by the caller, which subscribes to the PTY first so a synchronous repaint is not lost. Either way the handle reports the size as it stands and the caller reconciles, which is why the same-size repaint bounce fires for a peer attach (its owner already applied the size) and the resize path fires for a local one. Subscribing is a subscription, not a pair of calls: `peers.streamPty(ptyId)` returns its own unsubscribe, so a caller cannot leak a stream by losing track of the id it opened it with. +The router reference-counts those handles per PTY: only zero-to-one starts +cross-window forwarding and only one-to-zero stops it, so detaching one of two +concurrent viewers cannot silence the other. The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 6db64c3d..51b3bb74 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -36,6 +36,7 @@ import { setPeerLinkRole, } from './peer-link'; import { log } from './log'; +import { PtySubscriptions } from './pty-subscriptions'; import type { WebviewChannel } from './webview-messaging'; const clipboardOps = require('../../lib/clipboard-ops.cjs') as { @@ -405,7 +406,7 @@ export function attachRouter( * it never affects Workspace union status, `killOnDispose`, or which webview * the host considers the owner. */ - const subscribedPtyIds = new Set(); + const subscribedPtyIds = new PtySubscriptions(); // This webview's stake in the window-wide single-instance roles. const claimant: SingletonClaimant = { @@ -738,14 +739,14 @@ export function attachRouter( break; case 'pty:subscribe': if (typeof msg.id !== 'string') break; - subscribedPtyIds.add(msg.id); + if (!subscribedPtyIds.subscribe(msg.id)) break; // A PTY in another window has no local listener to hook; ask its window // to start sending it. if (isRemotePty(msg.id)) remoteSubscribe(msg.id); break; case 'pty:unsubscribe': if (typeof msg.id !== 'string') break; - subscribedPtyIds.delete(msg.id); + if (!subscribedPtyIds.unsubscribe(msg.id)) break; if (isRemotePty(msg.id)) remoteUnsubscribe(msg.id); break; case 'peer:request': { diff --git a/vscode-ext/src/pty-subscriptions.ts b/vscode-ext/src/pty-subscriptions.ts new file mode 100644 index 00000000..8b300e39 --- /dev/null +++ b/vscode-ext/src/pty-subscriptions.ts @@ -0,0 +1,35 @@ +/** Reference counts for one router's foreign PTY streams. */ +export class PtySubscriptions { + readonly #counts = new Map(); + + has(ptyId: string): boolean { + return this.#counts.has(ptyId); + } + + /** Add one viewer; true only for the zero-to-one transition. */ + subscribe(ptyId: string): boolean { + const count = this.#counts.get(ptyId) ?? 0; + this.#counts.set(ptyId, count + 1); + return count === 0; + } + + /** Remove one viewer; true only for the one-to-zero transition. */ + unsubscribe(ptyId: string): boolean { + const count = this.#counts.get(ptyId); + if (count === undefined) return false; + if (count > 1) { + this.#counts.set(ptyId, count - 1); + return false; + } + this.#counts.delete(ptyId); + return true; + } + + ids(): IterableIterator { + return this.#counts.keys(); + } + + clear(): void { + this.#counts.clear(); + } +} diff --git a/vscode-ext/test/pty-subscriptions.test.ts b/vscode-ext/test/pty-subscriptions.test.ts new file mode 100644 index 00000000..3ca42910 --- /dev/null +++ b/vscode-ext/test/pty-subscriptions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { PtySubscriptions } from '../src/pty-subscriptions'; + +describe('PtySubscriptions', () => { + it('keeps the stream until the final viewer unsubscribes', () => { + const subscriptions = new PtySubscriptions(); + + expect(subscriptions.subscribe('pty-1')).toBe(true); + expect(subscriptions.subscribe('pty-1')).toBe(false); + expect(subscriptions.has('pty-1')).toBe(true); + + expect(subscriptions.unsubscribe('pty-1')).toBe(false); + expect(subscriptions.has('pty-1')).toBe(true); + + expect(subscriptions.unsubscribe('pty-1')).toBe(true); + expect(subscriptions.has('pty-1')).toBe(false); + }); + + it('ignores an unmatched unsubscribe', () => { + const subscriptions = new PtySubscriptions(); + expect(subscriptions.unsubscribe('pty-missing')).toBe(false); + }); +}); From 3d4a5f70fede464b5bc51c6c89e3e86c2e4983ae Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:01:09 -0700 Subject: [PATCH 19/56] Cancel stale remote surface resolutions --- docs/specs/remote-api.md | 3 +++ lib/src/remote/host/peer-surfaces.test.ts | 25 +++++++++++++++++++++++ lib/src/remote/host/remote-api.ts | 13 ++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 3d450299..6a646681 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -261,6 +261,9 @@ 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 also invalidates an in-flight peer surface resolution; a +handle that resolves after disposal is released immediately and never becomes +an attachment. #### Size authority: last-attach-wins diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index f5444484..454cc7c1 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -36,6 +36,7 @@ class PeerPlatform { /** Surfaces the imaginary sibling webview owns. */ peerSurfaces = new Map(); peerEntries: unknown[] = []; + surfaceRequestGate: Promise | null = null; /** * One generic seam: `op` is opaque to the adapter, and a peer answers with @@ -45,6 +46,7 @@ class PeerPlatform { claimSingleton: () => {}, request: async (op: string, params: unknown) => { if (op === 'directory') return this.peerEntries; + await this.surfaceRequestGate; const { surfaceId, op: surfaceOp, cols, rows } = params as { surfaceId: string; op: string; cols?: number; rows?: number }; this.ops.push({ surfaceId, op: surfaceOp, cols, rows }); @@ -221,6 +223,29 @@ describe('remote-api peer surfaces', () => { expect(platform.unsubscribed).toEqual(['pty-far']); }); + it('releases a peer handle that resolves after session disposal', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + let finishResolve!: () => void; + platform.surfaceRequestGate = new Promise((resolve) => { + finishResolve = resolve; + }); + const { api, sent } = session(platform); + + api.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + }); + api.dispose(); + finishResolve(); + await settle(); + + expect(platform.subscribed).toEqual(['pty-far']); + expect(platform.unsubscribed).toEqual(['pty-far']); + expect(sent.some((p) => (p as RemoteResponse).requestId === 'attach-1')).toBe(false); + }); + it('fails cleanly when no webview owns the surface', async () => { const platform = new PeerPlatform(); const { api, sent } = session(platform); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index bd4ab8ff..92a74ce2 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -79,6 +79,8 @@ export class RemoteApiSession { #unsubDirectory: (() => void) | null = null; #directoryTimer: ReturnType | null = null; #attachment: Attachment | null = null; + #lifecycleGeneration = 0; + #disposed = false; constructor(options: RemoteApiSessionOptions) { this.#hostId = options.hostId; @@ -86,6 +88,7 @@ export class RemoteApiSession { } handle(data: unknown): void { + if (this.#disposed) return; const request = data as RemoteRequest; if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { return; @@ -113,6 +116,9 @@ export class RemoteApiSession { } dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#lifecycleGeneration += 1; this.#directorySubId = null; if (this.#directoryTimer) { clearTimeout(this.#directoryTimer); @@ -232,7 +238,14 @@ export class RemoteApiSession { // Where the pane lives — this webview's registry or a sibling's — is a fact // about VS Code webview hosting, not a protocol concept, so it is settled // below this line and never seen here (`surface-resolve.ts`). + const generation = this.#lifecycleGeneration; void resolveSurface(params.surfaceId, params).then((handle) => { + if (this.#disposed || this.#lifecycleGeneration !== generation) { + // A foreign resolve starts its stream before returning the handle. If + // the session died during that round trip, unwind it immediately. + handle?.release(); + return; + } if (!handle) { this.#fail(request, `no such surface: ${params.surfaceId}`); return; From 00c9fd62cd11784964d3faab88c50045444a6c55 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:02:20 -0700 Subject: [PATCH 20/56] Defer VS Code Host lease until enrollment --- docs/specs/vscode.md | 5 +- lib/src/remote/host/activation.test.ts | 79 +++++++++++++++++++++++--- lib/src/remote/host/activation.ts | 16 ++++-- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 22512a16..26ef6d40 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -272,7 +272,10 @@ A fresh claim is confirmed by re-reading: two windows can judge the same record Losing the window lease is not merely losing the right to be re-offered the role: any webview holding it is told `held: false` and stops its Host. That is the one path that sends a revocation, and it is why the lease is a boolean rather than a one-way grant. -Nothing here starts until a webview first claims `remote-host`, so a user who never enrolls a Host never gets the file or the timer. +Nothing here starts until a hydrated webview finds a persisted enrollment, or +a first enrollment succeeds and initiates the claim. A user who never enrolls +a Host therefore gets no heartbeat file, timer, or peer socket merely by +opening Dormouse. Source of truth: the rules and the cycle in `lib/src/lib/vscode-window-lease.ts` (tested in `lib/src/lib/vscode-window-lease.test.ts`), the filesystem and timers around them in `vscode-ext/src/window-lease.ts`, and `windowLeaseHeld` gating `electSingleton` in `vscode-ext/src/message-router.ts`. diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index f8b4f580..06c932d4 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -8,6 +8,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const started: Array<{ stopped: boolean }> = []; +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, +})); vi.mock('./remote-host', () => ({ RemoteHost: class { @@ -33,15 +48,20 @@ vi.mock('../../lib/push-devices', () => ({ setPushDevicesRefresher: () => {}, })); vi.mock('./enrollment', () => ({ - getEnrollment: () => ({ - serverUrl: 'https://relay.example.ts.net', - hostId: 'host-1', - hostToken: 'token', - origin: 'https://relay.example.ts.net', - rpId: 'relay.example.ts.net', - }), - clearEnrollment: () => {}, - enrollHost: async () => ({}), + getEnrollment: () => enrollmentState.current, + clearEnrollment: () => { + enrollmentState.current = null; + }, + enrollHost: async (serverUrl: string) => { + enrollmentState.current = { + serverUrl, + hostId: 'host-1', + hostToken: 'token', + origin: serverUrl, + rpId: new URL(serverUrl).hostname, + }; + return enrollmentState.current; + }, })); let claimSingleton: ((name: string, onChange: (held: boolean) => void) => void) | undefined; @@ -60,6 +80,13 @@ async function freshModule() { beforeEach(() => { started.length = 0; claimSingleton = undefined; + 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; @@ -126,6 +153,40 @@ describe('remote host activation lease', () => { expect(names).toEqual(['remote-host']); }); + it('does not claim the lease before an enrollment exists', async () => { + enrollmentState.current = null; + const names: string[] = []; + claimSingleton = (name) => void names.push(name); + + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + + expect(names).toEqual([]); + expect(started).toHaveLength(0); + }); + + it('claims after a successful first enrollment', async () => { + enrollmentState.current = null; + const names: string[] = []; + let grant!: (held: boolean) => void; + claimSingleton = (name, onChange) => { + names.push(name); + grant = onChange; + }; + const mod = await freshModule(); + mod.installRemoteHostConsoleHook(); + const hook = (globalThis as { + dormouseRemoteHost?: { enroll: (a: string, b: string, c: string) => Promise }; + }).dormouseRemoteHost!; + + await hook.enroll('https://relay.example.ts.net', 'password', 'Laptop'); + expect(names).toEqual(['remote-host']); + expect(started).toHaveLength(0); + + grant(true); + expect(started).toHaveLength(1); + }); + it('enrolling from a non-holder does not start a competing Host', async () => { await installWithLease(); const hook = (globalThis as { dormouseRemoteHost?: { enroll: (a: string, b: string, c: string) => Promise } }) diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index d68362b7..1dc60090 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -23,6 +23,7 @@ import { RemoteHost } from './remote-host'; let current: RemoteHost | null = null; let stopPush: (() => void) | null = null; +let leaseClaimRequested = false; /** * Whether this app instance is the one allowed to be the Host. @@ -129,7 +130,10 @@ export function installRemoteHostConsoleHook(): void { const peers = getPlatform().peers; if (peers) { owned = false; - peers.claimSingleton('remote-host', setRemoteHostOwnership); + if (getEnrollment()) { + leaseClaimRequested = true; + peers.claimSingleton('remote-host', setRemoteHostOwnership); + } } else { activateRemoteHost(); } @@ -139,10 +143,12 @@ export function installRemoteHostConsoleHook(): void { async enroll(serverUrl: string, password: string, label: string) { const enrollment = await enrollHost(serverUrl, password, label); stopRemoteHost(); - // Enrolling does not override the lease: a webview that is not the holder - // only persists the credentials. Nothing signals the current holder, so - // the Host starts on the next lease grant or reload, not on this call. - if (owned) current = startFromEnrollment(enrollment); + if (peers && !leaseClaimRequested) { + leaseClaimRequested = true; + peers.claimSingleton('remote-host', setRemoteHostOwnership); + } + // A synchronous grant may already have activated from persisted storage. + if (owned && !current) current = startFromEnrollment(enrollment); return { hostId: enrollment.hostId, serverUrl: enrollment.serverUrl }; }, status: remoteHostStatus, From a174f0de7820c46cfdb0087dfbfd2330ebed2e89 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:03:21 -0700 Subject: [PATCH 21/56] Reject peer listen failures --- docs/specs/vscode.md | 4 ++++ vscode-ext/src/peer-link.ts | 21 +++++++++++++++++++-- vscode-ext/test/peer-link.test.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 26ef6d40..a586984f 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -340,6 +340,10 @@ Once an answer names a `ptyId` the broker records which window it came from, bec Trust: the socket is user-owned, its path is published only in a mode-0600 file, and a client's first frame must carry the token from that file — the same bar as the `dor` control socket. +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 roles, `lib/src/lib/vscode-peer-link-protocol.ts` for the frames, framing, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`), and the `remote*` calls in `vscode-ext/src/message-router.ts`. Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, the resolver in `lib/src/remote/host/surface-resolve.ts`, and the attachment it backs in `lib/src/remote/host/remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index f6959ede..ca782b8f 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -262,6 +262,23 @@ function onServerFrame(client: PeerClient, frame: unknown): void { if ('id' in response) pendingRequests.get(response.id)?.(response); } +/** Turn Server.listen's event-based bind failure into a rejecting promise. */ +export function listenServer(nextServer: Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + nextServer.once('error', onError); + try { + nextServer.listen(socketPath, () => { + nextServer.off('error', onError); + resolve(); + }); + } catch (error) { + nextServer.off('error', onError); + reject(error); + } + }); +} + async function startServer(): Promise { const path = rendezvousPath(); if (!path || server) return; @@ -282,7 +299,7 @@ async function startServer(): Promise { try { rendezvous = next; - await new Promise((resolve) => server!.listen(next.socketPath, resolve)); + await listenServer(server, next.socketPath); await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); // The token is the only thing standing between another local process and // this window's terminals, so it is never briefly world-readable: written @@ -306,7 +323,7 @@ async function stopServer(): Promise { const path = rendezvousPath(); const socketPath = rendezvous?.socketPath; for (const client of [...clients]) dropClient(client); - server.close(); + if (server.listening) server.close(); server = null; rendezvous = null; if (socketPath) await rm(socketPath, { force: true }).catch(() => {}); diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 520eed7f..7c397ad9 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { join } from 'node:path'; +import { createServer } from 'node:net'; import { fakeContext, freshModule, removeDir, tempStorageDir, tick, waitFor, waitForFile } from './helpers'; type LinkModule = typeof import('../src/peer-link'); @@ -120,6 +121,14 @@ afterEach(async () => { }); describe('peer link between windows', () => { + 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('collects directory entries from the other window', async () => { const peerSide = fakeWindow({ entries: [{ surfaceId: 'far-1' }, { surfaceId: 'far-2' }] }); const { broker } = await linkedPair(fakeWindow(), peerSide); From 559dacf10dee730f2bbc4d7f41940e9551d0562b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:04:15 -0700 Subject: [PATCH 22/56] Handle asynchronous lease watcher errors --- docs/specs/vscode.md | 2 ++ vscode-ext/src/window-lease.ts | 18 +++++++++++++++++- vscode-ext/test/window-lease.test.ts | 20 +++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index a586984f..624937aa 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -267,6 +267,8 @@ On the webview side `activation.ts` starts un-owned whenever the adapter offers **Across windows.** The election above is per-window, because the extension host is — but the enrollment it guards is machine-wide, so window-local arbitration alone is not enough. Left there, every window would elect its own Host, all of them would connect `/ws/host` with the same enrollment, 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. So there is a second tier. A window may grant the role only while it holds a lease recorded in the extension's `globalStorageUri` — per-extension, shared by every window, and (unlike `globalState`) with no cross-window change event to depend on, so ownership is a heartbeat with a TTL rather than a flag. The holder re-stamps every 5s; a record unstamped for 15s is free. That TTL is what recovers the role from a window that died without running its disposables; a clean dispose deletes the record so the handoff is prompt, and a filesystem watcher makes the next window notice without waiting for its poll. +The watcher is only an accelerator: construction failures and later asynchronous +`error` events close and clear it, while the interval continues to arbitrate. A fresh claim is confirmed by re-reading: two windows can judge the same record stale in the same instant and both write, and the loser must not believe it won. Renewing skips that round trip, since the record already named the renewer. A heartbeat stamped far in the *future* counts as stale too — otherwise a clock jump would lock every window out of the role until the skew elapsed. diff --git a/vscode-ext/src/window-lease.ts b/vscode-ext/src/window-lease.ts index 90c08dd0..888a2b11 100644 --- a/vscode-ext/src/window-lease.ts +++ b/vscode-ext/src/window-lease.ts @@ -89,6 +89,17 @@ function setHeld(current: LeaseState, held: boolean): void { current.onChange(held); } +/** Make an FSWatcher failure degrade to polling instead of becoming uncaught. */ +export function installWatcherErrorFallback( + watcher: FSWatcher, + onUnavailable: (error: Error) => void, +): void { + watcher.once('error', (error) => { + watcher.close(); + onUnavailable(error); + }); +} + async function tick(current: LeaseState): Promise { // `state !== current` is how a disposed lease stops; a separate flag would be // a second copy of the same fact. @@ -151,7 +162,7 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { // The heartbeat alone would make a clean handoff take up to a TTL; the // watcher turns "the holder released it" into a prompt takeover. Purely // an accelerator — correctness is the timer's job. - current.watcher = watch(dir, (_event, filename) => { + const watcher = watch(dir, (_event, filename) => { if (filename && filename !== LEASE_FILE) return; // The holder's own heartbeat lands here too, and re-ticking on it turns // the heartbeat into a write loop that re-arms itself — ~50x the @@ -161,6 +172,11 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { if (current.held === true) return; void tick(current); }); + current.watcher = watcher; + installWatcherErrorFallback(watcher, (error) => { + log.error(`[window-lease] watcher failed; falling back to polling: ${String(error)}`); + if (current.watcher === watcher) current.watcher = null; + }); } catch { // No watcher on this platform/filesystem: the interval still converges. } diff --git a/vscode-ext/test/window-lease.test.ts b/vscode-ext/test/window-lease.test.ts index 65e3eedb..32e42e35 100644 --- a/vscode-ext/test/window-lease.test.ts +++ b/vscode-ext/test/window-lease.test.ts @@ -4,7 +4,8 @@ * instances — standing in for two VS Code windows — against a real directory. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fakeContext, freshModule, removeDir, tempStorageDir, waitFor } from './helpers'; @@ -33,6 +34,23 @@ afterEach(async () => { }); describe('window lease over a real directory', () => { + it('closes an asynchronously failing watcher and falls back', async () => { + const mod = await openWindow(); + const watcher = new EventEmitter() as EventEmitter & { close: ReturnType }; + watcher.close = vi.fn(); + const errors: Error[] = []; + mod.installWatcherErrorFallback( + watcher as unknown as import('node:fs').FSWatcher, + (error) => errors.push(error), + ); + + const failure = new Error('watch resources exhausted'); + watcher.emit('error', failure); + + expect(watcher.close).toHaveBeenCalledOnce(); + expect(errors).toEqual([failure]); + }); + it('acquires when nothing holds it, and records an owner', async () => { const window = await openWindow(); window.ensureWindowLease(() => {}); From 29c39db22c6c090c37c95b3738b81a07fccba23c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:04:52 -0700 Subject: [PATCH 23/56] Unwind peer streams on router disposal --- docs/specs/vscode.md | 3 +++ vscode-ext/src/message-router.ts | 4 +++- vscode-ext/src/pty-subscriptions.ts | 8 +++----- vscode-ext/test/pty-subscriptions.test.ts | 14 ++++++++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 624937aa..b3dfb9b1 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -319,6 +319,9 @@ Subscribing is a subscription, not a pair of calls: `peers.streamPty(ptyId)` ret The router reference-counts those handles per PTY: only zero-to-one starts cross-window forwarding and only one-to-zero stops it, so detaching one of two concurrent viewers cannot silence the other. +Router disposal releases every still-counted cross-window PTY once before +clearing the counts, so document teardown cannot leave an owner forwarding to +a webview that no longer exists. The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 51b3bb74..501aa4c0 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -1009,7 +1009,9 @@ export function attachRouter( if (!request.pending.delete(router)) continue; if (request.pending.size === 0) request.settle(); } - subscribedPtyIds.clear(); + subscribedPtyIds.releaseAll((ptyId) => { + if (isRemotePty(ptyId)) remoteUnsubscribe(ptyId); + }); releaseSingletons(claimant); removeWatchedCommandListener(); removeAlertSettingsListener(); diff --git a/vscode-ext/src/pty-subscriptions.ts b/vscode-ext/src/pty-subscriptions.ts index 8b300e39..048217df 100644 --- a/vscode-ext/src/pty-subscriptions.ts +++ b/vscode-ext/src/pty-subscriptions.ts @@ -25,11 +25,9 @@ export class PtySubscriptions { return true; } - ids(): IterableIterator { - return this.#counts.keys(); - } - - clear(): void { + /** Release every underlying unique stream, regardless of viewer count. */ + releaseAll(release: (ptyId: string) => void): void { + for (const ptyId of this.#counts.keys()) release(ptyId); this.#counts.clear(); } } diff --git a/vscode-ext/test/pty-subscriptions.test.ts b/vscode-ext/test/pty-subscriptions.test.ts index 3ca42910..e0fbb87a 100644 --- a/vscode-ext/test/pty-subscriptions.test.ts +++ b/vscode-ext/test/pty-subscriptions.test.ts @@ -20,4 +20,18 @@ describe('PtySubscriptions', () => { const subscriptions = new PtySubscriptions(); expect(subscriptions.unsubscribe('pty-missing')).toBe(false); }); + + it('releases each unique stream once on router disposal', () => { + const subscriptions = new PtySubscriptions(); + subscriptions.subscribe('pty-1'); + subscriptions.subscribe('pty-1'); + subscriptions.subscribe('pty-2'); + const released: string[] = []; + + subscriptions.releaseAll((ptyId) => released.push(ptyId)); + + expect(released.sort()).toEqual(['pty-1', 'pty-2']); + expect(subscriptions.has('pty-1')).toBe(false); + expect(subscriptions.has('pty-2')).toBe(false); + }); }); From d884623958cd906fe63f4cabf1a3a91f840558c4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 14:53:47 -0700 Subject: [PATCH 24/56] Fix three races the cloud review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attach: `#lifecycleGeneration` only advanced on dispose, so two attaches in one session shared a generation. A peer surface resolves over a socket round trip while a local one resolves on the next microtask, so an older attach could land last and take the attachment from the newer one, breaking last-attach-wins. Bump per attach; the superseded one is answered with an error rather than left pending on the client. Peer link: `startServer` claimed the server slot only after an await, and nothing rechecked it before publishing. A lease flipping back to client mid-startup either left a rendezvous naming a just-unlinked socket — every peer dialing it, failing, and backing off — or made a window that lost the lease serve as broker. Claim it in the same tick as the guard, recheck before the rename, and abandon the half-started server without touching whoever holds the role now. Rendezvous watcher: no 'error' listener, so an async FSWatcher failure was rethrown and killed the extension host. Same directory and same hazard the lease watcher was hardened against in 559dacf1; reuse its helper. --- docs/specs/remote-api.md | 12 ++++-- docs/specs/vscode.md | 4 +- lib/src/remote/host/peer-surfaces.test.ts | 49 +++++++++++++++++++++++ lib/src/remote/host/remote-api.ts | 17 +++++++- vscode-ext/src/peer-link.ts | 47 ++++++++++++++++++---- vscode-ext/test/peer-link.test.ts | 18 +++++++++ 6 files changed, 134 insertions(+), 13 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 6a646681..cd27a317 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -261,9 +261,15 @@ 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 also invalidates an in-flight peer surface resolution; a -handle that resolves after disposal is released immediately and never becomes -an attachment. +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. #### Size authority: last-attach-wins diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index b3dfb9b1..b0b3a841 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -331,7 +331,7 @@ The same problem one level out, and it cannot be solved the same way: VS Code ru The lease makes this one-directional. Because the webview lease is gated on the window lease, the broker window *is* the Host window — so the broker never has to relay a request back out to a remote Host, and a peer window only ever answers. -Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. +Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. Startup is several awaits long (bind, then write-and-rename), so the window claims the server slot in the same tick it decides to serve and re-checks that it still holds it before renaming the rendezvous into place: a lease that flips back to client mid-startup abandons the half-started server instead of publishing a rendezvous naming a socket the teardown already unlinked, which every peer would dial, fail on, and back off from until some later broker rewrote the file. The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. The first arbitration result is a role transition even when it is `false`: a window that starts while another owns the lease immediately enters the client @@ -357,7 +357,7 @@ Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerReques `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`: `test/window-lease.test.ts` drives two module instances against a real directory (two windows contending, and a handover on dispose), and `test/peer-link.test.ts` stands up a broker and a peer over a real socket to cover the rendezvous handshake, PTY routing, streaming, token rejection, and what a disconnect does to in-flight terminals. Separate module instances come from `vi.resetModules()` plus a dynamic import, which is what makes one process able to play two windows. +The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`: `test/window-lease.test.ts` drives two module instances against a real directory (two windows contending, and a handover on dispose), and `test/peer-link.test.ts` stands up a broker and a peer over a real socket to cover the rendezvous handshake, a lease that flips back mid-startup, PTY routing, streaming, token rejection, and what a disconnect does to in-flight terminals. 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`. diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 454cc7c1..b02e9bcc 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -10,7 +10,9 @@ import { REMOTE_EVENTS, REMOTE_METHODS, fromBase64Url, + toBase64Url, utf8Decode, + utf8Encode, type RemoteEventMsg, type RemoteResponse, } from 'server-lib-common'; @@ -246,6 +248,53 @@ describe('remote-api peer surfaces', () => { expect(sent.some((p) => (p as RemoteResponse).requestId === 'attach-1')).toBe(false); }); + it('does not let a gated peer attach outrank the newer attach that replaced it', async () => { + const platform = new PeerPlatform(); + platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); + const terminal = { cols: 80, rows: 24, resize: vi.fn() }; + registry.set('surface-near', { ptyId: 'pty-near', terminal } as unknown as TerminalEntry); + let finishResolve!: () => void; + platform.surfaceRequestGate = new Promise((resolve) => { + finishResolve = resolve; + }); + const { api, sent } = session(platform); + + // The client attaches a sibling's pane and switches to a local one before + // the sibling answers. The local resolve is a microtask, the peer's a round + // trip, so they land out of order. + api.handle({ + requestId: 'attach-far', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + }); + api.handle({ + requestId: 'attach-near', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId: 'surface-near', cols: 100, rows: 30 }, + }); + await settle(); + finishResolve(); + await settle(); + + // Last attach wins: the superseded one unwinds the stream it opened on the + // way instead of tearing down the newer attachment, and is answered rather + // than left pending on the client forever. + expect(platform.unsubscribed).toEqual(['pty-far']); + const near = sent.find((p) => (p as RemoteResponse).requestId === 'attach-near') as RemoteResponse; + expect(near.ok).toBe(true); + const far = sent.find((p) => (p as RemoteResponse).requestId === 'attach-far') as RemoteResponse; + expect(far.ok).toBe(false); + expect(far.error).toMatch(/superseded/); + + // Input still reaches the surface the client actually attached. + api.handle({ + requestId: 'write-1', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-near', bytes: toBase64Url(utf8Encode('ls')) }, + }); + expect(platform.writePty).toHaveBeenCalledWith('pty-near', 'ls'); + }); + it('fails cleanly when no webview owns the surface', async () => { const platform = new PeerPlatform(); const { api, sent } = session(platform); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 92a74ce2..40d9ae05 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -238,12 +238,27 @@ export class RemoteApiSession { // Where the pane lives — this webview's registry or a sibling's — is a fact // about VS Code webview hosting, not a protocol concept, so it is settled // below this line and never seen here (`surface-resolve.ts`). + // + // Bumped per attach, not only per session: last-attach-wins + // (docs/specs/remote-api.md) has to hold even while a resolve is in flight, + // and the two paths are wildly different lengths — a sibling's pane is a + // socket round trip away while a local one settles on the next microtask. + // Sharing one generation across concurrent attaches would let the older, + // slower one land last and steal the attachment from the newer one. + this.#lifecycleGeneration += 1; const generation = this.#lifecycleGeneration; void resolveSurface(params.surfaceId, params).then((handle) => { if (this.#disposed || this.#lifecycleGeneration !== generation) { // A foreign resolve starts its stream before returning the handle. If - // the session died during that round trip, unwind it immediately. + // the session died or a newer attach superseded this one during that + // round trip, unwind it immediately. handle?.release(); + // The client holds a request pending until it is answered, so a + // superseded attach is failed rather than dropped — that also drops its + // event subscription. A disposed session has no transport to answer on. + if (!this.#disposed) { + this.#fail(request, `superseded by a newer attach: ${params.surfaceId}`); + } return; } if (!handle) { diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index ca782b8f..c9a74cd8 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -40,6 +40,7 @@ import { type PeerLinkResponse, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; +import { installWatcherErrorFallback } from './window-lease'; /** * What this module needs from the router, injected rather than imported: the @@ -279,14 +280,22 @@ export function listenServer(nextServer: Server, socketPath: string): Promise { + if (orphan.listening) orphan.close(); + await rm(socketPath, { force: true }).catch(() => {}); +} + async function startServer(): Promise { const path = rendezvousPath(); if (!path || server) return; const next: Rendezvous = { socketPath: newSocketPath(), token: randomUUID() }; - await rm(next.socketPath, { force: true }).catch(() => {}); - - server = createServer((socket) => { + const nextServer = createServer((socket) => { const client: PeerClient = { socket, decoder: new FrameDecoder(), authenticated: false }; clients.add(client); socket.setEncoding('utf8'); @@ -296,10 +305,20 @@ async function startServer(): Promise { socket.on('error', () => dropClient(client)); socket.on('close', () => dropClient(client)); }); + // Claimed in the same tick as the guard above so that `server === nextServer` + // is the whole staleness test below — nothing can slip in between. Startup is + // several awaits long and the lease can flip back to client inside any of + // them, which runs `stopServer` and nulls `server`; a continuation that did + // not notice would publish a rendezvous naming a socket that is already + // unlinked, and every peer would dial it, fail, and sit in the reconnect + // backoff until some later broker rewrote the file. + server = nextServer; + rendezvous = next; try { - rendezvous = next; - await listenServer(server, next.socketPath); + await rm(next.socketPath, { force: true }).catch(() => {}); + if (server !== nextServer) return; + await listenServer(nextServer, next.socketPath); await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); // The token is the only thing standing between another local process and // this window's terminals, so it is never briefly world-readable: written @@ -307,6 +326,11 @@ async function startServer(): Promise { // never sees a half-written rendezvous and falls into the retry backoff. const temp = `${path}.${randomUUID()}.tmp`; await writeFile(temp, JSON.stringify(next), { encoding: 'utf8', mode: 0o600 }); + if (server !== nextServer) { + await rm(temp, { force: true }).catch(() => {}); + await abandonServer(nextServer, next.socketPath); + return; + } await rename(temp, path); log.info('[peer-link] serving peers'); } catch (err) { @@ -314,7 +338,8 @@ async function startServer(): Promise { // would surface as an unhandled one rather than as a broken link. An // unwritable globalStorage means no peers, not a crashed extension host. log.error(`[peer-link] could not start serving: ${String(err)}`); - await stopServer(); + if (server === nextServer) await stopServer(); + else await abandonServer(nextServer, next.socketPath); } } @@ -492,11 +517,19 @@ export function setPeerLinkRole(isBroker: boolean): void { function watchRendezvous(): void { if (rendezvousWatcher || !context) return; try { - rendezvousWatcher = watch(context.globalStorageUri.fsPath, (_event, filename) => { + const watcher = watch(context.globalStorageUri.fsPath, (_event, filename) => { if (filename && filename !== RENDEZVOUS_FILE) return; disconnectClient(); void connectClient(); }); + rendezvousWatcher = watcher; + // Same directory, same hazard as the lease watcher: an inotify handle the + // kernel invalidates emits asynchronously, and an unheard 'error' on an + // EventEmitter is rethrown — taking the whole extension host with it. + installWatcherErrorFallback(watcher, (error) => { + log.error(`[peer-link] rendezvous watcher failed; the reconnect timer converges: ${String(error)}`); + if (rendezvousWatcher === watcher) rendezvousWatcher = null; + }); } catch { // No watcher here: the reconnect timer still converges. } diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 7c397ad9..bb9260ba 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -8,6 +8,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { access } from 'node:fs/promises'; import { join } from 'node:path'; import { createServer } from 'node:net'; import { fakeContext, freshModule, removeDir, tempStorageDir, tick, waitFor, waitForFile } from './helpers'; @@ -270,6 +271,23 @@ describe('peer link between windows', () => { expect(broker.remoteWrite('pty-far', 'x')).toBe(false); }); + it('publishes no rendezvous when the lease flips back mid-startup', async () => { + const mod = await openWindow(fakeWindow()); + + // Same tick: startup is several awaits long, so the flip back lands inside + // it. A rendezvous published afterwards would name a socket the teardown + // already unlinked, and every peer would dial it, fail, and sit in the + // reconnect backoff until some later broker rewrote the file. + mod.setPeerLinkRole(true); + mod.setPeerLinkRole(false); + await tick(200); + + await expect(access(join(dir, 'remote-host.peer.json'))).rejects.toHaveProperty( + 'code', + 'ENOENT', + ); + }); + it('rejects a client that does not know the token', async () => { const brokerSide = fakeWindow(); const broker = await openWindow(brokerSide); From 427f017f714424a50be06ebd8875ea4342a7cfd1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 15:11:19 -0700 Subject: [PATCH 25/56] Simplify the peer-link and attach staleness guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the previous commit, no behavior change beyond the two noted below. peer-link: `startServer` had three staleness branches with three cleanup shapes; now one `abandon()` closure gives back exactly what the attempt claimed, and `stopServer` shares its close-and-unlink with it instead of spelling it out a second time. `setPeerLinkRole` records the role it is transitioning into, so the client branch no longer installs the rendezvous watcher when the lease flipped back to broker while it was standing down — a broker watching wakes itself on its own writes. The `fs.watch` dance the last commit copied out of window-lease is now one `watch-dir-file.ts` owning both failure modes, which also stops peer-link importing the lease module it is deliberately decoupled from. remote-api: `#lifecycleGeneration` became `#attachGeneration`, since the attach epoch is all it tracks — the bump in `dispose()` was dead, `#disposed` is checked first and never cleared. Tests: the flip-back test asserts nothing is left behind at all (rendezvous, socket, temp) rather than one missing file, with sockets pointed at the test's own directory; shared fixtures replace the copied registry and gate setup. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 5 +- lib/src/remote/host/peer-surfaces.test.ts | 38 +++++---- lib/src/remote/host/remote-api.ts | 19 ++--- vscode-ext/src/peer-link.ts | 98 ++++++++++++++--------- vscode-ext/src/watch-dir-file.ts | 48 +++++++++++ vscode-ext/src/window-lease.ts | 40 ++++----- vscode-ext/test/peer-link.test.ts | 43 +++++----- vscode-ext/test/watch-dir-file.test.ts | 46 +++++++++++ vscode-ext/test/window-lease.test.ts | 20 +---- 9 files changed, 226 insertions(+), 131 deletions(-) create mode 100644 vscode-ext/src/watch-dir-file.ts create mode 100644 vscode-ext/test/watch-dir-file.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index b0b3a841..8fe48a61 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -26,6 +26,7 @@ Extension Host (vscode-ext/src/) ├── window-lease.ts — cross-window Host lease: heartbeat record in globalStorageUri ├── peer-link.ts — socket between windows: broker serves, other windows report in │ (peer-surface brokering lives in message-router.ts) +├── watch-dir-file.ts — fs.watch on one file, degrading to no watcher instead of an uncaught error ├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` ├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging @@ -266,7 +267,7 @@ On the webview side `activation.ts` starts un-owned whenever the adapter offers **Across windows.** The election above is per-window, because the extension host is — but the enrollment it guards is machine-wide, so window-local arbitration alone is not enough. Left there, every window would elect its own Host, all of them would connect `/ws/host` with the same enrollment, 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. -So there is a second tier. A window may grant the role only while it holds a lease recorded in the extension's `globalStorageUri` — per-extension, shared by every window, and (unlike `globalState`) with no cross-window change event to depend on, so ownership is a heartbeat with a TTL rather than a flag. The holder re-stamps every 5s; a record unstamped for 15s is free. That TTL is what recovers the role from a window that died without running its disposables; a clean dispose deletes the record so the handoff is prompt, and a filesystem watcher makes the next window notice without waiting for its poll. +So there is a second tier. A window may grant the role only while it holds a lease recorded in the extension's `globalStorageUri` — per-extension, shared by every window, and (unlike `globalState`) with no cross-window change event to depend on, so ownership is a heartbeat with a TTL rather than a flag. The holder re-stamps every 5s; a record unstamped for 15s is free. That TTL is what recovers the role from a window that died without running its disposables; a clean dispose deletes the record so the handoff is prompt, and a filesystem watcher makes the next window notice without waiting for its poll. Both watchers in the extension — this one and the rendezvous — go through `watch-dir-file.ts`, which turns either kind of `fs.watch` failure (refused up front, or an `'error'` event later, which an unheard `EventEmitter` rethrows and would kill the extension host) into no watcher at all; that is safe precisely because each caller's timer converges on its own. The watcher is only an accelerator: construction failures and later asynchronous `error` events close and clear it, while the interval continues to arbitrate. @@ -331,7 +332,7 @@ The same problem one level out, and it cannot be solved the same way: VS Code ru The lease makes this one-directional. Because the webview lease is gated on the window lease, the broker window *is* the Host window — so the broker never has to relay a request back out to a remote Host, and a peer window only ever answers. -Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. Startup is several awaits long (bind, then write-and-rename), so the window claims the server slot in the same tick it decides to serve and re-checks that it still holds it before renaming the rendezvous into place: a lease that flips back to client mid-startup abandons the half-started server instead of publishing a rendezvous naming a socket the teardown already unlinked, which every peer would dial, fail on, and back off from until some later broker rewrote the file. The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. +Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. Neither transition is instant — serving binds a socket and then writes and renames the rendezvous, standing down tears that back down — so a flip can land inside one, and each direction re-checks the role it is transitioning into before its last step: the broker claims the server slot in the same tick it decides to serve and abandons a half-started server rather than publishing a rendezvous naming a socket the teardown already unlinked (peers would dial it, fail, and back off until a later broker rewrote the file), and the client side skips installing the rendezvous watcher if it is the broker again by the time its teardown finishes (a broker watching would wake on its own writes). The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. The first arbitration result is a role transition even when it is `false`: a window that starts while another owns the lease immediately enters the client diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index b02e9bcc..e2ce4cd1 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -108,6 +108,22 @@ function decodeTerminalData(payload: SentPayload): string { /** Let the peer round trips (they are promises) settle. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +/** A pane in *this* webview's registry, which resolves without asking anyone. */ +function registerLocalSurface(surfaceId: string, ptyId: string) { + const terminal = { cols: 80, rows: 24, resize: vi.fn() }; + registry.set(surfaceId, { ptyId, terminal } as unknown as TerminalEntry); + return terminal; +} + +/** Hold every peer surface round trip open until the returned function is called. */ +function gatePeers(platform: PeerPlatform): () => void { + let release!: () => void; + platform.surfaceRequestGate = new Promise((resolve) => { + release = resolve; + }); + return release; +} + describe('remote-api peer surfaces', () => { afterEach(() => { registry.clear(); @@ -228,10 +244,7 @@ describe('remote-api peer surfaces', () => { it('releases a peer handle that resolves after session disposal', async () => { const platform = new PeerPlatform(); platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - let finishResolve!: () => void; - platform.surfaceRequestGate = new Promise((resolve) => { - finishResolve = resolve; - }); + const finishResolve = gatePeers(platform); const { api, sent } = session(platform); api.handle({ @@ -251,17 +264,12 @@ describe('remote-api peer surfaces', () => { it('does not let a gated peer attach outrank the newer attach that replaced it', async () => { const platform = new PeerPlatform(); platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - const terminal = { cols: 80, rows: 24, resize: vi.fn() }; - registry.set('surface-near', { ptyId: 'pty-near', terminal } as unknown as TerminalEntry); - let finishResolve!: () => void; - platform.surfaceRequestGate = new Promise((resolve) => { - finishResolve = resolve; - }); + registerLocalSurface('surface-near', 'pty-near'); + const finishResolve = gatePeers(platform); const { api, sent } = session(platform); // The client attaches a sibling's pane and switches to a local one before - // the sibling answers. The local resolve is a microtask, the peer's a round - // trip, so they land out of order. + // the sibling answers, so the two resolves land out of order. api.handle({ requestId: 'attach-far', method: REMOTE_METHODS.surfaceAttach, @@ -277,8 +285,7 @@ describe('remote-api peer surfaces', () => { await settle(); // Last attach wins: the superseded one unwinds the stream it opened on the - // way instead of tearing down the newer attachment, and is answered rather - // than left pending on the client forever. + // way instead of tearing down the newer attachment. expect(platform.unsubscribed).toEqual(['pty-far']); const near = sent.find((p) => (p as RemoteResponse).requestId === 'attach-near') as RemoteResponse; expect(near.ok).toBe(true); @@ -312,8 +319,7 @@ describe('remote-api peer surfaces', () => { it('prefers a local surface without asking any peer', async () => { const platform = new PeerPlatform(); - const terminal = { cols: 80, rows: 24, resize: vi.fn() }; - registry.set('surface-near', { ptyId: 'pty-near', terminal } as unknown as TerminalEntry); + registerLocalSurface('surface-near', 'pty-near'); const { api } = session(platform); api.handle({ diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 40d9ae05..751febe2 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -79,7 +79,7 @@ export class RemoteApiSession { #unsubDirectory: (() => void) | null = null; #directoryTimer: ReturnType | null = null; #attachment: Attachment | null = null; - #lifecycleGeneration = 0; + #attachGeneration = 0; #disposed = false; constructor(options: RemoteApiSessionOptions) { @@ -118,7 +118,6 @@ export class RemoteApiSession { dispose(): void { if (this.#disposed) return; this.#disposed = true; - this.#lifecycleGeneration += 1; this.#directorySubId = null; if (this.#directoryTimer) { clearTimeout(this.#directoryTimer); @@ -239,16 +238,14 @@ export class RemoteApiSession { // about VS Code webview hosting, not a protocol concept, so it is settled // below this line and never seen here (`surface-resolve.ts`). // - // Bumped per attach, not only per session: last-attach-wins - // (docs/specs/remote-api.md) has to hold even while a resolve is in flight, - // and the two paths are wildly different lengths — a sibling's pane is a - // socket round trip away while a local one settles on the next microtask. - // Sharing one generation across concurrent attaches would let the older, - // slower one land last and steal the attachment from the newer one. - this.#lifecycleGeneration += 1; - const generation = this.#lifecycleGeneration; + // 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 resolveSurface(params.surfaceId, params).then((handle) => { - if (this.#disposed || this.#lifecycleGeneration !== generation) { + 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. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index c9a74cd8..d8c7b73f 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -23,7 +23,7 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { createConnection, createServer, type Server, type Socket } from 'node:net'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { watch, type FSWatcher } from 'node:fs'; +import { type FSWatcher } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -40,7 +40,7 @@ import { type PeerLinkResponse, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; -import { installWatcherErrorFallback } from './window-lease'; +import { watchDirFile } from './watch-dir-file'; /** * What this module needs from the router, injected rather than imported: the @@ -119,8 +119,10 @@ interface PeerClient { authenticated: boolean; } +/** The role the lease last asked for; an in-flight transition re-reads it. */ +let brokerRole = false; let server: Server | null = null; -/** Set exactly while `server` is listening; the two move together. */ +/** Claimed and cleared with `server`; the two always move together. */ let rendezvous: Rendezvous | null = null; const clients = new Set(); const routes = new Map(); @@ -280,13 +282,9 @@ export function listenServer(nextServer: Server, socketPath: string): Promise { - if (orphan.listening) orphan.close(); +/** Close one server and unlink its socket. Touches no module state. */ +async function closeServer(target: Server, socketPath: string): Promise { + if (target.listening) target.close(); await rm(socketPath, { force: true }).catch(() => {}); } @@ -295,6 +293,7 @@ async function startServer(): Promise { if (!path || server) return; const next: Rendezvous = { socketPath: newSocketPath(), token: randomUUID() }; + const temp = `${path}.${randomUUID()}.tmp`; const nextServer = createServer((socket) => { const client: PeerClient = { socket, decoder: new FrameDecoder(), authenticated: false }; clients.add(client); @@ -305,30 +304,45 @@ async function startServer(): Promise { socket.on('error', () => dropClient(client)); socket.on('close', () => dropClient(client)); }); - // Claimed in the same tick as the guard above so that `server === nextServer` - // is the whole staleness test below — nothing can slip in between. Startup is - // several awaits long and the lease can flip back to client inside any of - // them, which runs `stopServer` and nulls `server`; a continuation that did - // not notice would publish a rendezvous naming a socket that is already - // unlinked, and every peer would dial it, fail, and sit in the reconnect - // backoff until some later broker rewrote the file. + // Claimed in the same tick as the guard above, which is what makes + // `server === nextServer` a complete staleness test: nothing can slip in + // between. Everything below awaits, and the lease can flip back to client + // inside any of those gaps. server = nextServer; rendezvous = next; + /** + * Give back what this attempt claimed, leaving whoever holds the role now + * alone — `stopServer` would unlink a newer broker's socket and rendezvous + * along with this one. Anyone who connected in the meantime is left to the + * socket's own 'close' handler. + */ + const abandon = async (): Promise => { + await closeServer(nextServer, next.socketPath); + await rm(temp, { force: true }).catch(() => {}); + }; + try { + // The bind fails hard if anything owns this path. Nothing should — it is + // six fresh random bytes — and clearing it first is one fs call on a path + // taken once per lease acquisition. await rm(next.socketPath, { force: true }).catch(() => {}); - if (server !== nextServer) return; + if (server !== nextServer) { + await abandon(); + return; + } await listenServer(nextServer, next.socketPath); await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); // The token is the only thing standing between another local process and // this window's terminals, so it is never briefly world-readable: written // 0600 to a temp file and renamed into place, which also means a reader // never sees a half-written rendezvous and falls into the retry backoff. - const temp = `${path}.${randomUUID()}.tmp`; await writeFile(temp, JSON.stringify(next), { encoding: 'utf8', mode: 0o600 }); + // Stopped while we were publishing: the socket named in there is already + // unlinked, so renaming it into place would leave every peer dialing a + // dead path until some later broker rewrote the file. if (server !== nextServer) { - await rm(temp, { force: true }).catch(() => {}); - await abandonServer(nextServer, next.socketPath); + await abandon(); return; } await rename(temp, path); @@ -338,20 +352,20 @@ async function startServer(): Promise { // would surface as an unhandled one rather than as a broken link. An // unwritable globalStorage means no peers, not a crashed extension host. log.error(`[peer-link] could not start serving: ${String(err)}`); + await abandon(); if (server === nextServer) await stopServer(); - else await abandonServer(nextServer, next.socketPath); } } async function stopServer(): Promise { - if (!server) return; + const closing = server; + if (!closing) return; const path = rendezvousPath(); const socketPath = rendezvous?.socketPath; for (const client of [...clients]) dropClient(client); - if (server.listening) server.close(); server = null; rendezvous = null; - if (socketPath) await rm(socketPath, { force: true }).catch(() => {}); + if (socketPath) await closeServer(closing, socketPath); if (path) await rm(path, { force: true }).catch(() => {}); } @@ -493,8 +507,15 @@ function disconnectClient(): void { /** * Follow the window lease: the holder serves, everyone else connects to it. * Called on every lease change, and idempotent for an unchanged role. + * + * Either direction takes several awaits to settle and another flip can land + * inside them, so each branch re-checks the role it is transitioning into + * rather than assuming it still holds: `brokerRole` on the client side, and + * `server === nextServer` on the broker side, which additionally tells a later + * startup that already claimed the slot from this one. */ export function setPeerLinkRole(isBroker: boolean): void { + brokerRole = isBroker; if (isBroker) { disconnectClient(); stopWatchingRendezvous(); @@ -503,6 +524,9 @@ export function setPeerLinkRole(isBroker: boolean): void { } void (async () => { await stopServer(); + // Flipped back to broker while that was tearing down: a broker must not + // watch the rendezvous, or it wakes itself on its own writes. + if (brokerRole) return; watchRendezvous(); await connectClient(); })(); @@ -516,23 +540,19 @@ export function setPeerLinkRole(isBroker: boolean): void { */ function watchRendezvous(): void { if (rendezvousWatcher || !context) return; - try { - const watcher = watch(context.globalStorageUri.fsPath, (_event, filename) => { - if (filename && filename !== RENDEZVOUS_FILE) return; + const watcher = watchDirFile( + context.globalStorageUri.fsPath, + RENDEZVOUS_FILE, + () => { disconnectClient(); void connectClient(); - }); - rendezvousWatcher = watcher; - // Same directory, same hazard as the lease watcher: an inotify handle the - // kernel invalidates emits asynchronously, and an unheard 'error' on an - // EventEmitter is rethrown — taking the whole extension host with it. - installWatcherErrorFallback(watcher, (error) => { - log.error(`[peer-link] rendezvous watcher failed; the reconnect timer converges: ${String(error)}`); + }, + (error) => { + log.error(`[peer-link] rendezvous watcher failed; the timer converges: ${String(error)}`); if (rendezvousWatcher === watcher) rendezvousWatcher = null; - }); - } catch { - // No watcher here: the reconnect timer still converges. - } + }, + ); + rendezvousWatcher = watcher; } function stopWatchingRendezvous(): void { diff --git a/vscode-ext/src/watch-dir-file.ts b/vscode-ext/src/watch-dir-file.ts new file mode 100644 index 00000000..62e8eab6 --- /dev/null +++ b/vscode-ext/src/watch-dir-file.ts @@ -0,0 +1,48 @@ +/** + * Watch one file in a directory, or do without. + * + * Two things in this extension want the same watch over `globalStorageUri` — + * the Host lease and the peer-link rendezvous — and both want it for the same + * reason: their own timer already converges, and the watcher only makes the + * convergence prompt. That is what makes "no watcher" a complete answer here + * rather than a failure to report. + * + * `fs.watch` can fail twice over. Synchronously, when the platform or + * filesystem cannot watch at all; and asynchronously, with an `'error'` event + * once it is running (an inotify handle the kernel invalidated, the directory + * removed or remounted, watch resources exhausted). The second is the + * dangerous one: an `EventEmitter` rethrows an unheard `'error'`, so a watcher + * nobody listens to takes the whole extension host down — every extension in + * it, not just this one. Both failures land in the same place here. + */ + +import { watch, type FSWatcher } from 'node:fs'; + +/** + * Call `onChange` when `file` changes in `dir`, or return `null` if this + * platform will not watch it. A watcher that fails later closes itself and + * reports through `onUnavailable`, which is where the caller drops its handle; + * it never fires more than once. + */ +export function watchDirFile( + dir: string, + file: string, + onChange: () => void, + onUnavailable: (error: Error) => void, +): FSWatcher | null { + try { + const watcher = watch(dir, (_event, filename) => { + // A rename reports no filename on some platforms; take it rather than + // miss the change. + if (filename && filename !== file) return; + onChange(); + }); + watcher.once('error', (error: Error) => { + watcher.close(); + onUnavailable(error); + }); + return watcher; + } catch { + return null; + } +} diff --git a/vscode-ext/src/window-lease.ts b/vscode-ext/src/window-lease.ts index 888a2b11..3e7a0b4e 100644 --- a/vscode-ext/src/window-lease.ts +++ b/vscode-ext/src/window-lease.ts @@ -14,7 +14,7 @@ */ import { randomUUID } from 'node:crypto'; -import { watch, type FSWatcher } from 'node:fs'; +import { type FSWatcher } from 'node:fs'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -27,6 +27,7 @@ import { type WindowLeaseRecord, } from '../../lib/src/lib/vscode-window-lease'; import { log } from './log'; +import { watchDirFile } from './watch-dir-file'; const LEASE_FILE = 'remote-host.lease.json'; @@ -89,17 +90,6 @@ function setHeld(current: LeaseState, held: boolean): void { current.onChange(held); } -/** Make an FSWatcher failure degrade to polling instead of becoming uncaught. */ -export function installWatcherErrorFallback( - watcher: FSWatcher, - onUnavailable: (error: Error) => void, -): void { - watcher.once('error', (error) => { - watcher.close(); - onUnavailable(error); - }); -} - async function tick(current: LeaseState): Promise { // `state !== current` is how a disposed lease stops; a separate flag would be // a second copy of the same fact. @@ -158,12 +148,14 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { await tick(current); current.timer = setInterval(() => void tick(current), LEASE_RENEW_MS); - try { - // The heartbeat alone would make a clean handoff take up to a TTL; the - // watcher turns "the holder released it" into a prompt takeover. Purely - // an accelerator — correctness is the timer's job. - const watcher = watch(dir, (_event, filename) => { - if (filename && filename !== LEASE_FILE) return; + // The heartbeat alone would make a clean handoff take up to a TTL; the + // watcher turns "the holder released it" into a prompt takeover. Purely an + // accelerator — correctness is the timer's job, which is why no watcher at + // all is an acceptable answer. + const watcher = watchDirFile( + dir, + LEASE_FILE, + () => { // The holder's own heartbeat lands here too, and re-ticking on it turns // the heartbeat into a write loop that re-arms itself — ~50x the // intended I/O, with overlapping writes colliding and each failure @@ -171,15 +163,13 @@ export function ensureWindowLease(onChange: (held: boolean) => void): void { // accelerator. if (current.held === true) return; void tick(current); - }); - current.watcher = watcher; - installWatcherErrorFallback(watcher, (error) => { + }, + (error) => { log.error(`[window-lease] watcher failed; falling back to polling: ${String(error)}`); if (current.watcher === watcher) current.watcher = null; - }); - } catch { - // No watcher on this platform/filesystem: the interval still converges. - } + }, + ); + current.watcher = watcher; })(); context.subscriptions.push({ dispose: () => void disposeWindowLease() }); diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index bb9260ba..15072008 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -8,7 +8,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { access } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { createServer } from 'node:net'; import { fakeContext, freshModule, removeDir, tempStorageDir, tick, waitFor, waitForFile } from './helpers'; @@ -78,7 +78,8 @@ async function openWindow(deps: ReturnType): Promise waitForFile(join(dir, 'remote-host.peer.json')); +const rendezvousFile = () => join(dir, 'remote-host.peer.json'); +const waitForRendezvous = () => waitForFile(rendezvousFile()); /** Attach to the terminal {@link farWindow} owns, which is what places its route. */ const attachFar = (broker: LinkModule) => @@ -271,32 +272,36 @@ describe('peer link between windows', () => { expect(broker.remoteWrite('pty-far', 'x')).toBe(false); }); - it('publishes no rendezvous when the lease flips back mid-startup', async () => { - const mod = await openWindow(fakeWindow()); - - // Same tick: startup is several awaits long, so the flip back lands inside - // it. A rendezvous published afterwards would name a socket the teardown - // already unlinked, and every peer would dial it, fail, and sit in the - // reconnect backoff until some later broker rewrote the file. - mod.setPeerLinkRole(true); - mod.setPeerLinkRole(false); - await tick(200); - - await expect(access(join(dir, 'remote-host.peer.json'))).rejects.toHaveProperty( - 'code', - 'ENOENT', - ); + it('leaves nothing behind when the lease flips back mid-startup', async () => { + // Peer sockets live in the temp dir; point that at this test's own storage + // dir so a server nobody closed is as visible as a file nobody removed. + const realTmp = process.env.TMPDIR; + process.env.TMPDIR = dir; + try { + const mod = await openWindow(fakeWindow()); + + // Both calls in one tick, so the flip back lands inside startup's awaits. + mod.setPeerLinkRole(true); + mod.setPeerLinkRole(false); + await tick(); + + // No rendezvous (peers would dial a socket the teardown already unlinked + // and back off until some later broker rewrote the file), no listening + // socket, and no temp file from the write that was abandoned. + expect(await readdir(dir)).toEqual([]); + } finally { + process.env.TMPDIR = realTmp; + } }); it('rejects a client that does not know the token', async () => { const brokerSide = fakeWindow(); const broker = await openWindow(brokerSide); broker.setPeerLinkRole(true); - const rendezvousPath = join(dir, 'remote-host.peer.json'); await waitForRendezvous(); const { readFile } = await import('node:fs/promises'); - const { socketPath } = JSON.parse(await readFile(rendezvousPath, 'utf8')); + const { socketPath } = JSON.parse(await readFile(rendezvousFile(), 'utf8')); const { createConnection } = await import('node:net'); const socket = createConnection({ path: socketPath }); await new Promise((resolve) => socket.on('connect', resolve)); diff --git a/vscode-ext/test/watch-dir-file.test.ts b/vscode-ext/test/watch-dir-file.test.ts new file mode 100644 index 00000000..c9f44444 --- /dev/null +++ b/vscode-ext/test/watch-dir-file.test.ts @@ -0,0 +1,46 @@ +/** + * The one thing this helper exists for is failing safely: `fs.watch` can refuse + * up front or die later, and a later death that nobody listens for is rethrown + * and takes the extension host down. Both callers treat their watcher as an + * accelerator over a timer, so both failures have to end as "no watcher". + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { join } from 'node:path'; +import { removeDir, tempStorageDir } from './helpers'; +import { watchDirFile } from '../src/watch-dir-file'; + +let dir: string; + +beforeEach(async () => { + dir = await tempStorageDir(); +}); + +afterEach(async () => { + await removeDir(dir); +}); + +describe('watchDirFile', () => { + it('reports nothing to watch instead of throwing', () => { + expect(watchDirFile(join(dir, 'missing'), 'file.json', () => {}, () => {})).toBe(null); + }); + + it('closes an asynchronously failing watcher and reports it once', () => { + const errors: Error[] = []; + const watcher = watchDirFile(dir, 'file.json', () => {}, (error) => errors.push(error)); + expect(watcher).not.toBe(null); + const close = vi.spyOn(watcher!, 'close'); + + // What the kernel does when it invalidates an inotify handle. Unheard, this + // is an uncaught exception in the extension host. + const failure = new Error('watch resources exhausted'); + watcher!.emit('error', failure); + + expect(close).toHaveBeenCalledOnce(); + expect(errors).toEqual([failure]); + // And the hazard itself, for the record: with the closed watcher's listener + // spent, a further error is rethrown — an uncaught exception in the + // extension host, which is why nothing may watch without this. + expect(() => watcher!.emit('error', new Error('unheard'))).toThrow('unheard'); + }); +}); diff --git a/vscode-ext/test/window-lease.test.ts b/vscode-ext/test/window-lease.test.ts index 32e42e35..65e3eedb 100644 --- a/vscode-ext/test/window-lease.test.ts +++ b/vscode-ext/test/window-lease.test.ts @@ -4,8 +4,7 @@ * instances — standing in for two VS Code windows — against a real directory. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fakeContext, freshModule, removeDir, tempStorageDir, waitFor } from './helpers'; @@ -34,23 +33,6 @@ afterEach(async () => { }); describe('window lease over a real directory', () => { - it('closes an asynchronously failing watcher and falls back', async () => { - const mod = await openWindow(); - const watcher = new EventEmitter() as EventEmitter & { close: ReturnType }; - watcher.close = vi.fn(); - const errors: Error[] = []; - mod.installWatcherErrorFallback( - watcher as unknown as import('node:fs').FSWatcher, - (error) => errors.push(error), - ); - - const failure = new Error('watch resources exhausted'); - watcher.emit('error', failure); - - expect(watcher.close).toHaveBeenCalledOnce(); - expect(errors).toEqual([failure]); - }); - it('acquires when nothing holds it, and records an owner', async () => { const window = await openWindow(); window.ensureWindowLease(() => {}); From d85026dcf179251c617d67bd1c598e0ccef81750 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 15:25:11 -0700 Subject: [PATCH 26/56] Restore TMPDIR properly when the flip-back test is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assigning `undefined` back sets the literal string "undefined", so the next test's mkdtemp tried to create `undefined/dormouse-ext-…`. macOS always has a TMPDIR to put back, which is why this only showed up on the Linux runner. Co-Authored-By: Claude Opus 5 (1M context) --- vscode-ext/test/peer-link.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 15072008..50a36a43 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -290,7 +290,10 @@ describe('peer link between windows', () => { // socket, and no temp file from the write that was abandoned. expect(await readdir(dir)).toEqual([]); } finally { - process.env.TMPDIR = realTmp; + // 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; } }); From bbfcfb860c6d2812206f2fd7f47548366794f9f0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 17:25:40 -0700 Subject: [PATCH 27/56] Extract the surface-provider seam from the remote-api session RemoteApiSession now speaks protocol-v1 against a HostSurfaceProvider and nothing else: where a surface lives, how a PTY is read, written, and resized, and when the directory could differ are all provider calls, so the session no longer imports the platform adapter, the stores, or document. The webview-resident binding of that seam (xterm registry + peer bridge) is assembled inline in activation.ts, since it is exactly the part a Node-resident Host replaces. The directory now emits one snapshot per collect: the provider answers for every reachable surface, so there is no longer a subset that is known sooner than the rest, and the old local-then-merged double emit existed only because the peer round trip was visible from the session. Session tests run against a fake provider (7 cases grow to 26, covering the attach-generation guard, the same-size bounce edge at one row, and release-on-stale-resolve); peer-surfaces tests keep exercising the interim webview provider end to end. Co-Authored-By: Claude Fable 5 --- lib/src/remote/host/activation.ts | 82 +- lib/src/remote/host/host-surface-provider.ts | 92 +++ lib/src/remote/host/peer-surfaces.test.ts | 26 +- lib/src/remote/host/remote-api.test.ts | 826 +++++++++++++++---- lib/src/remote/host/remote-api.ts | 146 ++-- lib/src/remote/host/surface-resolve.ts | 12 +- 6 files changed, 918 insertions(+), 266 deletions(-) create mode 100644 lib/src/remote/host/host-surface-provider.ts diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 7d77a6c4..8b0ef4e8 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -3,9 +3,10 @@ * enrollment on app start, and exposes a `window.dormouseRemoteHost` console * hook for enrolling in the POC (no settings UI needed). * - * This is the one module that binds the DOM-free controller to the terminal - * bridge (`RemoteApiSession` touches xterm / the platform adapter), so only the - * running app imports it — the controller and its tests stay DOM-free. + * This is the one module that binds the DOM-free controller and remote-api + * session to the terminal bridge — the xterm registry, the platform adapter, + * and `document` all enter through the surface provider built below — so only + * the running app imports it, and everything it wires stays DOM-free. * * Enroll from the devtools console: * @@ -17,10 +18,16 @@ import { getPlatform } from '../../lib/platform'; import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; +import { subscribeToActivity } from '../../lib/session-activity-store'; +import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; import { refreshPushDevices, startAlertPush, type AlertPushDeps } from './alert-push'; +import { collectDirectorySnapshot } from './directory-collect'; import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; +import type { HostSurfaceProvider } from './host-surface-provider'; +import { peerDirectory } from './peer-surfaces'; import { RemoteApiSession } from './remote-api'; import { RemoteHost, type RemoteHostStatus } from './remote-host'; +import { resolveSurface } from './surface-resolve'; let current: RemoteHost | null = null; let stopPush: (() => void) | null = null; @@ -40,6 +47,74 @@ let leaseClaimRequested = false; */ let owned = true; +/** + * The webview-resident answer to "where do the surfaces live": this webview's + * xterm registry, plus whatever its peers own + * (`host-surface-provider.ts`, docs/specs/vscode.md → "Peer surfaces"). + * + * Assembled here rather than in a module of its own because it is exactly the + * part that a Node-resident Host replaces: the seam is the durable thing, this + * binding of it is not. + */ +export function createWebviewSurfaceProvider(): HostSurfaceProvider { + return { + async collectDirectory() { + // A window's terminals may be spread across several webviews with only + // this one as the Host, so the rest have to be asked; a host with no + // peers (standalone, the website) answers with nothing. The local panes + // are read after the round trip, not before, so they are as current as + // the answers they are merged with. + const remote = await peerDirectory(); + return [...collectDirectorySnapshot(), ...remote]; + }, + + watchDirectory(onChange) { + const unsubPane = subscribeToTerminalPaneState(onChange); + const unsubActivity = subscribeToActivity(onChange); + const unsubPeers = getPlatform().peers?.subscribe('directory', onChange); + const hasDocument = typeof document !== 'undefined'; + if (hasDocument) { + document.addEventListener('focusin', onChange); + document.addEventListener('focusout', onChange); + } + return () => { + unsubPane(); + unsubActivity(); + unsubPeers?.(); + if (hasDocument) { + document.removeEventListener('focusin', onChange); + document.removeEventListener('focusout', onChange); + } + }; + }, + + resolveSurface, + + writePty: (ptyId, data) => getPlatform().writePty(ptyId, data), + resizePty: (ptyId, cols, rows) => getPlatform().resizePty(ptyId, cols, rows), + + streamPty(ptyId, sink) { + // The adapter delivers every PTY this webview owns or subscribed to on + // one stream, so the id filter is the subscription. Pin the adapter the + // pair was registered on: removing a handler from a different one would + // leave this attachment streaming forever. + const platform = getPlatform(); + const onData = (detail: { id: string; data: string }): void => { + if (detail.id === ptyId) sink.onData(detail.data); + }; + const onExit = (detail: { id: string; exitCode: number }): void => { + if (detail.id === ptyId) sink.onExit(detail.exitCode); + }; + platform.onPtyData(onData); + platform.onPtyExit(onExit); + return () => { + platform.offPtyData(onData); + platform.offPtyExit(onExit); + }; + }, + }; +} + function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { const host = new RemoteHost({ enrollment, @@ -48,6 +123,7 @@ function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { hostId: opts.hostId, // The controller sends the untyped remote-api payload inside a `msg`. send: opts.send, + provider: createWebviewSurfaceProvider(), }), }); host.start(); 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..63eafe18 --- /dev/null +++ b/lib/src/remote/host/host-surface-provider.ts @@ -0,0 +1,92 @@ +/** + * 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`. + * + * Today the only implementation is the webview-backed one assembled in + * `activation.ts` (registry + peer bridge). The Node-resident host service + * answers the same interface 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'; + +export interface SurfaceHandle { + 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 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; returns the unsubscribe. 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): () => void; +} diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index e2ce4cd1..878db78a 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -3,6 +3,11 @@ * VS Code window is the remote Host, but the window's terminals are spread * across all of them, so the Host has to reach the others through the peer * bridge (docs/specs/vscode.md → "Peer surfaces"). + * + * This is the webview-backed {@link createWebviewSurfaceProvider} under test as + * much as the session: the session itself knows nothing about registries or + * peers (`host-surface-provider.ts`), so the local-vs-sibling distinction only + * exists here. */ import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -18,6 +23,7 @@ import { } from 'server-lib-common'; import { FakePtyAdapter, setPlatform, type PlatformAdapter } from '../../lib/platform'; import { registry, type TerminalEntry } from '../../lib/terminal-store'; +import { createWebviewSurfaceProvider } from './activation'; import { RemoteApiSession } from './remote-api'; type SentPayload = RemoteResponse | RemoteEventMsg; @@ -132,10 +138,16 @@ describe('remote-api peer surfaces', () => { function session(platform: PeerPlatform) { const sent: SentPayload[] = []; + // The provider reads `getPlatform()` lazily, so it has to be built after + // this platform is installed for its peer bridge to be the one under test. setPlatform(platform.asAdapter()); return { sent, - api: new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }), + api: new RemoteApiSession({ + hostId: 'host-1', + send: (payload) => sent.push(payload), + provider: createWebviewSurfaceProvider(), + }), }; } @@ -333,9 +345,10 @@ describe('remote-api peer surfaces', () => { expect(platform.subscribed).toEqual([]); }); - it('emits local entries first, then a merged snapshot including peers', async () => { + it('emits one snapshot merging this webview with its peers', async () => { const platform = new PeerPlatform(); platform.peerEntries = [{ surfaceId: 'surface-far', title: 'other webview' }]; + registerLocalSurface('surface-near', 'pty-near'); const { api, sent } = session(platform); api.handle({ requestId: 'dir-1', method: REMOTE_METHODS.directoryWatch, params: {} }); @@ -343,10 +356,11 @@ describe('remote-api peer surfaces', () => { const snapshots = sent .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.directorySnapshot) - .map((p) => ((p as RemoteEventMsg).data as { entries: unknown[] }).entries); - // The phone should not wait on a round trip to see this window's own panes. - expect(snapshots.length).toBe(2); - expect(snapshots[1]).toEqual([{ surfaceId: 'surface-far', title: 'other webview' }]); + .map((p) => ((p as RemoteEventMsg).data as { entries: Array<{ surfaceId: string }> }).entries); + // The peer round trip is the provider's business now, so the phone gets one + // snapshot per collect instead of local-then-merged (`remote-api.ts`). + expect(snapshots.length).toBe(1); + expect(snapshots[0]!.map((e) => e.surfaceId)).toEqual(['surface-near', 'surface-far']); }); it('resnapshots when a peer directory changes', async () => { diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index a089f7a1..e20bd45e 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,101 +14,193 @@ 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(); - onPtyData(handler: DataHandler): void { - this.dataHandlers.add(handler); - } +/** A surface the fake owns, standing in for a live xterm at a known size. */ +interface FakeSurface { + ptyId: string; + cols: number; + rows: number; +} - offPtyData(handler: DataHandler): void { - this.dataHandlers.delete(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(); + + /** `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; + + /** 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; + + readonly #sinks = new Map>(); + readonly #onChange = new Set<() => void>(); + + // --- HostSurfaceProvider --- + + collectDirectory = async (): Promise => { + this.collects += 1; + await this.collectGate; + return this.entries; + }; + + watchDirectory = (onChange: () => void): (() => void) => { + this.watchers += 1; + this.#onChange.add(onChange); + return () => { + this.watchers -= 1; + this.#onChange.delete(onChange); + }; + }; - onPtyExit(handler: ExitHandler): void { - this.exitHandlers.add(handler); + resolveSurface = async (surfaceId: string): Promise => { + this.resolved.push(surfaceId); + const surface = this.surfaces.get(surfaceId); + await this.resolveGate; + 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): (() => void) => { + this.streamed.push(ptyId); + let sinks = this.#sinks.get(ptyId); + if (!sinks) { + sinks = new Set(); + this.#sinks.set(ptyId, sinks); + } + sinks.add(sink); + return () => { + this.unstreamed.push(ptyId); + sinks.delete(sink); + }; + }; + + // --- Test drivers --- + + addSurface(surfaceId: string, ptyId: string, cols = 80, rows = 24): FakeSurface { + const surface: FakeSurface = { ptyId, cols, rows }; + 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 { + 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]); + 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 }; +} + +/** 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 now — a pane in a sibling webview is a round - * trip away, and the local path takes the same seam rather than a second one - * (`surface-resolve.ts`) — so an attach lands a microtask later even here. - * `terminal.resize` on a resolved pane is still synchronous, so everything the - * attach does still happens in one go once it starts. + * 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'): Promise { +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(); } -/** Let a promise-tailed handler run; microtasks are unaffected by fake timers. */ -async function settle(): Promise { - await Promise.resolve(); - await Promise.resolve(); +async function watchDirectory(session: RemoteApiSession, requestId = 'dir-1'): Promise { + session.handle({ requestId, method: REMOTE_METHODS.directoryWatch, params: {} }); + await settle(); } function decodeTerminalData(payload: SentPayload): string { @@ -108,73 +208,261 @@ 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(); +}); + +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('keeps synchronous repaint data from terminal resize', async () => { - 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('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('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('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); + + session.dispose(); + slow.release(); + await settle(); + provider.changeDirectory(); + await settle(); + + expect(provider.watchers).toBe(0); + expect(snapshots(sent)).toEqual([]); + }); +}); + +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', async () => { + 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); 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('bounces a one-row surface upward, where a bounce is not a no-op', async () => { + vi.useFakeTimers(); + const provider = new FakeProvider(); + provider.addSurface('surface-1', 'pty-1', 80, 1); + const { session } = makeSession(provider); + + await attach(session, 80, 1); + + // 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 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 } = 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({ @@ -182,47 +470,149 @@ 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', 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. await attach(session, 80, 24, 'surface-1'); - expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + 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. - await 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 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([]); + }); +}); + +describe('RemoteApiSession terminal input', () => { it('rejects write and resize unless the surface is the current attachment', async () => { - 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) }); + 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); await attach(session, 80, 24, 'surface-1'); sent.length = 0; @@ -238,8 +628,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', @@ -266,7 +656,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', @@ -276,19 +666,35 @@ describe('RemoteApiSession surface.attach', () => { ]); }); - it('keeps write and resize pinned to the attached terminal after pane swaps', async () => { - 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; + + 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'); - const attachedEntry = registry.get('surface-1')!; - const swappedInEntry = registry.get('surface-2')!; - registry.set('surface-1', swappedInEntry); - registry.set('surface-2', attachedEntry); + // 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({ @@ -297,8 +703,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', @@ -306,45 +711,107 @@ describe('RemoteApiSession surface.attach', () => { params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, }); - // The xterm resize is synchronous; only the reply waits on the handle, - // which for a sibling's pane is a round trip. - 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('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 }); + }); +}); + +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); + + 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('tears down the attachment when the attached PTY exits', async () => { - 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) }); + 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([ @@ -354,6 +821,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 @@ -369,8 +838,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', @@ -384,4 +853,39 @@ describe('RemoteApiSession surface.attach', () => { }, ]); }); + + 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 751febe2..ad7f46b7 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 { @@ -34,12 +40,7 @@ import { type TerminalResizeParams, type TerminalWriteParams, } from 'server-lib-common'; -import { getPlatform } from '../../lib/platform'; -import { subscribeToActivity } from '../../lib/session-activity-store'; -import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; -import { collectDirectorySnapshot } from './directory-collect'; -import { peerDirectory } from './peer-surfaces'; -import { resolveSurface, type SurfaceHandle } from './surface-resolve'; +import type { HostSurfaceProvider, SurfaceHandle } from './host-surface-provider'; /** Coalesce window for directory re-snapshots (remote-api.md: "Host coalesces"). */ const DIRECTORY_DEBOUNCE_MS = 150; @@ -54,13 +55,12 @@ interface Attachment { /** * 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 whether the pane is this webview's or a sibling's - * (`surface-resolve.ts`). + * 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; } @@ -69,11 +69,14 @@ 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; @@ -85,6 +88,7 @@ export class RemoteApiSession { constructor(options: RemoteApiSessionOptions) { this.#hostId = options.hostId; this.#send = options.send; + this.#provider = options.provider; } handle(data: unknown): void { @@ -176,55 +180,31 @@ 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 unsubPeers = getPlatform().peers?.subscribe('directory', trigger); - const hasDocument = typeof document !== 'undefined'; - if (hasDocument) { - document.addEventListener('focusin', trigger); - document.addEventListener('focusout', trigger); - } - this.#unsubDirectory = () => { - unsubPane(); - unsubActivity(); - unsubPeers?.(); - 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; const subId = this.#directorySubId; - // A window's terminals may be spread across several webviews with only this - // one as the Host, so the rest have to be asked (docs/specs/vscode.md → - // "Peer surfaces"). Emit twice rather than delaying the local panes behind - // a round trip: the phone renders what is here immediately, then fills in. - this.#event(subId, REMOTE_EVENTS.directorySnapshot, { - entries: collectDirectorySnapshot(), - }); - void peerDirectory().then((remote) => { - // Nothing to fill in on a host with no peers, and the subscription may - // have been replaced or torn down while we waited. - if (this.#directorySubId !== subId || remote.length === 0) return; - this.#event(subId, REMOTE_EVENTS.directorySnapshot, { - entries: [...collectDirectorySnapshot(), ...remote], - }); - }); + // 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. + const entries = await this.#provider.collectDirectory(); + // The subscription may have been replaced or torn down while we waited. + if (this.#directorySubId !== subId) return; + this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries }); } #attach(request: RemoteRequest): void { @@ -234,9 +214,9 @@ export class RemoteApiSession { return; } - // Where the pane lives — this webview's registry or a sibling's — is a fact - // about VS Code webview hosting, not a protocol concept, so it is settled - // below this line and never seen here (`surface-resolve.ts`). + // 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 @@ -244,7 +224,7 @@ export class RemoteApiSession { // microtask, so one shared epoch would let the older, slower attach land // last and take the attachment. const generation = ++this.#attachGeneration; - void resolveSurface(params.surfaceId, params).then((handle) => { + 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 @@ -274,7 +254,6 @@ export class RemoteApiSession { const cols = clampTerminalDimension(params.cols, handle.cols); const rows = clampTerminalDimension(params.rows, handle.rows); const sameSize = handle.cols === cols && handle.rows === rows; - const platform = getPlatform(); const subId = request.requestId; const pendingEvents: Array<{ event: string; data: unknown }> = []; let streaming = false; @@ -285,34 +264,33 @@ 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 stopStream = 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 }); + this.#teardownAttachment(); + }, + }); const attachment: Attachment = { surfaceId: params.surfaceId, handle, subId, - onData, - onExit, + stopStream, bounceTimer: null, }; this.#attachment = attachment; @@ -332,7 +310,7 @@ export class RemoteApiSession { // 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 @@ -341,7 +319,7 @@ export class RemoteApiSession { attachment.bounceTimer = setTimeout(() => { attachment.bounceTimer = null; if (this.#attachment !== attachment) return; - platform.resizePty(ptyId, cols, rows); + this.#provider.resizePty(ptyId, cols, rows); }, FORCE_REPAINT_BOUNCE_MS); } @@ -368,8 +346,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.handle.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, {}); } @@ -392,11 +370,9 @@ export class RemoteApiSession { clearTimeout(this.#attachment.bounceTimer); this.#attachment.bounceTimer = null; } - const platform = getPlatform(); - platform.offPtyData(this.#attachment.onData); - platform.offPtyExit(this.#attachment.onExit); - // Stops the host forwarding a PTY this webview never owned; nothing to undo - // for one it does. + 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; } diff --git a/lib/src/remote/host/surface-resolve.ts b/lib/src/remote/host/surface-resolve.ts index 02781538..59461e69 100644 --- a/lib/src/remote/host/surface-resolve.ts +++ b/lib/src/remote/host/surface-resolve.ts @@ -15,19 +15,9 @@ import { getPlatform } from '../../lib/platform'; import { registry } from '../../lib/terminal-store'; +import type { SurfaceHandle } from './host-surface-provider'; import { peerSurfaceOp } from './peer-surfaces'; -export interface SurfaceHandle { - 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; -} - /** * Resolve `surfaceId` at the size the client asked for, or `null` if nobody * owns it. From 9735b5e13b49084c0e014cbeed826ea3b6426956 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 18:22:37 -0700 Subject: [PATCH 28/56] Move the standalone remote Host into the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host — the relay socket, the enrollment, the ACL, the pairing ceremony, remote-api v1 — now runs as RemoteHostService in the process that owns the PTYs (lib/src/host/remote/, bundled to remote-host.cjs). PTY bytes stream straight from pty-core through a per-attachment strip-only protocol parser, so the phone still sees exactly what the local xterm renders; directory contents and attach-resizes are asked of the webview over an rhId-correlated bridge, first answer settles. 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. Its enrollment copy is handed to the service once and cleared. The hostToken bearer credential now lives in a 0600 file under app data and never enters a webview realm; ring detection stays webview-side but delivery and recipient selection read the service's own ACL at send time, so a webview can no longer choose push recipients. The relay origin allowlist moves with the Host: the webview CSP carries no relay sources at all, and DORMOUSE_REMOTE_CONNECT_SRC is baked into the sidecar bundle instead, where the service refuses enrollment to any origin outside it. Same variable, same custom-build story, one enforcement point (csp.mjs and the tauri.mjs override are gone). VS Code's webview-resident Host is untouched behind the same seam; it migrates onto this service next. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + docs/specs/standalone.md | 11 +- lib/src/host/remote/connect-src.test.ts | 77 +++ lib/src/host/remote/connect-src.ts | 94 ++++ lib/src/host/remote/host-state-store.test.ts | 140 +++++ lib/src/host/remote/host-state-store.ts | 157 ++++++ lib/src/host/remote/pty-strip.test.ts | 50 ++ lib/src/host/remote/pty-strip.ts | 29 ++ lib/src/host/remote/service-protocol.ts | 153 ++++++ lib/src/host/remote/service.test.ts | 514 +++++++++++++++++++ lib/src/host/remote/service.ts | 347 +++++++++++++ lib/src/host/remote/sidecar-entry.test.ts | 225 ++++++++ lib/src/host/remote/sidecar-entry.ts | 284 ++++++++++ lib/src/lib/platform/types.ts | 40 ++ lib/src/remote/host/acl.ts | 11 +- lib/src/remote/host/activation.test.ts | 264 +++++++++- lib/src/remote/host/activation.ts | 172 ++++++- lib/src/remote/host/alert-push.test.ts | 5 +- lib/src/remote/host/alert-push.ts | 170 ++---- lib/src/remote/host/enrollment.ts | 24 +- lib/src/remote/host/peer-surfaces.ts | 29 +- lib/src/remote/host/push-delivery.ts | 142 +++++ scripts/csp-defaults.mjs | 16 +- standalone/package.json | 2 +- standalone/scripts/build-sidecar-proxy.mjs | 34 +- standalone/scripts/csp.mjs | 33 -- standalone/scripts/csp.test.mjs | 42 -- standalone/scripts/dev-agent-browser.mjs | 8 + standalone/scripts/tauri-conf.test.mjs | 26 + standalone/scripts/tauri.mjs | 29 +- standalone/sidecar/main.js | 20 + standalone/src-tauri/src/lib.rs | 40 ++ standalone/src-tauri/tauri.conf.json | 2 +- standalone/src/browser-sidecar-adapter.ts | 106 ++++ standalone/src/main.tsx | 6 + standalone/src/tauri-adapter.test.ts | 128 +++++ standalone/src/tauri-adapter.ts | 133 +++++ 37 files changed, 3277 insertions(+), 287 deletions(-) create mode 100644 lib/src/host/remote/connect-src.test.ts create mode 100644 lib/src/host/remote/connect-src.ts create mode 100644 lib/src/host/remote/host-state-store.test.ts create mode 100644 lib/src/host/remote/host-state-store.ts create mode 100644 lib/src/host/remote/pty-strip.test.ts create mode 100644 lib/src/host/remote/pty-strip.ts create mode 100644 lib/src/host/remote/service-protocol.ts create mode 100644 lib/src/host/remote/service.test.ts create mode 100644 lib/src/host/remote/service.ts create mode 100644 lib/src/host/remote/sidecar-entry.test.ts create mode 100644 lib/src/host/remote/sidecar-entry.ts create mode 100644 lib/src/remote/host/push-delivery.ts delete mode 100644 standalone/scripts/csp.mjs delete mode 100644 standalone/scripts/csp.test.mjs create mode 100644 standalone/scripts/tauri-conf.test.mjs 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/docs/specs/standalone.md b/docs/specs/standalone.md index 43c0c0e2..0a1041b4 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -443,11 +443,12 @@ 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 (`docs/specs/server.md`, Host webview + CSP). - 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`). 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..ecf1b888 --- /dev/null +++ b/lib/src/host/remote/connect-src.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +// The build scripts read the `.mjs` and the Host service reads the `.ts`; the +// last test here is what keeps them one fact. +import { DEFAULT_REMOTE_CONNECT_SRC as BUILD_DEFAULT } from '../../../../scripts/csp-defaults.mjs'; +import { 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); + }); +}); diff --git a/lib/src/host/remote/connect-src.ts b/lib/src/host/remote/connect-src.ts new file mode 100644 index 00000000..e018069d --- /dev/null +++ b/lib/src/host/remote/connect-src.ts @@ -0,0 +1,94 @@ +/** + * 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'; + +/** 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; +} + +function parseSource(source: string): ParsedSource | null { + const match = /^([a-z][a-z0-9+.-]*:)\/\/([^/:]+)(?::(\*|\d+))?$/i.exec(source); + if (!match) return null; + const group = schemeClass(match[1]!.toLowerCase()); + if (!group) return null; + return { + group, + host: match[2]!.toLowerCase(), + port: match[3] ?? defaultPort(group), + }; +} + +/** + * 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..1cd5f8d2 --- /dev/null +++ b/lib/src/host/remote/host-state-store.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +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-')); +}); + +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('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('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('reads empty, drops writes, and says so once', async () => { + 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()).toBeNull(); + expect(await store.loadAcl('host-1')).toEqual([]); + expect(warnings).toHaveLength(1); + }); +}); 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..8cccd646 --- /dev/null +++ b/lib/src/host/remote/host-state-store.ts @@ -0,0 +1,157 @@ +/** + * 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 { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { HostAclRecord } from 'server-lib-common'; +import type { HostEnrollment } from '../../remote/host/enrollment'; + +export interface HostStateStore { + 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 isEnrollment(value: unknown): value is HostEnrollment { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.serverUrl === 'string' && + typeof v.hostId === 'string' && + typeof v.hostToken === 'string' && + typeof v.origin === 'string' && + typeof v.rpId === 'string' + ); +} + +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 #dir: string; + readonly #path: string; + #state: Promise | null = null; + + constructor(stateDir: string) { + this.#dir = stateDir; + this.#path = join(stateDir, FILE_NAME); + } + + async loadEnrollment(): Promise { + return (await this.#read()).enrollment; + } + + async saveEnrollment(enrollment: HostEnrollment): Promise { + const state = await this.#read(); + state.enrollment = enrollment; + await this.#write(state); + } + + async clearEnrollment(): Promise { + const state = await this.#read(); + state.enrollment = null; + await this.#write(state); + } + + async loadAcl(hostId: string): Promise { + const records = (await this.#read()).acl[hostId] ?? []; + // `HostAcl.fromRecords` rejects a mismatched hostId, so drop foreign rows + // rather than fail the whole load over one. + return records.filter((record) => !!record && record.hostId === hostId); + } + + async saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { + const state = await this.#read(); + state.acl[hostId] = [...records]; + await this.#write(state); + } + + #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 ??= (async () => { + try { + return parseState(await readFile(this.#path, 'utf8')); + } catch (error) { + if ((error as { code?: string } | null)?.code !== 'ENOENT') { + // Fail closed but loudly, like `loadHostAcl`: starting empty 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(); + } + })(); + return this.#state; + } + + 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 }); + // 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". + const tmp = `${this.#path}.${process.pid}.tmp`; + await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); + await rename(tmp, this.#path); + } +} + +/** + * The store for a run with no state directory (the browser dev harness). Reads + * answer empty and writes are dropped, so a Host can be enrolled and used for + * the session but nothing survives a restart. + */ +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; enrollment will not survive a restart'); + }; + return { + loadEnrollment: async () => null, + saveEnrollment: async () => warnOnce(), + clearEnrollment: async () => {}, + loadAcl: async () => [], + saveAcl: async () => warnOnce(), + }; +} 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..dd1f103c --- /dev/null +++ b/lib/src/host/remote/pty-strip.test.ts @@ -0,0 +1,50 @@ +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('leaves a color query for the client to answer', () => { + const strip = createPtyStrip(); + // No theme lives here, so the query falls through exactly as it does in a + // webview whose provider declines. + expect(strip(`${ESC}]11;?${BEL}`)).toBe(`${ESC}]11;?${BEL}`); + }); +}); diff --git a/lib/src/host/remote/pty-strip.ts b/lib/src/host/remote/pty-strip.ts new file mode 100644 index 00000000..2d4ed5a5 --- /dev/null +++ b/lib/src/host/remote/pty-strip.ts @@ -0,0 +1,29 @@ +/** + * 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. + */ + +import { TerminalProtocolParser } from '../../lib/terminal-protocol'; + +/** + * 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 { + // No color provider: OSC 10/11/12 queries fall through untouched, exactly as + // they do for a webview whose theme cannot answer them. + const parser = new TerminalProtocolParser(); + return (data) => parser.process(data).visibleData; +} diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts new file mode 100644 index 00000000..138eda0e --- /dev/null +++ b/lib/src/host/remote/service-protocol.ts @@ -0,0 +1,153 @@ +/** + * 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. The command travels under the first, the rest come back. */ +export const REMOTE_HOST_COMMAND_EVENT = 'remoteHost:command'; +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; +} + +/** 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; + 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[]; +} + +export type RemoteHostEvent = PairingQueueEvent; + +// --- Command parameter shapes --- + +export interface EnrollParams { + serverUrl: string; + password: string; + label: string; +} + +export interface ApproveParams { + clientId: string; + label?: string; +} + +export interface DenyParams { + clientId: 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[]; +} + +/** Answers an outstanding {@link RemoteHostAsk}; `rhId` is the ask's, not a new one. */ +export interface AnswerParams { + rhId: string; + results: unknown[]; +} + +/** Announces that future answers for `topic` may differ. */ +export interface NotifyParams { + topic: string; +} + +// --- 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; +} + +export interface AdoptResult { + adopted: boolean; +} + +/** + * 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..a77898ee --- /dev/null +++ b/lib/src/host/remote/service.test.ts @@ -0,0 +1,514 @@ +/** + * 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 type { WebSocketLike } from '../../remote/host/remote-host'; +import type { HostStateStore } from './host-state-store'; +import { RemoteHostService } from './service'; +import type { 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, + }; +} + +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) as Record); + } + + close(): void { + this.readyState = 3; + this.#emit('close', { code: 1000 }); + } + + open(): void { + this.#emit('open', {}); + } + + receive(frame: unknown): void { + this.#emit('message', { data: JSON.stringify(frame) }); + } + + 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); + } +} + +interface MemoryStore extends HostStateStore { + enrollment: HostEnrollment | null; + acl: Record; +} + +function memoryStore(seed: Partial> = {}): MemoryStore { + const store: MemoryStore = { + 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 sent + .filter((message) => message.event === 'remoteHost:event') + .map((message) => message.data as unknown as PairingQueueEvent); +} + +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); + }); +}); + +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('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' }); + }); +}); + +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')], + }); + + expect(result.result).toEqual({ adopted: 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(result.result).toEqual({ adopted: false }); + expect(store.enrollment).toEqual(ENROLLMENT); + expect(sockets).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(); + const result = await command('adopt', { enrollment: { hostId: 'x' }, aclRecords: [] }); + expect(result.result).toEqual({ adopted: false }); + expect(store.enrollment).toBeNull(); + }); +}); + +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]!.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 }); + + await command('approve', { clientId: 'c1', 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 }); + + await command('deny', { clientId: 'c1' }); + + 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('approving something already resolved is a no-op', async () => { + const socket = await running(); + socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); + await command('approve', { clientId: 'c1' }); + await command('approve', { clientId: 'c1' }); + expect(socket.frames('pair-result')).toHaveLength(1); + }); +}); + +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..67d309b8 --- /dev/null +++ b/lib/src/host/remote/service.ts @@ -0,0 +1,347 @@ +/** + * 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 today (`sidecar-entry.ts`) and in the VS Code extension host + * next, 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 type { HostAclRecord } from 'server-lib-common'; +import { 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 { + REMOTE_HOST_EVENT_EVENT, + REMOTE_HOST_RESULT_EVENT, + type AdoptParams, + type AdoptResult, + type ApproveParams, + type DenyParams, + type EnrollParams, + type EnrollResult, + type PairingQueueEvent, + type PairingQueueItem, + type PushDevicesResult, + type PushParams, + type RemoteHostCommand, + 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; + /** + * Pairings awaiting local approval, service-side. The webview mirrors a + * serializable projection of this and answers by clientId; 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. */ + 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 { + this.#stopHost(); + } + + async handleCommand(raw: unknown): Promise { + const command = raw as RemoteHostCommand | null; + if (!command || typeof command.rhId !== 'string' || typeof command.cmd !== 'string') return; + try { + const result = await this.#run(command.cmd, command.params); + this.#sendToUi(REMOTE_HOST_RESULT_EVENT, { rhId: command.rhId, result }); + } catch (error) { + 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) { + case 'enroll': + return this.#enroll(params as EnrollParams); + case 'status': + return this.#status(); + case 'reconnect': + return this.#reconnect(); + case 'clearEnrollment': + return 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.#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); + this.#stopHost(); + await this.#store.saveEnrollment(enrollment); + 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> { + this.#stopHost(); + this.#enrollment = null; + // 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(); + return {}; + } + + #approve(params: ApproveParams): Record { + this.#pairings.get(params.clientId)?.approve(params.label); + return {}; + } + + #deny(params: DenyParams): Record { + this.#pairings.get(params.clientId)?.deny(); + return {}; + } + + 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(); + let adopted = false; + if (!existing && isEnrollment(params.enrollment)) { + const enrollment = params.enrollment; + await this.#store.saveEnrollment(enrollment); + const records = (params.aclRecords ?? []).filter( + (record): record is HostAclRecord => + !!record && typeof record === 'object' && (record as HostAclRecord).hostId === enrollment.hostId, + ); + if (records.length > 0) await this.#store.saveAcl(enrollment.hostId, records); + adopted = true; + } + // 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 { adopted }; + } + + // --- Host lifecycle --- + + #allowed(serverUrl: string): boolean { + try { + return originAllowedByConnectSrc(new URL(serverUrl).origin, this.#connectSrc); + } catch { + return false; + } + } + + async #startHost(enrollment: HostEnrollment): Promise { + // 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); + 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(); + } + + #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, request, requestedAt }) => ({ + clientId, + request, + requestedAt, + })); + } + + #emitQueue(): void { + 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, + }; + } +} + +function isEnrollment(value: unknown): value is HostEnrollment { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.serverUrl === 'string' && + typeof v.hostId === 'string' && + typeof v.hostToken === 'string' && + typeof v.origin === 'string' && + typeof v.rpId === 'string' + ); +} 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..2d96e4b4 --- /dev/null +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -0,0 +1,225 @@ +/** + * 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 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 = []; + 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 }), + }, + }); +}); + +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('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 directory notify, and stops after unsubscribe', () => { + const changes = vi.fn(); + const unsubscribe = bridge.provider.watchDirectory(changes); + + bridge.onNotify({ topic: 'directory' }); + expect(changes).toHaveBeenCalledTimes(1); + + // An unrelated topic is not this watcher's business. + bridge.onNotify({ topic: 'something-else' }); + expect(changes).toHaveBeenCalledTimes(1); + + unsubscribe(); + bridge.onNotify({ topic: 'directory' }); + 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('gives each subscription its own parser state', () => { + const one = sink(); + const two = sink(); + bridge.provider.streamPty('pty-1', one); + // A second attachment starts mid-stream, after the OSC introducer. + 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']); + // The newcomer never saw the introducer, so it reads the tail as ordinary + // output (its lone BEL stripped as a bell, which is the parser's own rule). + expect(two.data).toEqual(['Ahi']); + }); + + 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('stops delivering after unsubscribe', () => { + const one = sink(); + const unsubscribe = bridge.provider.streamPty('pty-1', one); + unsubscribe(); + bridge.onPtyEvent('data', { id: 'pty-1', data: 'x' }); + expect(one.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..14117216 --- /dev/null +++ b/lib/src/host/remote/sidecar-entry.ts @@ -0,0 +1,284 @@ +/** + * 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 { DirectoryEntry } from 'server-lib-common'; +import type { + HostSurfaceProvider, + PtySink, + SurfaceHandle, +} from '../../remote/host/host-surface-provider'; +import type { PeerSurfaceResult } from '../../remote/host/peer-surfaces'; +import { DEFAULT_REMOTE_CONNECT_SRC } 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, + type AnswerParams, + type NotifyParams, + type RemoteHostCommand, +} from './service-protocol'; + +/** Substituted by esbuild at build time; see `scripts/csp-defaults.mjs`. */ +declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; + +/** 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; +} + +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(params: NotifyParams | undefined): 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 }); + }); + } + + const directoryWatchers = new Set<() => void>(); + + interface Subscription { + sink: PtySink; + strip: (data: string) => string; + } + const streams = new Map>(); + + const provider: HostSurfaceProvider = { + async collectDirectory(): Promise { + // The responder answers with its whole snapshot, so the results *are* the + // entries — no per-webview merging to do on this side. + return (await ask('directory', {})) as DirectoryEntry[]; + }, + + 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). + 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, + })) 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: (ptyId, data) => options.mgr.write(ptyId, data), + resizePty: (ptyId, cols, rows) => options.mgr.resize(ptyId, cols, rows), + + streamPty(ptyId, sink) { + const subscription: Subscription = { sink, strip: createPtyStrip() }; + let subscriptions = streams.get(ptyId); + if (!subscriptions) { + subscriptions = new Set(); + streams.set(ptyId, subscriptions); + } + subscriptions.add(subscription); + return () => { + subscriptions.delete(subscription); + if (subscriptions.size === 0) streams.delete(ptyId); + }; + }, + }; + + 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; + asks.get(params.rhId)?.settle(Array.isArray(params.results) ? params.results : []); + }, + + onNotify(params) { + if (params?.topic !== 'directory') return; + for (const watcher of [...directoryWatchers]) watcher(); + }, + + onPtyEvent(event, data) { + const detail = data as { id?: unknown } | null; + if (!detail || typeof detail.id !== 'string') return; + const subscriptions = streams.get(detail.id); + if (!subscriptions || subscriptions.size === 0) return; + if (event === 'data') { + const chunk = (detail as { data?: unknown }).data; + if (typeof chunk !== 'string') return; + for (const subscription of [...subscriptions]) { + // Each attachment strips on its own parser: the state an incomplete + // OSC leaves behind belongs to one stream's byte boundaries, not + // another's. + const visible = subscription.strip(chunk); + if (visible !== '') subscription.sink.onData(visible); + } + return; + } + if (event === 'exit') { + const exitCode = (detail as { exitCode?: unknown }).exitCode; + const code = typeof exitCode === 'number' ? exitCode : 0; + for (const subscription of [...subscriptions]) subscription.sink.onExit(code); + } + }, + + dispose() { + for (const pending of [...asks.values()]) pending.settle([]); + asks.clear(); + directoryWatchers.clear(); + streams.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 connectSrc = + typeof __DORMOUSE_REMOTE_CONNECT_SRC__ === 'string' + ? __DORMOUSE_REMOTE_CONNECT_SRC__ + : DEFAULT_REMOTE_CONNECT_SRC; + + 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, + }); + void service.start().catch((error: unknown) => { + console.error(`[remote-host] failed to start: ${String(error)}`); + }); + + return { + handleCommand(data) { + const command = data as RemoteHostCommand | null; + if (!command || typeof command.cmd !== 'string') return; + // 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(command.params as NotifyParams); + void service.handleCommand(command); + }, + onPtyEvent: bridge.onPtyEvent, + dispose() { + service.dispose(); + bridge.dispose(); + }, + }; +} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index cffe0d7b..a187d12c 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -165,6 +165,38 @@ export interface PeerBridge { streamPty(ptyId: string): () => void; } +/** + * The webview end of a Node-resident remote Host + * (`lib/src/host/remote/service-protocol.ts`). + * + * When the Host runs in the process that owns the PTYs, the webview stops being + * the Host and becomes 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. `respond`/`notify` are deliberately the same shape + * as {@link PeerBridge}'s, so one responder implementation serves a webview that + * answers a sibling and a webview that answers the service. + * + * `cmd` and `op` are opaque here for the same reason they are on `PeerBridge`: + * *what* the service can be asked belongs to the remote Host, not the platform. + */ +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 for `topic` may differ. */ + notify(topic: string): 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; @@ -189,6 +221,14 @@ export interface PlatformAdapter { */ peers?: PeerBridge; + /** + * Reach the remote Host service behind this host. Present exactly when the + * Host runs outside the webview (standalone's sidecar), which is also exactly + * when this webview is a surface responder rather than the Host itself. + * Adapters that omit it host the Host in the webview (VS Code, the website). + */ + remoteHost?: RemoteHostLink; + // Shell detection getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]>; diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts index 06e8d7f9..882d0c2a 100644 --- a/lib/src/remote/host/acl.ts +++ b/lib/src/remote/host/acl.ts @@ -9,7 +9,7 @@ */ import { HostAcl, type HostAclRecord } from 'server-lib-common'; -import { loadJson, saveJson } from '../../lib/local-json-store'; +import { loadJson, removeJson, saveJson } from '../../lib/local-json-store'; export const ACL_KEY_PREFIX = 'dormouse.remote-host.acl.'; @@ -32,6 +32,15 @@ export function saveAclRecords(hostId: string, records: readonly HostAclRecord[] saveJson(aclKey(hostId), records); } +/** + * 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` diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index 06c932d4..640deb71 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -1,11 +1,16 @@ /** - * The single-Host lease. VS Code can show several Dormouse webviews over one - * extension host; without this gate each would start its own `RemoteHost` - * against the same enrollment, fight over the one `/ws/host` socket, and arm - * its own alarm push. + * Legacy mode: the single-Host lease. VS Code can show several Dormouse webviews + * over one extension host; without this gate each would start its own + * `RemoteHost` against the same enrollment, fight over the one `/ws/host` + * socket, and arm its own alarm push. + * + * Bridge mode (bottom): the Host runs in another process, so this module starts + * none of that and is a client of the service instead. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PairingRequest } from 'server-lib-common'; +import type { RemoteHostLink } from '../../lib/platform/types'; const started: Array<{ stopped: boolean }> = []; const enrollmentState = vi.hoisted(() => ({ @@ -39,13 +44,34 @@ vi.mock('./remote-host', () => ({ }, })); vi.mock('./remote-api', () => ({ RemoteApiSession: class {} })); +const pushWatch = vi.hoisted(() => ({ + fire: undefined as ((sessionId: string, title: string) => void) | undefined, + loads: [] as Array<() => Promise>, +})); vi.mock('./alert-push', () => ({ startAlertPush: () => () => {}, refreshPushDevices: async () => {}, + watchPushRings: (fire: (sessionId: string, title: string) => void) => { + pushWatch.fire = fire; + return () => {}; + }, + commitPushDevices: async (load: () => Promise) => { + pushWatch.loads.push(load); + await load(); + }, })); +const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void> })); vi.mock('../../lib/push-devices', () => ({ resetPushDevices: () => {}, - setPushDevicesRefresher: () => {}, + setPushDevicesRefresher: (refresh: () => void) => void pushRefreshers.current.push(refresh), +})); +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, @@ -65,11 +91,15 @@ vi.mock('./enrollment', () => ({ })); let claimSingleton: ((name: string, onChange: (held: boolean) => void) => void) | undefined; +let remoteHostLink: RemoteHostLink | undefined; // A host with peers is exactly a host that arbitrates the role, so the lease // arrives through the same optional member (`PeerBridge`); no peers means -// single-instance. +// single-instance. A host with `remoteHost` runs the Host elsewhere entirely. vi.mock('../../lib/platform', () => ({ - getPlatform: () => ({ peers: claimSingleton ? { claimSingleton } : undefined }), + getPlatform: () => ({ + peers: claimSingleton ? { claimSingleton } : undefined, + remoteHost: remoteHostLink, + }), })); async function freshModule() { @@ -80,6 +110,12 @@ async function freshModule() { beforeEach(() => { started.length = 0; claimSingleton = undefined; + remoteHostLink = undefined; + pushWatch.fire = undefined; + pushWatch.loads.length = 0; + pushRefreshers.current.length = 0; + aclState.records = []; + aclState.cleared.length = 0; enrollmentState.current = { serverUrl: 'https://relay.example.ts.net', hostId: 'host-1', @@ -197,3 +233,217 @@ describe('remote host activation lease', () => { expect(started).toHaveLength(0); }); }); + +// --- 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. */ +async function installBridge(link: FakeLink) { + 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. + await Promise.resolve(); + await Promise.resolve(); + return { mod, pairing }; +} + +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('starts no Host of its own', async () => { + await installBridge(fakeLink()); + expect(started).toHaveLength(0); + }); + + 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' }], + }); + // Whatever the service decided, 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('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', request: PAIRING_REQUEST, requestedAt: 5 }], + }); + + const head = pairing.getPairingApprovalSnapshot()[0]!; + expect(head).toMatchObject({ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }); + + head.approve('Ned iPhone'); + expect(link.commands.at(-1)).toEqual({ + cmd: 'approve', + params: { clientId: 'c1', label: 'Ned iPhone' }, + }); + head.deny(); + expect(link.commands.at(-1)).toEqual({ cmd: 'deny', params: { clientId: 'c1' } }); + }); + + 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, 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('seeds the mirror once, for a webview that reloaded mid-pairing', async () => { + const link = fakeLink(); + link.results.pairingQueue = [{ clientId: 'c1', 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('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('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 8b0ef4e8..042663c0 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -1,12 +1,20 @@ /** - * 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: brings up the remote Host from the persisted enrollment on + * app start, and exposes a `window.dormouseRemoteHost` console hook for + * enrolling in the POC (no settings UI needed). * - * This is the one module that binds the DOM-free controller and remote-api - * session to the terminal bridge — the xterm registry, the platform adapter, - * and `document` all enter through the surface provider built below — so only - * the running app imports it, and everything it wires stays DOM-free. + * There are two worlds here, chosen by whether the platform adapter has a + * {@link RemoteHostLink}: + * + * - **Bridge mode** (standalone): the Host is a service in the process that + * owns the PTYs (`lib/src/host/remote/service.ts`). This module is then a + * client of it — it forwards console commands, mirrors the pairing queue, + * and reports rings — and starts no Host of its own. + * - **Legacy mode** (VS Code, the website): the Host runs in this webview, + * and this is the one module that binds the DOM-free controller and + * remote-api session to the terminal bridge — the xterm registry, the + * platform adapter, and `document` all enter through the surface provider + * built below. * * Enroll from the devtools console: * @@ -16,19 +24,40 @@ * window.dormouseRemoteHost.clearEnrollment() */ +import type { + PairingQueueEvent, + PairingQueueItem, + PushDevicesResult, + RemoteHostConsoleStatus, +} from '../../host/remote/service-protocol'; import { getPlatform } from '../../lib/platform'; +import type { RemoteHostLink } from '../../lib/platform/types'; import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; import { subscribeToActivity } from '../../lib/session-activity-store'; import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; -import { refreshPushDevices, startAlertPush, type AlertPushDeps } from './alert-push'; +import { clearAclRecords, loadAclRecords } from './acl'; +import { + commitPushDevices, + refreshPushDevices, + startAlertPush, + watchPushRings, + type AlertPushDeps, +} from './alert-push'; import { collectDirectorySnapshot } from './directory-collect'; import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; import type { HostSurfaceProvider } from './host-surface-provider'; +import { + enqueuePairingApproval, + getPairingApprovalSnapshot, + resolvePairingApproval, +} from './pairing-approval'; import { peerDirectory } from './peer-surfaces'; import { RemoteApiSession } from './remote-api'; -import { RemoteHost, type RemoteHostStatus } from './remote-host'; +import { RemoteHost } from './remote-host'; import { resolveSurface } from './surface-resolve'; +export type { RemoteHostConsoleStatus }; + let current: RemoteHost | null = null; let stopPush: (() => void) | null = null; let leaseClaimRequested = false; @@ -178,20 +207,6 @@ export function stopRemoteHost(): void { resetPushDevices(); } -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 { @@ -205,6 +220,12 @@ function remoteHostStatus(): RemoteHostConsoleStatus { /** Install the `window.dormouseRemoteHost` console hook and activate. Idempotent. */ export function installRemoteHostConsoleHook(): void { + const link = getPlatform().remoteHost; + if (link) { + installBridgeMode(link); + return; + } + // A host that can show several webviews arbitrates which one is the Host — // having peers at all is exactly the condition that needs arbitrating, which // is why one member answers both. Start un-owned so two webviews racing to @@ -251,3 +272,108 @@ export function installRemoteHostConsoleHook(): void { }, }; } + +// --- 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); + }); + + void adoptWebviewHost(link).then(() => { + // Seed once: a webview that reloads mid-pairing has an empty mirror and no + // event coming, since the service only pushes on change. + void link + .command('pairingQueue') + .then((queue) => mirrorPairingQueue(link, (queue ?? []) as PairingQueueItem[])) + .catch(() => {}); + }); + + // Rings are detected here — the activity store and the pane labels are + // webview state — and delivered there, where the ACL is. + watchPushRings((sessionId, title) => { + void link.command('push', { sessionId, title }).catch(() => {}); + }); + + const refresh = (): void => { + void commitPushDevices(async () => { + const result = (await link.command('pushDevices')) as PushDevicesResult; + return result ? result.devices : null; + }); + }; + setPushDevicesRefresher(refresh); + refresh(); + + 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'), + }; +} + +/** + * 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; either way the webview's copy is obsolete afterwards and is cleared — + * leaving it would be a second ACL for the same hostId, diverging from the + * moment the next device pairs. + */ +async function adoptWebviewHost(link: RemoteHostLink): Promise { + const enrollment = getEnrollment(); + if (!enrollment) return; + try { + await link.command('adopt', { + enrollment, + aclRecords: loadAclRecords(enrollment.hostId), + }); + } 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; + } + clearEnrollment(); + clearAclRecords(enrollment.hostId); +} + +/** 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 Set(getPairingApprovalSnapshot().map((pending) => pending.clientId)); + for (const item of queue) { + // Re-enqueuing an unchanged request would reorder the queue and re-render + // the modal for nothing; the approve/deny closures only need the clientId. + if (mirrored.has(item.clientId)) continue; + enqueuePairingApproval({ + clientId: item.clientId, + request: item.request, + requestedAt: item.requestedAt, + approve: (label) => void link.command('approve', { clientId: item.clientId, label }).catch(() => {}), + deny: () => void link.command('deny', { clientId: item.clientId }).catch(() => {}), + }); + } +} diff --git a/lib/src/remote/host/alert-push.test.ts b/lib/src/remote/host/alert-push.test.ts index 0aa080a7..7b3a08dc 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 { refreshPushDevices, startAlertPush } from './alert-push'; +// Delivery — the Server calls, the recipient rule, the title bounds — is shared +// with the Node-resident Host, so it lives beside neither webview nor sidecar. +import { toPushText } 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'; diff --git a/lib/src/remote/host/alert-push.ts b/lib/src/remote/host/alert-push.ts index ad2d498c..7a113fb6 100644 --- a/lib/src/remote/host/alert-push.ts +++ b/lib/src/remote/host/alert-push.ts @@ -4,24 +4,17 @@ * 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. + * 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. + * * 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. - * - * 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 { getAlertSettings } from '../../lib/alert-settings'; import { watchUnattendedRings } from '../../lib/alert-ring-watch'; import { deriveSessionLabel } from '../../lib/session-label'; @@ -31,79 +24,9 @@ import { 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; +import { loadPushDevices, sendPush, type AlertPushDeps } from './push-delivery'; - 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', - })); -} +export type { AlertPushDeps }; let pushDevicesRefreshSequence = 0; @@ -113,6 +36,18 @@ let pushDevicesRefreshSequence = 0; * devices are subscribed" are different things to show a user. */ export async function refreshPushDevices(deps: AlertPushDeps): Promise { + await commitPushDevices(() => loadPushDevices(deps)); +} + +/** + * Run `load` and publish its result to the dialog's store with the fences below. + * Shared with the bridge-mode Host, which loads the same list over the service + * bridge instead of fetching it itself — and answers `null` when no Host is + * running, which is "nowhere to push", not an empty list. + */ +export async function commitPushDevices( + load: () => Promise, +): 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 @@ -129,60 +64,37 @@ export async function refreshPushDevices(deps: AlertPushDeps): Promise { }; 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); - } -} - /** - * 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)), + }); +} + +/** {@link watchPushRings}, delivered by this process's own Host. */ +export function startAlertPush(deps: AlertPushDeps): () => void { + return watchPushRings((id, title) => { + // 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, title).catch((error: unknown) => { + console.warn('remote-host: push notification failed', error); + }); }); } diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index f0acee4f..763f9b48 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -58,11 +58,16 @@ function saveEnrollment(enrollment: HostEnrollment): void { } /** - * `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: where the credentials live differs by Host — `localStorage` + * for the webview-resident one below, a 0600 file for the Node-resident service + * (`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, @@ -78,13 +83,22 @@ 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, }; +} + +/** {@link performEnrollment}, persisted to the webview's own store. */ +export async function enrollHost( + serverUrl: string, + password: string, + label: string, +): Promise { + const enrollment = await performEnrollment(serverUrl, password, label); saveEnrollment(enrollment); return enrollment; } diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index f45138ee..3405fb85 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -23,6 +23,7 @@ import { clampTerminalDimension, type DirectoryEntry } from 'server-lib-common'; import { getPlatform } from '../../lib/platform'; +import type { PeerBridge } from '../../lib/platform/types'; import { subscribeToActivity } from '../../lib/session-activity-store'; import { registry } from '../../lib/terminal-store'; import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; @@ -72,12 +73,25 @@ async function askPeers( return (await peers.request(op, params)) as PeerOps[K]['result'][]; } -/** Answer `op` for this webview's own surfaces. No-op where there are no peers. */ +/** + * Whoever this webview answers to: the Node-resident Host service when one sits + * behind the adapter, otherwise its sibling webviews. The two are the same + * question asked from different processes — "what do you own, and drive it" — + * so they share this responder rather than each getting its own copy of the + * registry logic. Only the *asking* side differs, and that stays peers-only + * ({@link askPeers}): a webview never asks the service anything. + */ +function responderBridge(): Pick | undefined { + const platform = getPlatform(); + return platform.remoteHost ?? platform.peers; +} + +/** 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().peers?.respond(op, (params) => handler(params as PeerOps[K]['params'])); + responderBridge()?.respond(op, (params) => handler(params as PeerOps[K]['params'])); } /** Directory entries contributed by every other webview and window. */ @@ -120,16 +134,17 @@ function driveOwnSurface({ surfaceId, op, cols, rows }: PeerSurfaceParams): Peer } /** - * Make this webview's terminals reachable from whichever webview is the Host. - * Idempotent, and a no-op on hosts with no peers (standalone, the website). + * Make this webview's terminals reachable from whoever is the Host — a sibling + * webview, or the service in the process that owns the PTYs. Idempotent, and a + * no-op on hosts that have neither (the website). */ export function installPeerSurfaceResponder(): void { answerPeers('directory', () => collectDirectorySnapshot()); answerPeers('surfaceOp', driveOwnSurface); - const peers = getPlatform().peers; - if (!peers) return; - const notifyDirectory = () => peers.notify('directory'); + const bridge = responderBridge(); + if (!bridge) return; + const notifyDirectory = () => bridge.notify('directory'); subscribeToTerminalPaneState(notifyDirectory); subscribeToActivity(notifyDirectory); if (typeof document !== 'undefined') { diff --git a/lib/src/remote/host/push-delivery.ts b/lib/src/remote/host/push-delivery.ts new file mode 100644 index 00000000..f2265405 --- /dev/null +++ b/lib/src/remote/host/push-delivery.ts @@ -0,0 +1,142 @@ +/** + * 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) }), + 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/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs index ccd4b1ba..fc893cb2 100644 --- a/scripts/csp-defaults.mjs +++ b/scripts/csp-defaults.mjs @@ -1,13 +1,15 @@ // The one definition of where a Host may reach a relay server, shared by both // Hosts' build scripts. // -// The standalone binary and the VS Code extension bake this into their webview -// CSP by different mechanisms — Tauri has a config file to override -// (`standalone/scripts/csp.mjs` + `tauri.mjs`), the extension has no runtime -// config so esbuild substitutes a bundle literal -// (`vscode-ext/scripts/esbuild.mjs`) — but 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". +// The two Hosts bake it in at different places, because their Hosts run in +// different processes: standalone's runs in the sidecar, so esbuild substitutes +// it into that bundle (`standalone/scripts/build-sidecar-proxy.mjs`) and the +// service refuses any origin outside it; the VS Code extension still hosts the +// Host in its webview, so esbuild substitutes it into the webview's CSP +// (`vscode-ext/scripts/esbuild.mjs`) until that Host migrates too. Either way +// 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". /** The remote-server `connect-src` sources baked into the published builds. */ export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; 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..76640b47 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -3,21 +3,35 @@ // 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 { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { 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 CONNECT_SRC_PLACEHOLDER = '__DORMOUSE_REMOTE_CONNECT_SRC__'; + 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 +41,22 @@ for (const { entry, out } of bundles) { format: 'cjs', target: 'node24', logLevel: 'warning', + ...(define ? { define } : {}), }); + // The source reads the placeholder as a `declare const`, so a lost define + // compiles fine and only fails at runtime — as a Host that silently falls back + // to the shipped default allowlist. Fail the build instead, like the VS Code + // side does (vscode-ext/scripts/esbuild.mjs). + if (assertBaked) { + const bundled = readFileSync(outfile, 'utf8'); + if (bundled.includes(CONNECT_SRC_PLACEHOLDER)) { + throw new Error( + `connect-src: ${CONNECT_SRC_PLACEHOLDER} survived into ${out} — the esbuild define did not apply.`, + ); + } + if (!bundled.includes(remoteSrc)) { + throw new Error(`connect-src: ${out} does not contain the resolved sources (${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 8041b58d..00000000 --- a/standalone/scripts/csp.mjs +++ /dev/null @@ -1,33 +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. - -// Defined once for both Hosts in scripts/csp-defaults.mjs; re-exported here so -// this module stays the single entry point for the standalone CSP rules. -export { DEFAULT_REMOTE_CONNECT_SRC } from '../../scripts/csp-defaults.mjs'; -import { DEFAULT_REMOTE_CONNECT_SRC } from '../../scripts/csp-defaults.mjs'; - -/** - * 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/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..a892c5d7 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -11,7 +11,16 @@ import type { OpenPort, PlatformAdapter, PtyInfo, + RemoteHostLink, } from "dormouse-lib/lib/platform/types"; +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"; @@ -29,6 +38,9 @@ import { BrowserSidecarHost } from "./browser-sidecar-host"; const errMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); +/** Mirrors the Tauri adapter's bound; `enroll` makes an HTTP round trip. */ +const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; + function decodeBase64Bytes(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); @@ -46,6 +58,19 @@ 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 remoteHostPending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + private remoteHostResponders = new Map unknown[]>(); + private remoteHostListeners = new Map void>>(); + private nextRemoteHostId = 0; constructor(private readonly host: BrowserSidecarHost) { this.alertManager.onStateChange((id, state) => { @@ -77,10 +102,82 @@ export class BrowserSidecarAdapter implements PlatformAdapter { this.protocolParsers.clear(); this.unlistenHost?.(); this.unlistenHost = null; + for (const pending of this.remoteHostPending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("remote host bridge closed")); + } + this.remoteHostPending.clear(); this.host.send("kill_sidecar_now"); this.host.close(); } + // --- Remote host bridge (see TauriAdapter for the contract) --- + + readonly remoteHost: RemoteHostLink = { + command: (cmd, params) => this.remoteHostCommand(cmd, params), + respond: (op, handler) => { + this.remoteHostResponders.set(op, handler); + }, + notify: (topic) => { + this.sendRemoteHostCommand({ rhId: this.nextRhId(), cmd: "notify", params: { topic } }); + }, + on: (name, listener) => { + let listeners = this.remoteHostListeners.get(name); + if (!listeners) { + listeners = new Set(); + this.remoteHostListeners.set(name, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + + private nextRhId(): string { + return `rh-${++this.nextRemoteHostId}`; + } + + private sendRemoteHostCommand(command: RemoteHostCommand): void { + this.host.send("remote_host_command", { payload: command }); + } + + private remoteHostCommand(cmd: string, params?: unknown): Promise { + const rhId = this.nextRhId(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.remoteHostPending.delete(rhId); + reject(new Error(`remote host command timed out: ${cmd}`)); + }, REMOTE_HOST_COMMAND_TIMEOUT_MS); + this.remoteHostPending.set(rhId, { resolve, reject, timer }); + this.sendRemoteHostCommand({ rhId, cmd, params }); + }); + } + + private settleRemoteHostCommand(result: RemoteHostResult): void { + const pending = this.remoteHostPending.get(result?.rhId); + if (!pending) return; + this.remoteHostPending.delete(result.rhId); + clearTimeout(pending.timer); + if (typeof result.error === "string") pending.reject(new Error(result.error)); + else pending.resolve(result.result); + } + + private answerRemoteHostAsk(ask: RemoteHostAsk): void { + const handler = this.remoteHostResponders.get(ask?.op); + let results: unknown[] = []; + try { + results = handler ? handler(ask.params) : []; + } catch (err) { + console.error(`[browser-sidecar] remote host ask ${ask?.op} failed:`, err); + } + this.sendRemoteHostCommand({ + rhId: this.nextRhId(), + cmd: "answer", + params: { rhId: ask.rhId, results }, + }); + } + async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> { try { return await this.host.invoke("get_available_shells"); @@ -259,6 +356,15 @@ 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.settleRemoteHostCommand(data as RemoteHostResult); + } else if (event === REMOTE_HOST_ASK_EVENT) { + this.answerRemoteHostAsk(data as RemoteHostAsk); + } else if (event === REMOTE_HOST_EVENT_EVENT) { + const name = (data as { name?: string } | null)?.name; + if (typeof name === "string") { + for (const listener of this.remoteHostListeners.get(name) ?? []) listener(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 ee8303a3..945dcbd9 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 { setDefaultShellOpts } from "dormouse-lib/lib/shell-defaults"; @@ -83,6 +84,11 @@ async function createPlatform(): Promise { async function bootstrap() { const platform = await createPlatform(); setPlatform(platform); + // 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). + installPeerSurfaceResponder(); await platform.init(); // Quit orchestrator (docs/specs/standalone.md §Quit flow). Tauri-only: the // browser-dev harness has no Rust quit interception, and quit.ts pulls the diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 7873f6fd..3e020932 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,130 @@ 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. +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("rejects with the error the service reported", async () => { + const { adapter, sent, deliver } = await bridged(); + const pending = adapter.remoteHost.command("enroll", { serverUrl: "https://nope" }); + deliver("remoteHost:result", { rhId: sent()[0]!.rhId, error: "outside the allowed sources" }); + await expect(pending).rejects.toThrow("outside the allowed sources"); + }); + + it("rejects when the sidecar never answers", async () => { + const { adapter, deliver } = await bridged(); + vi.useFakeTimers(); + try { + const pending = adapter.remoteHost.command("status"); + const rejected = expect(pending).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(20_000); + await rejected; + // The late answer finds nothing to settle. + expect(() => deliver("remoteHost:result", { rhId: "rh-1", result: {} })).not.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + 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("answers with nothing rather than leaving an ask open", async () => { + const { adapter, sent, deliver } = await bridged(); + // Nobody responds to this op, and a handler that throws is the same case: + // the service would otherwise hold the ask for its whole budget. + deliver("remoteHost:ask", { rhId: "ask-1", op: "directory", params: {} }); + adapter.remoteHost.respond("directory", () => { + throw new Error("registry blew up"); + }); + deliver("remoteHost:ask", { rhId: "ask-2", op: "directory", params: {} }); + + expect(sent().map((p) => p.params)).toEqual([ + { rhId: "ask-1", results: [] }, + { rhId: "ask-2", results: [] }, + ]); + }); + + it("fans events out by name, and stops after unsubscribe", async () => { + const { adapter, deliver } = await bridged(); + const seen: unknown[] = []; + const unsubscribe = adapter.remoteHost.on("pairing-queue", (data) => void seen.push(data)); + + deliver("remoteHost:event", { name: "pairing-queue", queue: [{ clientId: "c1" }] }); + deliver("remoteHost:event", { name: "something-else", queue: [] }); + expect(seen).toEqual([{ name: "pairing-queue", queue: [{ clientId: "c1" }] }]); + + unsubscribe(); + deliver("remoteHost:event", { name: "pairing-queue", queue: [] }); + expect(seen).toHaveLength(1); + }); + + it("notifies without waiting for anything", async () => { + const { adapter, sent } = await bridged(); + adapter.remoteHost.notify("directory"); + expect(sent()[0]).toMatchObject({ cmd: "notify", params: { topic: "directory" } }); + }); + + it("rejects what is still in flight when the bridge closes", 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..fd318fea 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -14,7 +14,16 @@ import type { OpenPort, PlatformAdapter, PtyInfo, + RemoteHostLink, } from "dormouse-lib/lib/platform/types"; +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"; @@ -42,6 +51,13 @@ function invoke(cmd: string, args?: Record): void { const errMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); +/** + * How long a remote-host command may wait for the sidecar. Generous — `enroll` + * makes an HTTP round trip to the relay server — but finite, so a dead sidecar + * surfaces as a rejected promise instead of a hung console call. + */ +const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; + /** * Platform adapter for the Tauri standalone app. * @@ -72,6 +88,20 @@ export class TauriAdapter implements PlatformAdapter { private flushHandlers = new Set<(detail: { requestId: string }) => void>(); private pendingFlushRequests = new Map void>(); private nextFlushRequestId = 0; + // Remote-host bridge state (docs/specs/server.md; the contract is + // lib/src/host/remote/service-protocol.ts). Correlation is `rhId`, never + // `requestId` — Rust swallows any sidecar line carrying the latter. + private remoteHostPending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + private remoteHostResponders = new Map unknown[]>(); + private remoteHostListeners = new Map void>>(); + private nextRemoteHostId = 0; constructor() { // Wire alert manager state changes to handlers @@ -146,6 +176,26 @@ export class TauriAdapter implements PlatformAdapter { }), ); + this.unlistenFns.push( + await listen(REMOTE_HOST_RESULT_EVENT, (event) => { + this.settleRemoteHostCommand(event.payload); + }), + ); + + this.unlistenFns.push( + await listen(REMOTE_HOST_ASK_EVENT, (event) => { + this.answerRemoteHostAsk(event.payload); + }), + ); + + this.unlistenFns.push( + await listen<{ name?: string }>(REMOTE_HOST_EVENT_EVENT, (event) => { + const name = event.payload?.name; + if (typeof name !== "string") return; + for (const listener of this.remoteHostListeners.get(name) ?? []) listener(event.payload); + }), + ); + this.unlistenFns.push( await listen("dor:controlRequest", (event) => { const payload = event.payload; @@ -197,6 +247,12 @@ export class TauriAdapter implements PlatformAdapter { unlisten(); } this.unlistenFns = []; + // Nothing will answer these once the sidecar is gone. + for (const pending of this.remoteHostPending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("remote host bridge closed")); + } + this.remoteHostPending.clear(); invoke("kill_sidecar_now"); } @@ -443,6 +499,83 @@ export class TauriAdapter implements PlatformAdapter { ); } + // --- 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. + + readonly remoteHost: RemoteHostLink = { + command: (cmd, params) => this.remoteHostCommand(cmd, params), + respond: (op, handler) => { + this.remoteHostResponders.set(op, handler); + }, + notify: (topic) => { + this.sendRemoteHostCommand({ rhId: this.nextRhId(), cmd: "notify", params: { topic } }); + }, + on: (name, listener) => { + let listeners = this.remoteHostListeners.get(name); + if (!listeners) { + listeners = new Set(); + this.remoteHostListeners.set(name, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + + private nextRhId(): string { + return `rh-${++this.nextRemoteHostId}`; + } + + private sendRemoteHostCommand(command: RemoteHostCommand): void { + rawInvoke("remote_host_command", { payload: command }).catch((err) => + console.error("[tauri-adapter] remote_host_command failed:", err), + ); + } + + private remoteHostCommand(cmd: string, params?: unknown): Promise { + const rhId = this.nextRhId(); + return new Promise((resolve, reject) => { + // Bounded: a sidecar that died mid-command must reject rather than leave + // the console hook (or the device dialog) waiting forever. + const timer = setTimeout(() => { + this.remoteHostPending.delete(rhId); + reject(new Error(`remote host command timed out: ${cmd}`)); + }, REMOTE_HOST_COMMAND_TIMEOUT_MS); + this.remoteHostPending.set(rhId, { resolve, reject, timer }); + this.sendRemoteHostCommand({ rhId, cmd, params }); + }); + } + + private settleRemoteHostCommand(result: RemoteHostResult): void { + const pending = this.remoteHostPending.get(result?.rhId); + if (!pending) return; + this.remoteHostPending.delete(result.rhId); + clearTimeout(pending.timer); + if (typeof result.error === "string") pending.reject(new Error(result.error)); + else pending.resolve(result.result); + } + + private answerRemoteHostAsk(ask: RemoteHostAsk): void { + const handler = this.remoteHostResponders.get(ask?.op); + let results: unknown[] = []; + try { + results = handler ? handler(ask.params) : []; + } catch (err) { + console.error(`[tauri-adapter] remote host ask ${ask?.op} failed:`, err); + } + // Always answer, even with nothing: the service holds the ask open for its + // whole budget otherwise, and an attach waits on it. + this.sendRemoteHostCommand({ + rhId: this.nextRhId(), + cmd: "answer", + params: { rhId: ask.rhId, results }, + }); + } + // --- Alert management (local AlertManager) --- alertRemove(id: string): void { From a6c2ac4794bffa12e758f5a13e9d5084f3c5ef43 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 18:58:20 -0700 Subject: [PATCH 29/56] Move the VS Code remote Host into the extension host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same RemoteHostService the sidecar runs now runs in the extension host — the process that already owns the PTYs and already strips terminal protocol once per chunk, so the service streams processed output directly and adds no second parser. Webviews are demoted to surface responders plus the pairing modal and console hook, reached over the same rhId bridge as standalone; enrollment lives in SecretStorage and the ACL in globalState, read directly, under the same keys the webview store bridge wrote, so an enrolled Host carries over with no migration. The webview CSP loses its relay sources; the baked allowlist moves to the service's connect gate. Which window hosts is decided by bind-as-lease: every enrolled window contends for one fixed-path socket, the bind is the arbitration, and a role never flips downward while a process lives — the transition races the heartbeat lease design guarded against are unrepresentable rather than handled. A window that loses the bind stays a client and races to rebind when the broker dies; until phase 3b wires the second tier, its webviews are told the Host runs in another window. Deleted with the old design: the heartbeat window lease and its filesystem watcher, the webview singleton election, the store hydration/broadcast/snapshot tier and the localStorage prefix claims, the webview-resident Host assembly and its registry fast path, and the foreign-PTY subscription plumbing. Net -1,900 lines. Co-Authored-By: Claude Fable 5 --- docs/specs/vscode.md | 4 +- lib/src/host/remote/host-state-store.ts | 4 + lib/src/lib/local-json-store.test.ts | 69 +-- lib/src/lib/local-json-store.ts | 46 +- lib/src/lib/platform/types.ts | 97 +--- lib/src/lib/platform/vscode-adapter.test.ts | 210 +++---- lib/src/lib/platform/vscode-adapter.ts | 269 +++------ lib/src/lib/vscode-peer-link-protocol.ts | 7 - lib/src/lib/vscode-window-lease.test.ts | 186 ------- lib/src/lib/vscode-window-lease.ts | 101 ---- lib/src/main.tsx | 19 +- .../remote/host/RemotePairingModalHost.tsx | 24 +- lib/src/remote/host/activation.test.ts | 172 +----- lib/src/remote/host/activation.ts | 257 +-------- lib/src/remote/host/alert-push.test.ts | 48 +- lib/src/remote/host/alert-push.ts | 38 +- lib/src/remote/host/host-surface-provider.ts | 12 +- lib/src/remote/host/peer-surfaces.test.ts | 431 ++++----------- lib/src/remote/host/peer-surfaces.ts | 74 +-- lib/src/remote/host/store.ts | 32 +- lib/src/remote/host/surface-resolve.ts | 94 ---- standalone/src/browser-sidecar-adapter.ts | 9 +- standalone/src/tauri-adapter.ts | 13 +- vscode-ext/scripts/esbuild.mjs | 20 +- vscode-ext/src/extension.ts | 15 +- vscode-ext/src/message-router.ts | 310 ++--------- vscode-ext/src/message-types.ts | 25 +- vscode-ext/src/peer-link.ts | 522 ++++++++++-------- vscode-ext/src/pty-subscriptions.ts | 33 -- vscode-ext/src/remote-host-store.ts | 136 ++--- vscode-ext/src/remote-host.ts | 259 +++++++++ vscode-ext/src/watch-dir-file.ts | 48 -- vscode-ext/src/webview-html.ts | 17 +- vscode-ext/src/window-lease.ts | 198 ------- vscode-ext/test/peer-link.test.ts | 217 +++++--- vscode-ext/test/pty-subscriptions.test.ts | 37 -- vscode-ext/test/remote-host.test.ts | 342 ++++++++++++ vscode-ext/test/watch-dir-file.test.ts | 46 -- vscode-ext/test/window-lease.test.ts | 138 ----- 39 files changed, 1631 insertions(+), 2948 deletions(-) delete mode 100644 lib/src/lib/vscode-window-lease.test.ts delete mode 100644 lib/src/lib/vscode-window-lease.ts delete mode 100644 lib/src/remote/host/surface-resolve.ts delete mode 100644 vscode-ext/src/pty-subscriptions.ts create mode 100644 vscode-ext/src/remote-host.ts delete mode 100644 vscode-ext/src/watch-dir-file.ts delete mode 100644 vscode-ext/src/window-lease.ts delete mode 100644 vscode-ext/test/pty-subscriptions.test.ts create mode 100644 vscode-ext/test/remote-host.test.ts delete mode 100644 vscode-ext/test/watch-dir-file.test.ts delete mode 100644 vscode-ext/test/window-lease.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 8fe48a61..efbb72e1 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -280,7 +280,7 @@ a first enrollment succeeds and initiates the claim. A user who never enrolls a Host therefore gets no heartbeat file, timer, or peer socket merely by opening Dormouse. -Source of truth: the rules and the cycle in `lib/src/lib/vscode-window-lease.ts` (tested in `lib/src/lib/vscode-window-lease.test.ts`), the filesystem and timers around them in `vscode-ext/src/window-lease.ts`, and `windowLeaseHeld` gating `electSingleton` in `vscode-ext/src/message-router.ts`. +Source of truth: `ensurePeerNet` in `vscode-ext/src/peer-link.ts` (tested in `vscode-ext/test/peer-link.test.ts`), and the service it gates in `vscode-ext/src/remote-host.ts`. Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PeerBridge.claimSingleton` in `lib/src/lib/platform/types.ts`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. @@ -352,7 +352,7 @@ host error. Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and roles, `lib/src/lib/vscode-peer-link-protocol.ts` for the frames, framing, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`), and the `remote*` calls in `vscode-ext/src/message-router.ts`. -Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, the resolver in `lib/src/remote/host/surface-resolve.ts`, and the attachment it backs in `lib/src/remote/host/remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. +Source of truth: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, the resolver in `vscode-ext/src/remote-host.ts`, and the attachment it backs in `lib/src/remote/host/remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. ### Testing the extension host diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index 8cccd646..521616eb 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -14,6 +14,10 @@ import { join } from 'node:path'; import type { HostAclRecord } from 'server-lib-common'; import type { HostEnrollment } from '../../remote/host/enrollment'; +// 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 { loadEnrollment(): Promise; saveEnrollment(enrollment: HostEnrollment): Promise; diff --git a/lib/src/lib/local-json-store.test.ts b/lib/src/lib/local-json-store.test.ts index 737e9e61..080684ba 100644 --- a/lib/src/lib/local-json-store.test.ts +++ b/lib/src/lib/local-json-store.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { loadJson, removeJson, saveJson, setJsonStoreBackend } from './local-json-store'; +import { loadJson, removeJson, saveJson } from './local-json-store'; -/** A Map-backed `Storage` surface, usable as a stub or as a claimed backend. */ +/** A Map-backed `Storage` surface. */ function memoryStore() { const map = new Map(); return { @@ -105,69 +105,4 @@ describe('local-json-store', () => { expect(() => removeJson('k')).not.toThrow(); }); }); - - describe('prefix-claimed backends', () => { - afterEach(() => setJsonStoreBackend('a.', null)); - - it('routes a claimed prefix to its backend and leaves other keys on localStorage', () => { - const local = stubLocalStorage(); - const backend = memoryStore(); - setJsonStoreBackend('a.', backend); - - saveJson('a.one', { id: 'w1' }); - saveJson('other.two', { id: 'w2' }); - - expect(backend.map.has('a.one')).toBe(true); - // The unrelated key must not be swept into the claimed backend — this is - // what keeps alert settings and watched commands on their own storage. - expect(backend.map.has('other.two')).toBe(false); - expect(local.get('other.two')).toBe(JSON.stringify({ id: 'w2' })); - expect(local.has('a.one')).toBe(false); - - expect(loadJson('a.one', null, isWidget)).toEqual({ id: 'w1' }); - expect(loadJson('other.two', null, isWidget)).toEqual({ id: 'w2' }); - }); - - it('releases a claim back to localStorage', () => { - const local = stubLocalStorage(); - const backend = memoryStore(); - setJsonStoreBackend('a.', backend); - setJsonStoreBackend('a.', null); - - saveJson('a.one', { id: 'w1' }); - - expect(backend.map.size).toBe(0); - expect(local.get('a.one')).toBe(JSON.stringify({ id: 'w1' })); - }); - - it('removeJson deletes through the claimed backend', () => { - stubLocalStorage(); - const backend = memoryStore(); - setJsonStoreBackend('a.', backend); - saveJson('a.one', { id: 'w1' }); - - removeJson('a.one'); - - expect(backend.map.has('a.one')).toBe(false); - }); - - it('a throwing backend never propagates', () => { - stubLocalStorage(); - setJsonStoreBackend('a.', { - getItem: () => { - throw new Error('nope'); - }, - setItem: () => { - throw new Error('nope'); - }, - removeItem: () => { - throw new Error('nope'); - }, - }); - - expect(() => saveJson('a.one', 1)).not.toThrow(); - expect(loadJson('a.one', 'fallback')).toBe('fallback'); - expect(() => removeJson('a.one')).not.toThrow(); - }); - }); }); diff --git a/lib/src/lib/local-json-store.ts b/lib/src/lib/local-json-store.ts index a54b8414..e62c977f 100644 --- a/lib/src/lib/local-json-store.ts +++ b/lib/src/lib/local-json-store.ts @@ -13,42 +13,8 @@ * Each caller supplies its own key, fallback, and (optionally) a type guard, so * the fallback and validation stay caller-specific while the boilerplate lives * here once. - * - * `localStorage` is the default backend, but a host whose storage lives - * elsewhere can claim a key prefix with {@link setJsonStoreBackend} — the VS - * Code webview routes `dormouse.remote-host.*` to the extension host, whose - * `SecretStorage` holds the Host's bearer credential (docs/specs/vscode.md). - * The claim is per-prefix rather than global so unrelated stores (alert - * settings, watched commands) keep their own backend. */ -/** The minimal `localStorage` surface these helpers use. */ -export interface JsonStoreBackend { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -} - -/** Claimed prefixes. Claims must not overlap; the first match wins. */ -const backends = new Map(); - -/** - * Route every key starting with `prefix` to `backend`. Pass `null` to release - * the claim. Backends must be synchronous: callers read at module init and on - * every access, so an async store has to be hydrated into memory first. - */ -export function setJsonStoreBackend(prefix: string, backend: JsonStoreBackend | null): void { - if (backend) backends.set(prefix, backend); - else backends.delete(prefix); -} - -function backendFor(key: string): JsonStoreBackend | undefined { - for (const [prefix, backend] of backends) { - if (key.startsWith(prefix)) return backend; - } - return globalThis.localStorage as JsonStoreBackend | undefined; -} - /** * Read and JSON-parse the value at `key`, returning `fallback` if storage is * unavailable, the key is missing, the JSON is malformed, or `validate` (when @@ -60,7 +26,7 @@ export function loadJson( validate?: (value: unknown) => value is V, ): V | F { try { - const raw = backendFor(key)?.getItem(key); + const raw = globalThis.localStorage?.getItem(key); if (!raw) return fallback; const parsed: unknown = JSON.parse(raw); if (validate && !validate(parsed)) return fallback; @@ -76,20 +42,16 @@ export function loadJson( */ export function saveJson(key: string, value: unknown): void { try { - backendFor(key)?.setItem(key, JSON.stringify(value)); + globalThis.localStorage?.setItem(key, JSON.stringify(value)); } catch { // No localStorage / quota exceeded: the in-memory value still works. } } -/** - * Delete the value at `key`, swallowing any failure. Callers must go through - * this rather than touching `localStorage` directly, or a claimed prefix would - * clear the wrong store. - */ +/** Delete the value at `key`, swallowing any failure. */ export function removeJson(key: string): void { try { - backendFor(key)?.removeItem(key); + 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 a187d12c..635253f7 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -112,72 +112,20 @@ export interface AgentBrowserPopResult { error?: string; } -/** - * Coordination between the several webviews one host backend can show - * (docs/specs/vscode.md → "Peer surfaces"). - * - * The remote Host runs in exactly one webview, but a window's terminals are - * spread across all of them and each webview has its own xterm registry. The - * Host therefore cannot list or drive a sibling's pane directly; the host - * process brokers, and this is the webview end of that. - * - * Arbitrating a single-holder role and asking a sibling a question are two - * facets of one precondition — being able to show more than one webview over - * one backend — so they sit behind one optional member rather than two. A host - * either has peers to elect among and ask, or it has neither: standalone and - * the website are one webview per app, so they omit this and callers treat - * themselves as the only instance. - * - * `op` is deliberately opaque here. *What* a peer can be asked is a property of - * the remote Host, not of the platform, so the operation map and its real types - * live in `lib/src/remote/host/peer-surfaces.ts`; this layer, the extension-host - * broker, and the cross-window link only carry the bytes. - */ -export interface PeerBridge { - /** - * Claim a named role that at most one webview may hold, and be told whenever - * the claim is granted or revoked. The host arbitrates, because it is the - * only party that sees every webview and outlives each one. - */ - claimSingleton(name: string, onChange: (held: boolean) => void): void; - - /** - * Put `op` to every peer and collect what they answer. Each peer contributes - * zero or more results, so an empty array means nobody owned what was asked - * about — there is no separate miss signal. - */ - request(op: 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 for `topic` may differ. */ - notify(topic: string): void; - - /** Re-run a peer-backed subscription after another webview announces `topic`. */ - subscribe(topic: string, listener: () => void): () => void; - - /** - * Start receiving `pty:data` / `pty:exit` for a PTY this webview does not - * own, and return the unsubscribe. A subscription, not a pair of calls, so - * the caller cannot leak one by forgetting the id it used. - */ - streamPty(ptyId: string): () => void; -} - /** * The webview end of a Node-resident remote Host * (`lib/src/host/remote/service-protocol.ts`). * - * When the Host runs in the process that owns the PTYs, the webview stops being - * the Host and becomes 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. `respond`/`notify` are deliberately the same shape - * as {@link PeerBridge}'s, so one responder implementation serves a webview that - * answers a sibling and a webview that answers the service. + * 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 opaque here for the same reason they are on `PeerBridge`: - * *what* the service can be asked belongs to the remote Host, not the platform. + * `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. */ @@ -203,29 +151,10 @@ export interface PlatformAdapter { shutdown(): void; /** - * Make every key under `prefix` readable synchronously from a host-owned - * store instead of `localStorage`, then keep it written through. Optional: - * only hosts whose real storage lives outside the webview implement it (VS - * Code, where the extension host holds `SecretStorage`). Callers must await - * it before any module reads those keys, because `local-json-store` is - * synchronous by contract. Adapters that omit it leave `localStorage` in - * charge, which is correct for standalone and the website. - */ - hydrateScopedStore?(prefix: string): Promise; - - /** - * Elect among, and reach surfaces owned by, sibling webviews. Optional: only - * a host that can show several webviews over one backend has peers at all - * (VS Code). Adapters that omit it are single-instance, so callers hold every - * role and have nobody to ask. - */ - peers?: PeerBridge; - - /** - * Reach the remote Host service behind this host. Present exactly when the - * Host runs outside the webview (standalone's sidecar), which is also exactly - * when this webview is a surface responder rather than the Host itself. - * Adapters that omit it host the Host in the webview (VS Code, the website). + * 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; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index aa124ce2..8b38a6f4 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -29,7 +29,6 @@ import { } from '../terminal-protocol'; import { HOST_MESSAGE_TOKEN_FIELD, HOST_MESSAGE_TOKEN_GLOBAL } from '../vscode-message-token'; import { VSCodeAdapter } from './vscode-adapter'; -import { loadJson, saveJson, setJsonStoreBackend } from '../local-json-store'; /** Stand-in for the per-boot token the extension host injects at webview boot. */ const HOST_TOKEN = 'test-host-message-token'; @@ -383,149 +382,152 @@ describe('VSCodeAdapter PTY exit handling', () => { }); -describe('VSCodeAdapter host store', () => { - const PREFIX = 'dormouse.remote-host.'; - const KEY = `${PREFIX}acl.host-1`; - +// 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. +describe('VSCodeAdapter remote host link', () => { beforeEach(stubWebviewEnv); afterEach(() => { - setJsonStoreBackend(PREFIX, null); vi.unstubAllGlobals(); vi.clearAllMocks(); }); - /** Answer the `store:read` the adapter just posted, as the host would. */ - function answerRead(entries: Record): void { - const request = postMessage.mock.calls.map((call) => call[0]).find((m) => m.type === 'store:read'); - expect(request).toBeTruthy(); - windowTarget.dispatchEvent( - hostMessage({ type: 'store:entries', requestId: request.requestId, entries }), - ); + /** 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); } - async function hydrated(entries: Record) { - const adapter = new VSCodeAdapter(); - const done = adapter.hydrateScopedStore(PREFIX); - answerRead(entries); - await done; - return adapter; + function deliver(data: Record): void { + windowTarget.dispatchEvent(hostMessage(data)); } - it('serves reads from the hydrated snapshot', async () => { - await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); - expect(loadJson(KEY, [])).toEqual([{ id: 'a' }]); - }); - - it('writes through to the host', async () => { - await hydrated({}); - saveJson(KEY, [{ id: 'b' }]); - expect(postMessage).toHaveBeenCalledWith({ - type: 'store:write', - key: KEY, - value: JSON.stringify([{ id: 'b' }]), - }); - }); + it('resolves a command by its rhId', async () => { + const adapter = new VSCodeAdapter(); + const pending = adapter.remoteHost.command('status'); - it("applies another webview's committed write, so a later lease grant is not stale", async () => { - await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); + const payload = sent()[0]!; + expect(payload.cmd).toBe('status'); + // A result for someone else's rhId must not resolve this one. + deliver({ type: 'remoteHost:result', payload: { rhId: 'other', result: { enrolled: false } } }); + deliver({ type: 'remoteHost:result', payload: { rhId: payload.rhId, result: { enrolled: true } } }); - // The webview holding the lease approves a pairing; the host broadcasts it. - windowTarget.dispatchEvent( - hostMessage({ type: 'store:changed', key: KEY, value: JSON.stringify([{ id: 'a' }, { id: 'b' }]) }), - ); - - // Without this the next holder would start from the boot snapshot and write - // it back, dropping the pairing permanently. - expect(loadJson(KEY, [])).toEqual([{ id: 'a' }, { id: 'b' }]); + expect(await pending).toEqual({ enrolled: true }); }); - it('applies a broadcast deletion', async () => { - await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); - - windowTarget.dispatchEvent(hostMessage({ type: 'store:changed', key: KEY, value: null })); - - expect(loadJson(KEY, null)).toBeNull(); + it('mints rhIds no sibling webview can collide with', () => { + // Results are broadcast to every webview in the window, so two adapters + // counting from 1 would settle each other's commands. + const a = new VSCodeAdapter(); + const b = new VSCodeAdapter(); + void a.remoteHost.command('status'); + void b.remoteHost.command('status'); + + const ids = sent().map((payload) => payload.rhId); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); }); - it('replaces stale cached keys from a lease-handoff snapshot', async () => { - const enrollmentKey = `${PREFIX}enrollment`; - await hydrated({ - [KEY]: JSON.stringify([{ id: 'old' }]), - [enrollmentKey]: JSON.stringify({ hostId: 'host-1' }), + it('rejects with the error the service reported', async () => { + const adapter = new VSCodeAdapter(); + const pending = adapter.remoteHost.command('enroll', { serverUrl: 'https://nope' }); + deliver({ + type: 'remoteHost:result', + payload: { rhId: sent()[0]!.rhId, error: 'the remote Host runs in another VS Code window' }, }); + await expect(pending).rejects.toThrow('another VS Code window'); + }); - windowTarget.dispatchEvent(hostMessage({ - type: 'store:snapshot', - prefix: PREFIX, - entries: { [KEY]: JSON.stringify([{ id: 'new' }]) }, - })); - - expect(loadJson(KEY, [])).toEqual([{ id: 'new' }]); - expect(loadJson(enrollmentKey, null)).toBeNull(); + it('rejects when the extension host never answers', async () => { + const adapter = new VSCodeAdapter(); + vi.useFakeTimers(); + try { + const pending = adapter.remoteHost.command('status'); + const rejected = expect(pending).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(20_000); + await rejected; + // The late answer finds nothing to settle. + expect(() => + deliver({ type: 'remoteHost:result', payload: { rhId: sent()[0]!.rhId, result: {} } }), + ).not.toThrow(); + } finally { + vi.useRealTimers(); + } }); - it('uses a lease-handoff snapshot that arrives during hydration', async () => { + it('answers an ask from the registered responder', () => { const adapter = new VSCodeAdapter(); - const done = adapter.hydrateScopedStore(PREFIX); + adapter.remoteHost.respond('surfaceOp', (params) => [ + { ptyId: 'pty-1', ...(params as Record) }, + ]); - windowTarget.dispatchEvent(hostMessage({ - type: 'store:snapshot', - prefix: PREFIX, - entries: { [KEY]: JSON.stringify([{ id: 'fresh' }]) }, - })); - answerRead({ [KEY]: JSON.stringify([{ id: 'stale' }]) }); - await done; + deliver({ type: 'peer:ask', requestId: 'ask-1', op: 'surfaceOp', params: { surfaceId: 's1' } }); - expect(loadJson(KEY, [])).toEqual([{ id: 'fresh' }]); + expect(postMessage).toHaveBeenCalledWith({ + type: 'peer:answer', + requestId: 'ask-1', + results: [{ ptyId: 'pty-1', surfaceId: 's1' }], + }); }); - it('ignores an unauthenticated broadcast', async () => { - await hydrated({ [KEY]: JSON.stringify([{ id: 'a' }]) }); - - windowTarget.dispatchEvent( - hostMessage({ type: 'store:changed', key: KEY, value: JSON.stringify([]) }, 'wrong-token'), - ); - - expect(loadJson(KEY, [])).toEqual([{ id: 'a' }]); + it('answers with nothing rather than leaving an ask open', () => { + const adapter = new VSCodeAdapter(); + // Nobody responds to this op, and a handler that throws is the same case: + // the broker would otherwise hold the fan-out for its whole budget. + deliver({ type: 'peer:ask', requestId: 'ask-1', op: 'directory', params: {} }); + adapter.remoteHost.respond('directory', () => { + throw new Error('registry blew up'); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + deliver({ type: 'peer:ask', requestId: 'ask-2', op: 'directory', params: {} }); + + const answers = postMessage.mock.calls + .map((call) => call[0]) + .filter((message) => message.type === 'peer:answer'); + expect(answers).toEqual([ + { type: 'peer:answer', requestId: 'ask-1', results: [] }, + { type: 'peer:answer', requestId: 'ask-2', results: [] }, + ]); }); - it('keeps a write committed while the read is still in flight', async () => { + it('fans events out by name, and stops after unsubscribe', () => { const adapter = new VSCodeAdapter(); - const done = adapter.hydrateScopedStore(PREFIX); + const seen: unknown[] = []; + const unsubscribe = adapter.remoteHost.on('pairing-queue', (data) => void seen.push(data)); - // The host snapshots globalState before it waits on the keychain, so the - // holder can commit a pairing that the in-flight snapshot cannot contain. - windowTarget.dispatchEvent( - hostMessage({ type: 'store:changed', key: KEY, value: JSON.stringify([{ id: 'a' }, { id: 'b' }]) }), - ); - answerRead({ [KEY]: JSON.stringify([{ id: 'a' }]) }); - await done; + deliver({ type: 'remoteHost:event', payload: { name: 'pairing-queue', queue: [{ clientId: 'c1' }] } }); + deliver({ type: 'remoteHost:event', payload: { name: 'something-else', queue: [] } }); + expect(seen).toEqual([{ name: 'pairing-queue', queue: [{ clientId: 'c1' }] }]); - expect(loadJson(KEY, [])).toEqual([{ id: 'a' }, { id: 'b' }]); + unsubscribe(); + deliver({ type: 'remoteHost:event', payload: { name: 'pairing-queue', queue: [] } }); + expect(seen).toHaveLength(1); }); - it('keeps a deletion committed while the read is still in flight', async () => { + it('notifies without waiting for anything', () => { const adapter = new VSCodeAdapter(); - const done = adapter.hydrateScopedStore(PREFIX); - - windowTarget.dispatchEvent(hostMessage({ type: 'store:changed', key: KEY, value: null })); - answerRead({ [KEY]: JSON.stringify([{ id: 'a' }]) }); - await done; - - expect(loadJson(KEY, null)).toBeNull(); + adapter.remoteHost.notify('directory'); + expect(postMessage).toHaveBeenCalledWith({ type: 'peer:notify', topic: 'directory' }); }); - it('installs an empty cache when the host never answers', async () => { + it('ignores an unauthenticated result, so framed content cannot settle a command', async () => { + const adapter = new VSCodeAdapter(); vi.useFakeTimers(); try { - const adapter = new VSCodeAdapter(); - const done = adapter.hydrateScopedStore(PREFIX); - await vi.advanceTimersByTimeAsync(10_000); - await done; + 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(); } - expect(loadJson(KEY, null)).toBeNull(); }); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 741c797a..349d1532 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 type { RemoteHostCommand, RemoteHostResult } from '../../host/remote/service-protocol'; import type { AlertSettings } from '../alert-settings'; import { readInjectedRecoveryCommands } from '../vscode-recovery-global'; import { setDefaultShellOpts } from '../shell-defaults'; @@ -14,18 +15,24 @@ import { getTerminalTheme, onTerminalThemeChange } from '../terminal-theme'; import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; -import type { PeerBridge } from './types'; -import { PEER_REQUEST_TIMEOUT_MS } from '../vscode-peer-link-protocol'; -import { setJsonStoreBackend } from '../local-json-store'; /** - * Budget for the boot-time host-store read. Generous because it is gated on an - * OS keychain unlock, and a miss degrades the Host to "un-enrolled" rather than - * failing loudly. + * How long a remote-host command may wait for the extension host. Generous — + * `enroll` makes an HTTP round trip to the relay server — but finite, so a + * broker window that went away surfaces as a rejected promise instead of a hung + * console call. Mirrors the standalone adapters' bound. */ -const HOST_STORE_READ_TIMEOUT_MS = 10_000; - +const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; +/** + * A short random component for this adapter's `rhId`s. Every webview in the + * window sees every `remoteHost:result`, 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); +} export class VSCodeAdapter implements PlatformAdapter { // VS Code owns the theme here: it provides --vscode-* itself and has its own @@ -45,20 +52,17 @@ 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>(); - private singletonHandlers = new Map void>(); - private peerChangeHandlers = new Map void>>(); - /** Hydrated host-store caches, by claimed prefix — see `hydrateScopedStore`. */ - private scopedCaches = new Map>(); - /** - * Broadcasts that landed before their prefix finished hydrating. The read is - * gated on a keychain unlock, so this window is wide enough to matter: the - * host snapshots `globalState` before that wait, so another webview can - * commit a change that the in-flight snapshot will not contain. Carries the - * value, not just the key, because a deletion has to survive too. - */ - private pendingStoreChanges = new Map(); - /** Fresh lease-handoff snapshots that arrived before boot hydration finished. */ - private pendingStoreSnapshots = new Map>(); + // Remote-host bridge state (the contract is + // lib/src/host/remote/service-protocol.ts). Results are broadcast to every + // webview, so `rhId` carries a per-adapter tag — see `nextRhId`. + private remoteHostPending = new Map< + string, + { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType } + >(); + private remoteHostResponders = new Map unknown[]>(); + private remoteHostListeners = new Map void>>(); + private readonly rhTag = randomTag(); + private nextRemoteHostId = 0; constructor() { this.vscode = acquireVsCodeApi(); @@ -180,29 +184,14 @@ export class VSCodeAdapter implements PlatformAdapter { respond, }, })); - } else if (msg.type === 'singleton:lease') { - this.singletonHandlers.get(msg.name)?.(!!msg.held); - } else if (msg.type === 'store:changed') { - this.applyStoreChange(msg.key, msg.value ?? null); - } else if (msg.type === 'store:snapshot') { - this.applyStoreSnapshot(msg.prefix, msg.entries ?? {}); } else if (msg.type === 'peer:ask') { - // Answer even with no responder installed, and even to say nothing: the - // broker settles once every webview 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. - this.vscode.postMessage({ - type: 'peer:answer', - requestId: msg.requestId, - results: this.peerResponders.get(msg.op)?.(msg.params) ?? [], - }); - } else if (msg.type === 'peer:changed') { - if (msg.topic === null) { - for (const handlers of this.peerChangeHandlers.values()) { - for (const handler of handlers) handler(); - } - } else { - for (const handler of this.peerChangeHandlers.get(msg.topic) ?? []) handler(); + this.answerRemoteHostAsk(msg.requestId, msg.op, msg.params); + } else if (msg.type === 'remoteHost:result') { + this.settleRemoteHostCommand(msg.payload); + } else if (msg.type === 'remoteHost:event') { + const name = (msg.payload as { name?: unknown } | null)?.name; + if (typeof name === 'string') { + for (const listener of this.remoteHostListeners.get(name) ?? []) listener(msg.payload); } } }); @@ -243,158 +232,78 @@ export class VSCodeAdapter implements PlatformAdapter { // No initialization needed — the webview is already running } - /** - * Elect among, and reach terminals owned by, sibling webviews — both brokered - * by the extension host (docs/specs/vscode.md → "Peer surfaces"). Present - * unconditionally: every webview both asks (when it is the Host) and answers - * (for its own panes). - */ - readonly peers: PeerBridge = { - /** - * Ask the extension host for a named single-instance role and report every - * grant/revoke. The extension host is the arbiter because it is the only - * thing that outlives and sees all of this window's webviews; it re-offers - * the role when the holder is disposed, so closing the Dormouse view hands - * the Host to another open one rather than dropping it until reload. - */ - claimSingleton: (name, onChange) => { - // One entry per role, dispatched from the constructor's authenticated - // listener: re-claiming (a React effect remounting, StrictMode's double - // mount) replaces the handler instead of stacking another listener on the - // busiest message path in the app. - this.singletonHandlers.set(name, onChange); - this.vscode.postMessage({ type: 'singleton:claim', name }); - }, - request: async (op, params) => { - const results = await this.requestResponse( - 'peer:request', - 'peer:results', - { op, params }, - (msg) => msg.results as unknown[], - PEER_REQUEST_TIMEOUT_MS, - ); - // A timeout reads as "nobody answered", which is what a miss looks like - // anyway — the caller has no repair to make either way. - return results ?? []; - }, + // --- 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. + + readonly remoteHost: RemoteHostLink = { + command: (cmd, params) => this.remoteHostCommand(cmd, params), respond: (op, handler) => { - this.peerResponders.set(op, handler); + this.remoteHostResponders.set(op, handler); }, notify: (topic) => { this.vscode.postMessage({ type: 'peer:notify', topic }); }, - subscribe: (topic, listener) => { - let handlers = this.peerChangeHandlers.get(topic); - if (!handlers) { - handlers = new Set(); - this.peerChangeHandlers.set(topic, handlers); + on: (name, listener) => { + let listeners = this.remoteHostListeners.get(name); + if (!listeners) { + listeners = new Set(); + this.remoteHostListeners.set(name, listeners); } - handlers.add(listener); + listeners.add(listener); return () => { - handlers!.delete(listener); - if (handlers!.size === 0) this.peerChangeHandlers.delete(topic); - }; - }, - streamPty: (ptyId) => { - this.vscode.postMessage({ type: 'pty:subscribe', id: ptyId }); - let live = true; - return () => { - if (!live) return; - live = false; - this.vscode.postMessage({ type: 'pty:unsubscribe', id: ptyId }); + listeners.delete(listener); }; }, }; - private peerResponders = new Map unknown[]>(); - - /** - * Pull every `prefix`-scoped value out of extension-host storage and install - * a synchronous, write-through backend over it (docs/specs/vscode.md → - * "Remote Host: store and lease"). Webview `localStorage` is not the VS Code persistence story, and - * the remote Host's enrollment carries a bearer credential that belongs in - * `SecretStorage`, so the store has to live on the other side of the message - * boundary. A failed read installs an empty cache rather than throwing: the - * Host then behaves as un-enrolled instead of blocking webview boot. - */ - async hydrateScopedStore(prefix: string): Promise { - // The host answers only after reading `SecretStorage`, which on a cold OS - // keychain (or a locked libsecret) can take well over the default second. - // `requestResponse` resolves `null` on timeout rather than rejecting, so a - // too-short budget silently installs an empty cache and the Host reads as - // un-enrolled — indistinguishable from never having enrolled. - const entries = await this.requestResponse( - 'store:read', - 'store:entries', - { prefix }, - (msg) => msg.entries as Record, - HOST_STORE_READ_TIMEOUT_MS, - ); - if (entries === null) { - console.warn( - `[dormouse] host store "${prefix}" did not answer in ${HOST_STORE_READ_TIMEOUT_MS}ms; ` + - 'continuing without it. A remote Host enrollment will read as absent.', - ); - } - const refreshed = this.pendingStoreSnapshots.get(prefix); - this.pendingStoreSnapshots.delete(prefix); - const cache = new Map(Object.entries(refreshed ?? entries ?? {})); - // Anything committed while the read was in flight is newer than the - // snapshot, so it is applied on top of it before the cache goes live. - for (const [key, pending] of this.pendingStoreChanges) { - if (!key.startsWith(prefix)) continue; - if (pending === null) cache.delete(key); - else cache.set(key, pending); - this.pendingStoreChanges.delete(key); - } - this.scopedCaches.set(prefix, cache); - setJsonStoreBackend(prefix, { - getItem: (key) => cache.get(key) ?? null, - setItem: (key, value) => { - cache.set(key, value); - this.vscode.postMessage({ type: 'store:write', key, value }); - }, - removeItem: (key) => { - cache.delete(key); - this.vscode.postMessage({ type: 'store:write', key, value: null }); - }, + private nextRhId(): string { + return `rh-${this.rhTag}-${++this.nextRemoteHostId}`; + } + + private remoteHostCommand(cmd: string, params?: unknown): Promise { + const rhId = this.nextRhId(); + return new Promise((resolve, reject) => { + // Bounded: a broker window that closed mid-command must reject rather + // than leave the console hook (or the device dialog) waiting forever. + const timer = setTimeout(() => { + this.remoteHostPending.delete(rhId); + reject(new Error(`remote host command timed out: ${cmd}`)); + }, REMOTE_HOST_COMMAND_TIMEOUT_MS); + this.remoteHostPending.set(rhId, { resolve, reject, timer }); + this.vscode.postMessage({ type: 'remoteHost:command', payload: { rhId, cmd, params } satisfies RemoteHostCommand }); }); } + private settleRemoteHostCommand(result: RemoteHostResult | undefined): void { + const pending = result ? this.remoteHostPending.get(result.rhId) : undefined; + if (!pending || !result) return; + this.remoteHostPending.delete(result.rhId); + clearTimeout(pending.timer); + if (typeof result.error === 'string') pending.reject(new Error(result.error)); + else pending.resolve(result.result); + } + /** - * Apply another webview's committed write to this one's cache. Without it a - * webview serves reads from its boot-time snapshot forever, and — because the - * lease can hand it the Host later — would start from that snapshot and write - * it back, dropping every pairing the previous holder approved. + * 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 broker settles once every webview 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. */ - private applyStoreChange(key: string, value: string | null): void { - for (const [prefix, cache] of this.scopedCaches) { - if (!key.startsWith(prefix)) continue; - if (value === null) cache.delete(key); - else cache.set(key, value); - return; - } - // No cache holds this key yet: either its prefix is still hydrating (buffer - // it — `hydrateScopedStore` drains it) or nothing here claimed the prefix, - // in which case the entry is inert. - this.pendingStoreChanges.set(key, value); - } - - /** Replace a whole prefix before a newly elected window starts its Host. */ - private applyStoreSnapshot(prefix: string, entries: Record): void { - const cache = this.scopedCaches.get(prefix); - if (cache) { - cache.clear(); - for (const [key, value] of Object.entries(entries)) cache.set(key, value); - return; - } - - // The snapshot is newer than any earlier per-key broadcast. Later changes - // remain buffered and are applied on top when hydration completes. - for (const key of this.pendingStoreChanges.keys()) { - if (key.startsWith(prefix)) this.pendingStoreChanges.delete(key); + private answerRemoteHostAsk(requestId: string, op: string, params: unknown): void { + const handler = this.remoteHostResponders.get(op); + let results: unknown[] = []; + try { + results = handler ? handler(params) : []; + } catch (err) { + console.error(`[dormouse] remote host ask ${op} failed:`, err); } - this.pendingStoreSnapshots.set(prefix, entries); + this.vscode.postMessage({ type: 'peer:answer', requestId, results }); } shutdown(): void { diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index b780cad6..ec580069 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -17,13 +17,6 @@ /** How long the broker waits for a window to answer before giving up on it. */ export const PEER_REPLY_BUDGET_MS = 1_000; -/** - * The webview's budget for a round trip through the broker. Must exceed - * {@link PEER_REPLY_BUDGET_MS}, or a slow sibling shows up as a timeout on the - * asking side instead of as an incomplete answer. - */ -export const PEER_REQUEST_TIMEOUT_MS = 3_000; - /** * Broker → peer window. * diff --git a/lib/src/lib/vscode-window-lease.test.ts b/lib/src/lib/vscode-window-lease.test.ts deleted file mode 100644 index 15b5a010..00000000 --- a/lib/src/lib/vscode-window-lease.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - LEASE_TTL_MS, - decideWindowLease, - isWindowLeaseRecord, - runWindowLeaseCycle, - type WindowLeaseIo, - type WindowLeaseRecord, -} from './vscode-window-lease'; - -const SELF = 'window-a'; -const NOW = 1_700_000_000_000; - -describe('decideWindowLease', () => { - it('takes an unclaimed lease', () => { - expect(decideWindowLease(null, SELF, NOW)).toBe('take'); - }); - - it('holds its own claim', () => { - expect(decideWindowLease({ owner: SELF, heartbeatAt: NOW - 1_000 }, SELF, NOW)).toBe('hold'); - }); - - it('holds its own claim even when the heartbeat has gone stale', () => { - // Our own stale record means we were slow, not that we lost it — re-stamp - // rather than racing ourselves for a lease we already hold. - const stale = { owner: SELF, heartbeatAt: NOW - LEASE_TTL_MS * 10 }; - expect(decideWindowLease(stale, SELF, NOW)).toBe('hold'); - }); - - it('waits while another window is heartbeating', () => { - expect(decideWindowLease({ owner: 'window-b', heartbeatAt: NOW - 1_000 }, SELF, NOW)).toBe('wait'); - }); - - it('takes over once another window stops heartbeating', () => { - const abandoned = { owner: 'window-b', heartbeatAt: NOW - LEASE_TTL_MS - 1 }; - expect(decideWindowLease(abandoned, SELF, NOW)).toBe('take'); - }); - - it('takes over a heartbeat stamped far in the future', () => { - // Otherwise a clock jump locks every window out until the skew elapses. - const skewed = { owner: 'window-b', heartbeatAt: NOW + LEASE_TTL_MS + 1 }; - expect(decideWindowLease(skewed, SELF, NOW)).toBe('take'); - }); - - it('respects an explicit ttl', () => { - const record = { owner: 'window-b', heartbeatAt: NOW - 100 }; - expect(decideWindowLease(record, SELF, NOW, 50)).toBe('take'); - expect(decideWindowLease(record, SELF, NOW, 1_000)).toBe('wait'); - }); -}); - -describe('isWindowLeaseRecord', () => { - it('accepts a well-formed record', () => { - expect(isWindowLeaseRecord({ owner: 'w', heartbeatAt: NOW })).toBe(true); - }); - - it('rejects malformed or partial records', () => { - expect(isWindowLeaseRecord(null)).toBe(false); - expect(isWindowLeaseRecord({})).toBe(false); - expect(isWindowLeaseRecord({ owner: 'w' })).toBe(false); - expect(isWindowLeaseRecord({ owner: 5, heartbeatAt: NOW })).toBe(false); - expect(isWindowLeaseRecord({ owner: 'w', heartbeatAt: 'soon' })).toBe(false); - expect(isWindowLeaseRecord({ owner: 'w', heartbeatAt: NaN })).toBe(false); - }); -}); - - -/** A shared lease file every simulated window reads and writes. */ -function fakeFile(initial: WindowLeaseRecord | null = null) { - let record = initial; - let onSettle: (() => void) | null = null; - return { - get record() { - return record; - }, - set record(next: WindowLeaseRecord | null) { - record = next; - }, - /** Run `fn` while a claimant is between writing and confirming. */ - duringSettle(fn: () => void) { - onSettle = fn; - }, - io(selfId: string, now = () => NOW): WindowLeaseIo { - return { - read: async () => record, - write: async (next) => { - record = next; - }, - now, - settle: async () => { - onSettle?.(); - onSettle = null; - }, - }; - }, - }; -} - -describe('runWindowLeaseCycle', () => { - it('claims an unheld lease and confirms it', async () => { - const file = fakeFile(); - expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(true); - expect(file.record?.owner).toBe(SELF); - }); - - it('does not claim while another window is alive', async () => { - const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - 1_000 }); - expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(false); - // And it must not have stamped over the live holder. - expect(file.record?.owner).toBe('window-b'); - }); - - it('takes over an abandoned lease', async () => { - const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - LEASE_TTL_MS - 1 }); - expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(true); - expect(file.record?.owner).toBe(SELF); - }); - - it('loses a contested takeover to the window that wrote last', async () => { - const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - LEASE_TTL_MS - 1 }); - // Both windows judge the same record stale; the other one writes second. - file.duringSettle(() => { - file.record = { owner: 'window-c', heartbeatAt: NOW }; - }); - - expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(false); - expect(file.record?.owner).toBe('window-c'); - }); - - it('renews without paying for a confirmation round trip', async () => { - const file = fakeFile({ owner: SELF, heartbeatAt: NOW - 1_000 }); - let settled = false; - const io = file.io(SELF); - const held = await runWindowLeaseCycle( - { ...io, settle: async () => { settled = true; } }, - SELF, - ); - - expect(held).toBe(true); - expect(settled).toBe(false); - }); - - it('re-stamps the heartbeat on renewal', async () => { - const file = fakeFile({ owner: SELF, heartbeatAt: NOW - 4_000 }); - await runWindowLeaseCycle(file.io(SELF, () => NOW), SELF); - expect(file.record?.heartbeatAt).toBe(NOW); - }); - - it('propagates a write failure rather than claiming', async () => { - const file = fakeFile(); - const io: WindowLeaseIo = { - ...file.io(SELF), - write: async () => { - throw new Error('read-only filesystem'); - }, - }; - await expect(runWindowLeaseCycle(io, SELF)).rejects.toThrow('read-only'); - }); - - it('hands over cleanly when the holder releases', async () => { - const file = fakeFile({ owner: 'window-b', heartbeatAt: NOW - 1_000 }); - expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(false); - - // window-b disposed and unlinked the file. - file.record = null; - - expect(await runWindowLeaseCycle(file.io(SELF), SELF)).toBe(true); - }); - - it('keeps exactly one holder across many contending windows', async () => { - const file = fakeFile(); - const ids = ['w1', 'w2', 'w3', 'w4']; - - // First pass: one wins the empty file. - const first = []; - for (const id of ids) first.push(await runWindowLeaseCycle(file.io(id), id)); - expect(first.filter(Boolean)).toHaveLength(1); - - // Steady state: the winner renews, everyone else keeps standing down. - const owner = file.record!.owner; - const second = []; - for (const id of ids) second.push(await runWindowLeaseCycle(file.io(id), id)); - expect(second.filter(Boolean)).toHaveLength(1); - expect(file.record?.owner).toBe(owner); - }); -}); diff --git a/lib/src/lib/vscode-window-lease.ts b/lib/src/lib/vscode-window-lease.ts deleted file mode 100644 index 22a1f469..00000000 --- a/lib/src/lib/vscode-window-lease.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * The decision half of the cross-window Host lease (docs/specs/vscode.md → - * "Remote Host: store and lease"). - * - * VS Code runs one extension host per window, so the in-window webview lease - * cannot see another window. Without a second tier every window elects its own - * Host, they all connect `/ws/host` with the same enrollment, and the server - * displaces whoever connected first (`server/src/relay.ts`) — whose `close` - * handler reconnects and displaces the next one, forever, each window arming - * its own alarm push. - * - * Arbitration therefore has to happen on state every window can see. The I/O - * half lives in `vscode-ext/src/window-lease.ts`, which keeps a heartbeat - * record in the extension's `globalStorageUri`; this module holds the rules, - * which is where the interesting cases are (staleness, self-ownership, a clock - * that jumped). It is pure so those cases are testable without a filesystem. - */ - -/** One window's claim on being the Host, as persisted in the lease file. */ -export interface WindowLeaseRecord { - /** Random per-extension-host id — identifies the window, not the machine. */ - owner: string; - /** When the owner last proved it was alive, as epoch ms. */ - heartbeatAt: number; -} - -/** - * How long a record outlives its last heartbeat. A window that is killed - * without running its disposables (a crash, a force-quit) leaves the file - * behind, so the only thing that frees it is age. - */ -export const LEASE_TTL_MS = 15_000; - -/** How often the holder re-stamps its heartbeat, and others re-check. */ -export const LEASE_RENEW_MS = 5_000; - -export function isWindowLeaseRecord(value: unknown): value is WindowLeaseRecord { - if (!value || typeof value !== 'object') return false; - const record = value as WindowLeaseRecord; - return typeof record.owner === 'string' && Number.isFinite(record.heartbeatAt); -} - -/** - * `take` — write our own record; `hold` — ours already, re-stamp it; - * `wait` — someone else holds a live claim. - */ -export type WindowLeaseAction = 'take' | 'hold' | 'wait'; - -export function decideWindowLease( - record: WindowLeaseRecord | null, - selfId: string, - now: number, - ttlMs = LEASE_TTL_MS, -): WindowLeaseAction { - if (!record) return 'take'; - if (record.owner === selfId) return 'hold'; - // A heartbeat far in the future is as unusable as one far in the past: the - // clock moved under us, and treating it as live would deadlock every window - // out of the role until the skew elapsed. - if (Math.abs(now - record.heartbeatAt) > ttlMs) return 'take'; - return 'wait'; -} - -/** - * The filesystem the lease cycle needs, so the protocol can be exercised - * without one. `settle` is the pause between claiming and believing the claim. - */ -export interface WindowLeaseIo { - read(): Promise; - write(record: WindowLeaseRecord): Promise; - now(): number; - settle(): Promise; -} - -/** - * Run one arbitration cycle and report whether this window holds the role - * afterwards. - * - * A fresh claim is confirmed by re-reading, because two windows can judge the - * same record stale in the same instant and both write — the file keeps one of - * them, and the loser must not believe it won. Renewing an existing claim skips - * that round trip: the record already named us, so a takeover would have to - * have happened inside this cycle, and the next one catches it. - * - * Write failures propagate; a lease you cannot write is one you cannot hold. - */ -export async function runWindowLeaseCycle( - io: WindowLeaseIo, - selfId: string, - ttlMs = LEASE_TTL_MS, -): Promise { - const action = decideWindowLease(await io.read(), selfId, io.now(), ttlMs); - if (action === 'wait') return false; - - await io.write({ owner: selfId, heartbeatAt: io.now() }); - if (action === 'hold') return true; - - await io.settle(); - const confirmed = await io.read(); - return confirmed?.owner === selfId; -} diff --git a/lib/src/main.tsx b/lib/src/main.tsx index 09e36f7b..fba10b64 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -4,7 +4,6 @@ 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 { REMOTE_HOST_STORE_PREFIX, setHostStoreReady } from "./remote/host/store"; import { installPeerSurfaceResponder } from "./remote/host/peer-surfaces"; import App from "./App"; import "./index.css"; @@ -12,15 +11,14 @@ import "./index.css"; const platform = initPlatform(); // This entry serves the VS Code webview and the lib dev server. Only the -// former can be a remote Host: the dev server has no PTYs behind it, and the -// extension host is what arbitrates the single-Host lease across webviews. +// 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, whether or not it is the one - // holding the Host — that is what lets the phone see a whole window rather - // than one webview's panes. + // Every webview answers for its own terminals — that is what lets the phone + // see a whole window rather than one webview's panes. installPeerSurfaceResponder(); } @@ -29,15 +27,6 @@ initAlertStateReceiver(); // Request PTY list before rendering so Wall can restore existing sessions. // On non-VSCode platforms (or first launch), this resolves immediately with no IDs. -// -// Host-store hydration starts now but deliberately does not gate first paint: -// its read waits on an OS keychain, and a blank terminal for that long reads as -// a hang. `local-json-store` is synchronous by contract, so the keys must be in -// memory before the remote-Host modules read them — but that happens when the -// lazily-mounted Host calls `installRemoteHostConsoleHook`, well after render, -// so it awaits `hostStoreReady()` instead. -setHostStoreReady(platform.hydrateScopedStore?.(REMOTE_HOST_STORE_PREFIX)); - resumeOrRestore(platform).then((result) => { createRoot(document.getElementById("root")!).render( diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx index 0f0ee1f7..d5b408da 100644 --- a/lib/src/remote/host/RemotePairingModalHost.tsx +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -5,13 +5,12 @@ import { subscribePairingApproval, } from './pairing-approval'; import { installRemoteHostConsoleHook } from './activation'; -import { hostStoreReady } from './store'; /** - * 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, @@ -21,19 +20,8 @@ export function RemotePairingModalHost({ const pending = useSyncExternalStore(subscribePairingApproval, getPairingApprovalSnapshot); const head = pending[0] ?? null; - useEffect(() => { - // The Host's enrollment and ACL may live outside the webview (VS Code), in - // which case boot started an async read that must land before anything - // reads those keys. First paint deliberately does not wait on it, so this - // is where the ordering is enforced. - let cancelled = false; - void hostStoreReady().then(() => { - if (!cancelled) installRemoteHostConsoleHook(); - }); - return () => { - cancelled = true; - }; - }, []); + // Idempotent, because StrictMode mounts this twice. + useEffect(() => installRemoteHostConsoleHook(), []); useEffect(() => { onKeyboardActiveChange?.(head !== null); diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index 640deb71..f2a2580f 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -1,18 +1,15 @@ /** - * Legacy mode: the single-Host lease. VS Code can show several Dormouse webviews - * over one extension host; without this gate each would start its own - * `RemoteHost` against the same enrollment, fight over the one `/ws/host` - * socket, and arm its own alarm push. - * - * Bridge mode (bottom): the Host runs in another process, so this module starts - * none of that and is a client of the service instead. + * 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 started: Array<{ stopped: boolean }> = []; const enrollmentState = vi.hoisted(() => ({ current: { serverUrl: 'https://relay.example.ts.net', @@ -29,28 +26,11 @@ const enrollmentState = vi.hoisted(() => ({ } | null, })); -vi.mock('./remote-host', () => ({ - RemoteHost: class { - activeRecords: never[] = []; - status = 'connecting'; - #self = { stopped: false }; - constructor() { - started.push(this.#self); - } - start() {} - stop() { - this.#self.stopped = true; - } - }, -})); -vi.mock('./remote-api', () => ({ RemoteApiSession: class {} })); const pushWatch = vi.hoisted(() => ({ fire: undefined as ((sessionId: string, title: string) => void) | undefined, loads: [] as Array<() => Promise>, })); vi.mock('./alert-push', () => ({ - startAlertPush: () => () => {}, - refreshPushDevices: async () => {}, watchPushRings: (fire: (sessionId: string, title: string) => void) => { pushWatch.fire = fire; return () => {}; @@ -62,7 +42,6 @@ vi.mock('./alert-push', () => ({ })); const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void> })); vi.mock('../../lib/push-devices', () => ({ - resetPushDevices: () => {}, setPushDevicesRefresher: (refresh: () => void) => void pushRefreshers.current.push(refresh), })); const aclState = vi.hoisted(() => ({ @@ -78,38 +57,16 @@ vi.mock('./enrollment', () => ({ clearEnrollment: () => { enrollmentState.current = null; }, - enrollHost: async (serverUrl: string) => { - enrollmentState.current = { - serverUrl, - hostId: 'host-1', - hostToken: 'token', - origin: serverUrl, - rpId: new URL(serverUrl).hostname, - }; - return enrollmentState.current; - }, })); -let claimSingleton: ((name: string, onChange: (held: boolean) => void) => void) | undefined; let remoteHostLink: RemoteHostLink | undefined; -// A host with peers is exactly a host that arbitrates the role, so the lease -// arrives through the same optional member (`PeerBridge`); no peers means -// single-instance. A host with `remoteHost` runs the Host elsewhere entirely. +// A host with `remoteHost` has a Host service behind it; without one (the +// website) there is no Host anywhere. vi.mock('../../lib/platform', () => ({ - getPlatform: () => ({ - peers: claimSingleton ? { claimSingleton } : undefined, - remoteHost: remoteHostLink, - }), + getPlatform: () => ({ remoteHost: remoteHostLink }), })); -async function freshModule() { - vi.resetModules(); - return import('./activation'); -} - beforeEach(() => { - started.length = 0; - claimSingleton = undefined; remoteHostLink = undefined; pushWatch.fire = undefined; pushWatch.loads.length = 0; @@ -132,108 +89,6 @@ afterEach(() => { vi.unstubAllGlobals(); }); -async function installWithLease() { - let grant!: (held: boolean) => void; - claimSingleton = (_name, onChange) => { - grant = onChange; - }; - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - return { mod, grant: (held: boolean) => grant(held) }; -} - -describe('remote host activation lease', () => { - it('activates immediately on a host with no lease (standalone)', async () => { - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - expect(started).toHaveLength(1); - }); - - it('waits for the lease on a host that arbitrates', async () => { - const { grant } = await installWithLease(); - - // Mount alone must not start a Host — the answer has not arrived yet. - expect(started).toHaveLength(0); - - grant(true); - expect(started).toHaveLength(1); - expect(started[0].stopped).toBe(false); - }); - - it('stops when the lease is revoked and restarts when re-granted', async () => { - const { grant } = await installWithLease(); - grant(true); - expect(started).toHaveLength(1); - - grant(false); - expect(started[0].stopped).toBe(true); - - grant(true); - expect(started).toHaveLength(2); - }); - - it('a repeated grant does not start a second Host', async () => { - const { grant } = await installWithLease(); - grant(true); - grant(true); - expect(started).toHaveLength(1); - }); - - it('claims under the shared role name', async () => { - const names: string[] = []; - claimSingleton = (name) => void names.push(name); - - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - - expect(names).toEqual(['remote-host']); - }); - - it('does not claim the lease before an enrollment exists', async () => { - enrollmentState.current = null; - const names: string[] = []; - claimSingleton = (name) => void names.push(name); - - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - - expect(names).toEqual([]); - expect(started).toHaveLength(0); - }); - - it('claims after a successful first enrollment', async () => { - enrollmentState.current = null; - const names: string[] = []; - let grant!: (held: boolean) => void; - claimSingleton = (name, onChange) => { - names.push(name); - grant = onChange; - }; - const mod = await freshModule(); - mod.installRemoteHostConsoleHook(); - const hook = (globalThis as { - dormouseRemoteHost?: { enroll: (a: string, b: string, c: string) => Promise }; - }).dormouseRemoteHost!; - - await hook.enroll('https://relay.example.ts.net', 'password', 'Laptop'); - expect(names).toEqual(['remote-host']); - expect(started).toHaveLength(0); - - grant(true); - expect(started).toHaveLength(1); - }); - - it('enrolling from a non-holder does not start a competing Host', async () => { - await installWithLease(); - const hook = (globalThis as { dormouseRemoteHost?: { enroll: (a: string, b: string, c: string) => Promise } }) - .dormouseRemoteHost!; - - await hook.enroll('https://relay.example.ts.net', 'password', 'Laptop'); - - expect(started).toHaveLength(0); - }); -}); - // --- Bridge mode --- const PAIRING_REQUEST = { @@ -299,9 +154,14 @@ function consoleHook() { } describe('remote host bridge mode', () => { - it('starts no Host of its own', async () => { - await installBridge(fakeLink()); - expect(started).toHaveLength(0); + 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 () => { diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 042663c0..bb762e91 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -1,20 +1,14 @@ /** - * Activation glue: brings up the remote Host from the persisted enrollment on - * app start, and exposes a `window.dormouseRemoteHost` console hook for + * 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). * - * There are two worlds here, chosen by whether the platform adapter has a - * {@link RemoteHostLink}: - * - * - **Bridge mode** (standalone): the Host is a service in the process that - * owns the PTYs (`lib/src/host/remote/service.ts`). This module is then a - * client of it — it forwards console commands, mirrors the pairing queue, - * and reports rings — and starts no Host of its own. - * - **Legacy mode** (VS Code, the website): the Host runs in this webview, - * and this is the one module that binds the DOM-free controller and - * remote-api session to the terminal bridge — the xterm registry, the - * platform adapter, and `document` all enter through the surface provider - * built below. + * 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: * @@ -32,245 +26,24 @@ import type { } from '../../host/remote/service-protocol'; import { getPlatform } from '../../lib/platform'; import type { RemoteHostLink } from '../../lib/platform/types'; -import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; -import { subscribeToActivity } from '../../lib/session-activity-store'; -import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; +import { setPushDevicesRefresher } from '../../lib/push-devices'; import { clearAclRecords, loadAclRecords } from './acl'; -import { - commitPushDevices, - refreshPushDevices, - startAlertPush, - watchPushRings, - type AlertPushDeps, -} from './alert-push'; -import { collectDirectorySnapshot } from './directory-collect'; -import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; -import type { HostSurfaceProvider } from './host-surface-provider'; +import { commitPushDevices, watchPushRings } from './alert-push'; +import { clearEnrollment, getEnrollment } from './enrollment'; import { enqueuePairingApproval, getPairingApprovalSnapshot, resolvePairingApproval, } from './pairing-approval'; -import { peerDirectory } from './peer-surfaces'; -import { RemoteApiSession } from './remote-api'; -import { RemoteHost } from './remote-host'; -import { resolveSurface } from './surface-resolve'; export type { RemoteHostConsoleStatus }; -let current: RemoteHost | null = null; -let stopPush: (() => void) | null = null; -let leaseClaimRequested = false; - -/** - * Whether this app instance is the one allowed to be the Host. - * - * Standalone is a single webview per app, so it owns the role outright and this - * stays `true`. VS Code can show several Dormouse webviews at once (a - * `WebviewView` plus any number of `WebviewPanel`s), and each would otherwise - * start its own `RemoteHost` against the same enrollment — they would fight - * over the single `/ws/host` socket (the server displaces the previous holder, - * see `server/test/relay-displaced.test.mjs`) and each would arm its own alarm - * push. So a host that can have more than one webview hands out a lease - * instead, and only the holder activates. - */ -let owned = true; - -/** - * The webview-resident answer to "where do the surfaces live": this webview's - * xterm registry, plus whatever its peers own - * (`host-surface-provider.ts`, docs/specs/vscode.md → "Peer surfaces"). - * - * Assembled here rather than in a module of its own because it is exactly the - * part that a Node-resident Host replaces: the seam is the durable thing, this - * binding of it is not. - */ -export function createWebviewSurfaceProvider(): HostSurfaceProvider { - return { - async collectDirectory() { - // A window's terminals may be spread across several webviews with only - // this one as the Host, so the rest have to be asked; a host with no - // peers (standalone, the website) answers with nothing. The local panes - // are read after the round trip, not before, so they are as current as - // the answers they are merged with. - const remote = await peerDirectory(); - return [...collectDirectorySnapshot(), ...remote]; - }, - - watchDirectory(onChange) { - const unsubPane = subscribeToTerminalPaneState(onChange); - const unsubActivity = subscribeToActivity(onChange); - const unsubPeers = getPlatform().peers?.subscribe('directory', onChange); - const hasDocument = typeof document !== 'undefined'; - if (hasDocument) { - document.addEventListener('focusin', onChange); - document.addEventListener('focusout', onChange); - } - return () => { - unsubPane(); - unsubActivity(); - unsubPeers?.(); - if (hasDocument) { - document.removeEventListener('focusin', onChange); - document.removeEventListener('focusout', onChange); - } - }; - }, - - resolveSurface, - - writePty: (ptyId, data) => getPlatform().writePty(ptyId, data), - resizePty: (ptyId, cols, rows) => getPlatform().resizePty(ptyId, cols, rows), - - streamPty(ptyId, sink) { - // The adapter delivers every PTY this webview owns or subscribed to on - // one stream, so the id filter is the subscription. Pin the adapter the - // pair was registered on: removing a handler from a different one would - // leave this attachment streaming forever. - const platform = getPlatform(); - const onData = (detail: { id: string; data: string }): void => { - if (detail.id === ptyId) sink.onData(detail.data); - }; - const onExit = (detail: { id: string; exitCode: number }): void => { - if (detail.id === ptyId) sink.onExit(detail.exitCode); - }; - platform.onPtyData(onData); - platform.onPtyExit(onExit); - return () => { - platform.offPtyData(onData); - platform.offPtyExit(onExit); - }; - }, - }; -} - -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, - provider: createWebviewSurfaceProvider(), - }), - }); - 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, - }; - 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); - - return host; -} - -/** - * Grant or revoke this instance's claim to being the Host, starting or stopping - * it to match. Called by the platform's singleton lease; hosts without one stay - * granted from the start. - */ -export function setRemoteHostOwnership(next: boolean): void { - if (owned === next) return; - owned = next; - if (owned) activateRemoteHost(); - else stopRemoteHost(); -} - -/** - * Start the Host if an enrollment exists, this instance holds the lease, and - * none is running. Idempotent. - */ -export function activateRemoteHost(): void { - if (current || !owned) return; - const enrollment = getEnrollment(); - if (!enrollment) return; - current = startFromEnrollment(enrollment); -} - -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(); -} - -function remoteHostStatus(): RemoteHostConsoleStatus { - const enrollment = getEnrollment(); - return { - enrolled: !!enrollment, - serverUrl: enrollment?.serverUrl ?? null, - hostId: enrollment?.hostId ?? null, - connection: current?.status ?? 'stopped', - pairedClients: current?.activeRecords.length ?? 0, - }; -} - -/** Install the `window.dormouseRemoteHost` console hook and activate. Idempotent. */ +/** Install the `window.dormouseRemoteHost` console hook and connect. Idempotent. */ export function installRemoteHostConsoleHook(): void { const link = getPlatform().remoteHost; - if (link) { - installBridgeMode(link); - return; - } - - // A host that can show several webviews arbitrates which one is the Host — - // having peers at all is exactly the condition that needs arbitrating, which - // is why one member answers both. Start un-owned so two webviews racing to - // mount cannot both activate before the first lease answer arrives, and let - // the grant do the activating. - const peers = getPlatform().peers; - if (peers) { - owned = false; - if (getEnrollment()) { - leaseClaimRequested = true; - peers.claimSingleton('remote-host', setRemoteHostOwnership); - } - } else { - 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(); - if (peers && !leaseClaimRequested) { - leaseClaimRequested = true; - peers.claimSingleton('remote-host', setRemoteHostOwnership); - } - // A synchronous grant may already have activated from persisted storage. - if (owned && !current) 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(); - }, - }; + // 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 --- diff --git a/lib/src/remote/host/alert-push.test.ts b/lib/src/remote/host/alert-push.test.ts index 7b3a08dc..33cbfbaa 100644 --- a/lib/src/remote/host/alert-push.test.ts +++ b/lib/src/remote/host/alert-push.test.ts @@ -5,10 +5,10 @@ vi.mock('../../lib/platform', () => ({ })); import type { HostAclRecord } from 'server-lib-common'; -import { refreshPushDevices, startAlertPush } from './alert-push'; -// Delivery — the Server calls, the recipient rule, the title bounds — is shared -// with the Node-resident Host, so it lives beside neither webview nor sidecar. -import { toPushText } from './push-delivery'; +import { commitPushDevices, watchPushRings, type AlertPushDeps } 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 } 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'; @@ -60,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' }); @@ -144,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); @@ -160,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); @@ -173,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); @@ -186,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); @@ -197,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); @@ -209,7 +229,7 @@ describe('alarm push', () => { // 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 () => ({ @@ -228,7 +248,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, @@ -242,7 +262,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); @@ -250,7 +270,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 = []; @@ -268,7 +288,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(); diff --git a/lib/src/remote/host/alert-push.ts b/lib/src/remote/host/alert-push.ts index 7a113fb6..305336b3 100644 --- a/lib/src/remote/host/alert-push.ts +++ b/lib/src/remote/host/alert-push.ts @@ -9,10 +9,10 @@ * list. The Server calls themselves are in `push-delivery.ts`, which a * Node-resident Host runs without any of this. * - * 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. + * 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 { getAlertSettings } from '../../lib/alert-settings'; @@ -24,26 +24,19 @@ import { type PushDevice, type PushDevicesState, } from '../../lib/push-devices'; -import { loadPushDevices, sendPush, type AlertPushDeps } from './push-delivery'; +import type { AlertPushDeps } from './push-delivery'; export type { AlertPushDeps }; 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. - */ -export async function refreshPushDevices(deps: AlertPushDeps): Promise { - await commitPushDevices(() => loadPushDevices(deps)); -} - /** * Run `load` and publish its result to the dialog's store with the fences below. - * Shared with the bridge-mode Host, which loads the same list over the service - * bridge instead of fetching it itself — and answers `null` when no Host is - * running, which is "nowhere to push", not an empty list. + * `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 commitPushDevices( load: () => Promise, @@ -87,14 +80,3 @@ export function watchPushRings(fire: (sessionId: string, title: string) => void) fire: (id) => fire(id, deriveSessionLabel(id)), }); } - -/** {@link watchPushRings}, delivered by this process's own Host. */ -export function startAlertPush(deps: AlertPushDeps): () => void { - return watchPushRings((id, title) => { - // 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, title).catch((error: unknown) => { - console.warn('remote-host: push notification failed', error); - }); - }); -} diff --git a/lib/src/remote/host/host-surface-provider.ts b/lib/src/remote/host/host-surface-provider.ts index 63eafe18..ee973b89 100644 --- a/lib/src/remote/host/host-surface-provider.ts +++ b/lib/src/remote/host/host-surface-provider.ts @@ -9,10 +9,10 @@ * interface and the session never imports the platform adapter, the stores, or * `document`. * - * Today the only implementation is the webview-backed one assembled in - * `activation.ts` (registry + peer bridge). The Node-resident host service - * answers the same interface from the process that owns the PTYs, with the - * webviews demoted to surface responders. + * 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. @@ -20,6 +20,10 @@ 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 { readonly ptyId: string; /** The size the surface stands at now — live for a local pane, last-reported for a peer's. */ diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 878db78a..786856ef 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -1,382 +1,145 @@ /** - * Attaching to a terminal owned by a *sibling* webview. Only one webview in a - * VS Code window is the remote Host, but the window's terminals are spread - * across all of them, so the Host has to reach the others through the peer - * bridge (docs/specs/vscode.md → "Peer surfaces"). + * 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"). * - * This is the webview-backed {@link createWebviewSurfaceProvider} under test as - * much as the session: the session itself knows nothing about registries or - * peers (`host-surface-provider.ts`), so the local-vs-sibling distinction only - * exists here. + * 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, describe, expect, it, vi } from 'vitest'; -import { - REMOTE_EVENTS, - REMOTE_METHODS, - fromBase64Url, - toBase64Url, - utf8Decode, - utf8Encode, - type RemoteEventMsg, - type RemoteResponse, -} from 'server-lib-common'; +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 { createWebviewSurfaceProvider } from './activation'; -import { RemoteApiSession } from './remote-api'; +import { installPeerSurfaceResponder } from './peer-surfaces'; -type SentPayload = RemoteResponse | RemoteEventMsg; -type DataHandler = (detail: { id: string; data: string }) => void; -type ExitHandler = (detail: { id: string; exitCode: number }) => void; - -/** A platform whose peer bridge stands in for the other webviews. */ -class PeerPlatform { - readonly dataHandlers = new Set(); - readonly exitHandlers = new Set(); - readonly resizePty = vi.fn(); - readonly writePty = vi.fn(); - readonly subscribed: string[] = []; - readonly unsubscribed: string[] = []; - readonly ops: Array<{ surfaceId: string; op: string; cols?: number; rows?: number }> = []; - readonly peerChangeHandlers = new Map void>>(); +interface Responder { + (params: unknown): unknown[]; +} - /** Surfaces the imaginary sibling webview owns. */ - peerSurfaces = new Map(); - peerEntries: unknown[] = []; - surfaceRequestGate: Promise | null = null; +/** A platform whose `remoteHost` link stands in for the Host service. */ +class ServicePlatform { + readonly responders = new Map(); + readonly notified: string[] = []; - /** - * One generic seam: `op` is opaque to the adapter, and a peer answers with - * zero or more results — none of them meaning nobody owns it. - */ - readonly peers = { - claimSingleton: () => {}, - request: async (op: string, params: unknown) => { - if (op === 'directory') return this.peerEntries; - await this.surfaceRequestGate; - const { surfaceId, op: surfaceOp, cols, rows } = - params as { surfaceId: string; op: string; cols?: number; rows?: number }; - this.ops.push({ surfaceId, op: surfaceOp, cols, rows }); - const surface = this.peerSurfaces.get(surfaceId); - if (!surface) return []; - if (surfaceOp !== 'detach' && cols && rows) { - surface.cols = cols; - surface.rows = rows; - } - return [{ ptyId: surface.ptyId, cols: surface.cols, rows: surface.rows }]; - }, - respond: () => {}, - notify: () => {}, - subscribe: (topic: string, listener: () => void) => { - let handlers = this.peerChangeHandlers.get(topic); - if (!handlers) { - handlers = new Set(); - this.peerChangeHandlers.set(topic, handlers); - } - handlers.add(listener); - return () => void handlers!.delete(listener); - }, - streamPty: (id: string) => { - this.subscribed.push(id); - return () => void this.unsubscribed.push(id); + readonly remoteHost = { + command: async () => undefined, + respond: (op: string, handler: Responder) => { + this.responders.set(op, handler); }, + notify: (topic: string) => void this.notified.push(topic), + on: () => () => {}, }; - onPtyData(handler: DataHandler): void { - this.dataHandlers.add(handler); - } - offPtyData(handler: DataHandler): void { - this.dataHandlers.delete(handler); - } - onPtyExit(handler: ExitHandler): void { - this.exitHandlers.add(handler); - } - offPtyExit(handler: ExitHandler): void { - this.exitHandlers.delete(handler); - } - emitData(id: string, data: string): void { - for (const handler of this.dataHandlers) handler({ id, data }); - } - emitPeerChange(topic: string): void { - for (const handler of this.peerChangeHandlers.get(topic) ?? []) handler(); + 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; } } -function decodeTerminalData(payload: SentPayload): string { - const event = payload as RemoteEventMsg; - return utf8Decode(fromBase64Url((event.data as { bytes: string }).bytes)); -} - -/** Let the peer round trips (they are promises) settle. */ -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -/** A pane in *this* webview's registry, which resolves without asking anyone. */ -function registerLocalSurface(surfaceId: string, ptyId: string) { - const terminal = { cols: 80, rows: 24, resize: vi.fn() }; +/** 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; } -/** Hold every peer surface round trip open until the returned function is called. */ -function gatePeers(platform: PeerPlatform): () => void { - let release!: () => void; - platform.surfaceRequestGate = new Promise((resolve) => { - release = resolve; - }); - return release; -} - -describe('remote-api peer surfaces', () => { - afterEach(() => { - registry.clear(); - setPlatform(new FakePtyAdapter()); - }); - - function session(platform: PeerPlatform) { - const sent: SentPayload[] = []; - // The provider reads `getPlatform()` lazily, so it has to be built after - // this platform is installed for its peer bridge to be the one under test. - setPlatform(platform.asAdapter()); - return { - sent, - api: new RemoteApiSession({ - hostId: 'host-1', - send: (payload) => sent.push(payload), - provider: createWebviewSurfaceProvider(), - }), - }; - } - - it('attaches to a surface owned by another webview', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - const { api, sent } = session(platform); - - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 100, rows: 30 }, - }); - await settle(); - - // The owner did the resize — attach-is-the-resize has to go through the - // live xterm, which this webview cannot touch. - expect(platform.ops).toEqual([{ surfaceId: 'surface-far', op: 'attach', cols: 100, rows: 30 }]); - const ok = sent.find((p) => (p as RemoteResponse).requestId === 'attach-1') as RemoteResponse; - expect(ok.result).toEqual({ cols: 100, rows: 30 }); - }); - - it('subscribes to the foreign PTY and streams its bytes', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - const { api, sent } = session(platform); - - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, - }); - await settle(); - - // The host only forwards pty:data for PTYs a webview owns or subscribed to. - expect(platform.subscribed).toEqual(['pty-far']); - - platform.emitData('pty-far', 'hello from the other webview'); - const data = sent.filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalData); - expect(data.map(decodeTerminalData)).toContain('hello from the other webview'); - }); - - it('ignores bytes from PTYs it is not attached to', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - const { api, sent } = session(platform); - - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, - }); - await settle(); - platform.emitData('pty-other', 'not mine'); - - const data = sent.filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.terminalData); - expect(data.map(decodeTerminalData)).not.toContain('not mine'); - }); - - it('routes a later resize back to the owning webview', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - const { api, sent } = session(platform); - - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, - }); - await settle(); - api.handle({ - requestId: 'resize-1', - method: REMOTE_METHODS.terminalResize, - params: { surfaceId: 'surface-far', cols: 120, rows: 40 }, - }); - await settle(); - - expect(platform.ops.at(-1)).toEqual({ surfaceId: 'surface-far', op: 'resize', cols: 120, rows: 40 }); - const ok = sent.find((p) => (p as RemoteResponse).requestId === 'resize-1') as RemoteResponse; - expect(ok.result).toEqual({ cols: 120, rows: 40 }); - }); +let platform: ServicePlatform; - it('stops the foreign stream when the attachment is replaced', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - platform.peerSurfaces.set('surface-far2', { ptyId: 'pty-far2', cols: 80, rows: 24 }); - const { api } = session(platform); +beforeEach(() => { + platform = new ServicePlatform(); + setPlatform(platform.asAdapter()); + installPeerSurfaceResponder(); +}); - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, - }); - await settle(); - api.handle({ - requestId: 'attach-2', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far2', cols: 80, rows: 24 }, - }); - await settle(); +afterEach(() => { + registry.clear(); + clearPrimedActivity(); + setPlatform(new FakePtyAdapter()); +}); - // Otherwise the host keeps forwarding a PTY nobody is reading. - expect(platform.unsubscribed).toEqual(['pty-far']); +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('releases a peer handle that resolves after session disposal', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - const finishResolve = gatePeers(platform); - const { api, sent } = session(platform); + it('resizes the live xterm on attach and reports what it settled at', () => { + const terminal = registerSurface('surface-1', 'pty-1'); - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + const results = platform.answer('surfaceOp', { + surfaceId: 'surface-1', op: 'attach', cols: 100, rows: 30, }); - api.dispose(); - finishResolve(); - await settle(); - expect(platform.subscribed).toEqual(['pty-far']); - expect(platform.unsubscribed).toEqual(['pty-far']); - expect(sent.some((p) => (p as RemoteResponse).requestId === 'attach-1')).toBe(false); + // 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('does not let a gated peer attach outrank the newer attach that replaced it', async () => { - const platform = new PeerPlatform(); - platform.peerSurfaces.set('surface-far', { ptyId: 'pty-far', cols: 80, rows: 24 }); - registerLocalSurface('surface-near', 'pty-near'); - const finishResolve = gatePeers(platform); - const { api, sent } = session(platform); + 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 }); - // The client attaches a sibling's pane and switches to a local one before - // the sibling answers, so the two resolves land out of order. - api.handle({ - requestId: 'attach-far', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-far', cols: 80, rows: 24 }, + const results = platform.answer('surfaceOp', { + surfaceId: 'surface-1', op: 'resize', cols: 120, rows: 40, }); - api.handle({ - requestId: 'attach-near', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-near', cols: 100, rows: 30 }, - }); - await settle(); - finishResolve(); - await settle(); - - // Last attach wins: the superseded one unwinds the stream it opened on the - // way instead of tearing down the newer attachment. - expect(platform.unsubscribed).toEqual(['pty-far']); - const near = sent.find((p) => (p as RemoteResponse).requestId === 'attach-near') as RemoteResponse; - expect(near.ok).toBe(true); - const far = sent.find((p) => (p as RemoteResponse).requestId === 'attach-far') as RemoteResponse; - expect(far.ok).toBe(false); - expect(far.error).toMatch(/superseded/); - // Input still reaches the surface the client actually attached. - api.handle({ - requestId: 'write-1', - method: REMOTE_METHODS.terminalWrite, - params: { surfaceId: 'surface-near', bytes: toBase64Url(utf8Encode('ls')) }, - }); - expect(platform.writePty).toHaveBeenCalledWith('pty-near', 'ls'); + expect(terminal.resize).toHaveBeenLastCalledWith(120, 40); + expect(results).toEqual([{ ptyId: 'pty-1', cols: 120, rows: 40 }]); }); - it('fails cleanly when no webview owns the surface', async () => { - const platform = new PeerPlatform(); - const { api, sent } = session(platform); + 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); - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'nobody', cols: 80, rows: 24 }, - }); - await settle(); + expect(platform.answer('surfaceOp', { surfaceId: 'surface-1', op: 'attach' })).toEqual([ + { ptyId: 'pty-1', cols: 80, rows: 24 }, + ]); + expect(terminal.resize).not.toHaveBeenCalled(); - const reply = sent.find((p) => (p as RemoteResponse).requestId === 'attach-1') as RemoteResponse; - expect(reply.error).toMatch(/no such surface/); + 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('prefers a local surface without asking any peer', async () => { - const platform = new PeerPlatform(); - registerLocalSurface('surface-near', 'pty-near'); - const { api } = session(platform); - - api.handle({ - requestId: 'attach-1', - method: REMOTE_METHODS.surfaceAttach, - params: { surfaceId: 'surface-near', cols: 80, rows: 24 }, - }); - await settle(); + it('leaves the pane alone on detach', () => { + // Last-attach-wins: the Host stops streaming on its side and the pane keeps + // whatever size it was left at. + const terminal = registerSurface('surface-1', 'pty-1', 90, 25); - expect(platform.ops).toEqual([]); - expect(platform.subscribed).toEqual([]); + expect(platform.answer('surfaceOp', { surfaceId: 'surface-1', op: 'detach' })).toEqual([ + { ptyId: 'pty-1', cols: 90, rows: 25 }, + ]); + expect(terminal.resize).not.toHaveBeenCalled(); }); - it('emits one snapshot merging this webview with its peers', async () => { - const platform = new PeerPlatform(); - platform.peerEntries = [{ surfaceId: 'surface-far', title: 'other webview' }]; - registerLocalSurface('surface-near', 'pty-near'); - const { api, sent } = session(platform); - - api.handle({ requestId: 'dir-1', method: REMOTE_METHODS.directoryWatch, params: {} }); - await settle(); - - const snapshots = sent - .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.directorySnapshot) - .map((p) => ((p as RemoteEventMsg).data as { entries: Array<{ surfaceId: string }> }).entries); - // The peer round trip is the provider's business now, so the phone gets one - // snapshot per collect instead of local-then-merged (`remote-api.ts`). - expect(snapshots.length).toBe(1); - expect(snapshots[0]!.map((e) => e.surfaceId)).toEqual(['surface-near', 'surface-far']); + 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('resnapshots when a peer directory changes', async () => { - const platform = new PeerPlatform(); - platform.peerEntries = [{ surfaceId: 'surface-far', title: 'before' }]; - const { api, sent } = session(platform); - api.handle({ requestId: 'dir-1', method: REMOTE_METHODS.directoryWatch, params: {} }); - await settle(); - - platform.peerEntries = [{ surfaceId: 'surface-far', title: 'after' }]; - platform.emitPeerChange('directory'); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const snapshots = sent - .filter((p) => (p as RemoteEventMsg).event === REMOTE_EVENTS.directorySnapshot) - .map((p) => ((p as RemoteEventMsg).data as { entries: unknown[] }).entries); - expect(snapshots.at(-1)).toEqual([{ surfaceId: 'surface-far', title: 'after' }]); + it('tells the Host when a future directory answer could differ', () => { + // 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. + primeActivity('pty-1', { status: 'ALERT_RINGING' }); + expect(platform.notified).toContain('directory'); }); }); diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index 3405fb85..966f1d46 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -1,29 +1,26 @@ /** - * What one webview may ask its peers, and the answers it gives back + * What the Host may ask a webview, and the answers it gives back * (docs/specs/vscode.md → "Peer surfaces"). * - * The remote Host runs in one webview, but a window's terminals are spread - * across all of them and each webview has its own xterm registry. So *every* - * webview installs the responder here, not just the Host's: it answers the - * broker's questions about the panes this webview owns, and drives them when - * the Host asks. + * 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 peer operations have real types. The platform + * 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 peer 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. + * `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 - * that will never be the Host pays almost nothing to make its terminals - * reachable from one that is. + * 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 { PeerBridge } from '../../lib/platform/types'; import { subscribeToActivity } from '../../lib/session-activity-store'; import { registry } from '../../lib/terminal-store'; import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; @@ -63,47 +60,12 @@ export interface PeerOps { surfaceOp: { params: PeerSurfaceParams; result: PeerSurfaceResult }; } -/** Put `op` to every peer and collect their answers; empty means nobody owns it. */ -async function askPeers( - op: K, - params: PeerOps[K]['params'], -): Promise { - const peers = getPlatform().peers; - if (!peers) return []; - return (await peers.request(op, params)) as PeerOps[K]['result'][]; -} - -/** - * Whoever this webview answers to: the Node-resident Host service when one sits - * behind the adapter, otherwise its sibling webviews. The two are the same - * question asked from different processes — "what do you own, and drive it" — - * so they share this responder rather than each getting its own copy of the - * registry logic. Only the *asking* side differs, and that stays peers-only - * ({@link askPeers}): a webview never asks the service anything. - */ -function responderBridge(): Pick | undefined { - const platform = getPlatform(); - return platform.remoteHost ?? platform.peers; -} - /** 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 { - responderBridge()?.respond(op, (params) => handler(params as PeerOps[K]['params'])); -} - -/** Directory entries contributed by every other webview and window. */ -export function peerDirectory(): Promise { - return askPeers('directory', {}); -} - -/** Drive a surface someone else owns; `null` if nobody does. */ -export async function peerSurfaceOp(params: PeerSurfaceParams): Promise { - // Surface ids are unique across webviews, so at most one peer answers. - const [owner] = await askPeers('surfaceOp', params); - return owner ?? null; + getPlatform().remoteHost?.respond(op, (params) => handler(params as PeerOps[K]['params'])); } /** @@ -134,17 +96,17 @@ function driveOwnSurface({ surfaceId, op, cols, rows }: PeerSurfaceParams): Peer } /** - * Make this webview's terminals reachable from whoever is the Host — a sibling - * webview, or the service in the process that owns the PTYs. Idempotent, and a - * no-op on hosts that have neither (the website). + * 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 { answerPeers('directory', () => collectDirectorySnapshot()); answerPeers('surfaceOp', driveOwnSurface); - const bridge = responderBridge(); - if (!bridge) return; - const notifyDirectory = () => bridge.notify('directory'); + const link = getPlatform().remoteHost; + if (!link) return; + const notifyDirectory = () => link.notify('directory'); subscribeToTerminalPaneState(notifyDirectory); subscribeToActivity(notifyDirectory); if (typeof document !== 'undefined') { diff --git a/lib/src/remote/host/store.ts b/lib/src/remote/host/store.ts index b3cbae1f..eebf8126 100644 --- a/lib/src/remote/host/store.ts +++ b/lib/src/remote/host/store.ts @@ -2,12 +2,10 @@ * The one key prefix every Host-side persisted value lives under * (`enrollment.ts` → `ENROLLMENT_KEY`, `acl.ts` → `ACL_KEY_PREFIX`). * - * It exists so a host can move the whole Host store somewhere other than - * `localStorage` in one claim: the VS Code webview hands this prefix to - * `PlatformAdapter.hydrateScopedStore`, and the extension host backs it with - * `SecretStorage` (the enrollment blob carries `hostToken`, a bearer - * credential) plus `globalState` for the ACL. Both sides validate against this - * prefix, so a webview can never reach unrelated extension storage. + * One prefix rather than a scatter of keys so a host can name the whole Host + * store at once — which is what lets a Node-resident Host adopt what a webview + * persisted before it existed, and what keys the VS Code extension host writes + * its own copy under (`vscode-ext/src/remote-host-store.ts`). */ export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; @@ -17,25 +15,3 @@ export const REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; * extension bundle; `enrollment.ts` re-exports it for its own callers. */ export const ENROLLMENT_KEY = `${REMOTE_HOST_STORE_PREFIX}enrollment`; - -/** - * Resolves once the Host store is readable — see - * `PlatformAdapter.hydrateScopedStore`. - * - * The webview entry starts hydration at boot but must not gate first paint on - * it: the read waits on an OS keychain, which can take seconds, and a blank - * terminal for that long reads as a hang. The real ordering constraint is - * narrower — hydrated before anything reads a `dormouse.remote-host.` key, - * which happens when `installRemoteHostConsoleHook` runs, downstream of render. - * So the entry publishes the promise here and the lazily-mounted Host awaits - * it. Hosts that never hydrate leave the resolved default in place. - */ -let ready: Promise = Promise.resolve(); - -export function setHostStoreReady(promise: Promise | undefined): void { - ready = promise ?? Promise.resolve(); -} - -export function hostStoreReady(): Promise { - return ready; -} diff --git a/lib/src/remote/host/surface-resolve.ts b/lib/src/remote/host/surface-resolve.ts deleted file mode 100644 index 59461e69..00000000 --- a/lib/src/remote/host/surface-resolve.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Take hold of a surface by id, wherever it lives. - * - * A window's terminals are spread across its webviews and only one of them is - * the Host, so a pane the phone names is either in this webview's registry or - * in a sibling's (docs/specs/vscode.md → "Peer surfaces"). Which one is a fact - * about VS Code webview hosting; it is not a protocol-v1 concept - * (docs/specs/remote-api.md), so it is answered here and never seen above. - * - * The rest of the feature already works this way — `pty:data` from another - * window is injected into the ordinary data path, and `pty:input` / `pty:resize` - * route by table before falling back to the local manager — so `terminal.write` - * has no idea either. This closes the last gap. - */ - -import { getPlatform } from '../../lib/platform'; -import { registry } from '../../lib/terminal-store'; -import type { SurfaceHandle } from './host-surface-provider'; -import { peerSurfaceOp } from './peer-surfaces'; - -/** - * Resolve `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): a sibling has to apply it inside the attach round - * trip, since there is no way to reach into its xterm afterwards without a - * second one. A local pane 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. - */ -export async function resolveSurface( - surfaceId: string, - size: { cols?: number; rows?: number }, -): Promise { - const entry = registry.get(surfaceId); - if (entry) { - const term = entry.terminal; - return { - ptyId: entry.ptyId, - get cols() { - return term.cols; - }, - get rows() { - return term.rows; - }, - // Pinned to the terminal resolved here, not re-read from the registry: a - // pane swap must not move an attachment onto a different terminal. - resize: async (cols, rows) => { - if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); - return { cols: term.cols, rows: term.rows }; - }, - release: () => {}, - }; - } - - // Not ours: ask the other webviews of this window, and the other windows. - // The owner resizes its own xterm — attach-is-the-resize has to go through - // the live terminal, not the PTY, or the owning pane's view drifts from the - // size the phone set. - const peers = getPlatform().peers; - if (!peers) return null; - const owner = await peerSurfaceOp({ surfaceId, op: 'attach', cols: size.cols, rows: size.rows }); - if (!owner) return null; - - const stopStream = peers.streamPty(owner.ptyId); - 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 peerSurfaceOp({ - surfaceId, - op: 'resize', - cols: nextCols, - rows: nextRows, - }); - if (settled) { - cols = settled.cols; - rows = settled.rows; - } - return { cols, rows }; - }, - release: stopStream, - }; -} diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index a892c5d7..97168540 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -41,6 +41,12 @@ const errMessage = (err: unknown): string => err instanceof Error ? err.message /** Mirrors the Tauri adapter's bound; `enroll` makes an HTTP round trip. */ const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; +/** See TauriAdapter: `rhId`s must be unique across every webview, not per adapter. */ +function randomTag(): string { + const uuid = globalThis.crypto?.randomUUID?.(); + return uuid ? uuid.slice(0, 8) : Math.random().toString(36).slice(2, 10); +} + function decodeBase64Bytes(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); @@ -70,6 +76,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { >(); private remoteHostResponders = new Map unknown[]>(); private remoteHostListeners = new Map void>>(); + private readonly rhTag = randomTag(); private nextRemoteHostId = 0; constructor(private readonly host: BrowserSidecarHost) { @@ -135,7 +142,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { }; private nextRhId(): string { - return `rh-${++this.nextRemoteHostId}`; + return `rh-${this.rhTag}-${++this.nextRemoteHostId}`; } private sendRemoteHostCommand(command: RemoteHostCommand): void { diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index fd318fea..56b5a293 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -58,6 +58,16 @@ const errMessage = (err: unknown): string => */ const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; +/** + * A short random component for this adapter'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); +} + /** * Platform adapter for the Tauri standalone app. * @@ -101,6 +111,7 @@ export class TauriAdapter implements PlatformAdapter { >(); private remoteHostResponders = new Map unknown[]>(); private remoteHostListeners = new Map void>>(); + private readonly rhTag = randomTag(); private nextRemoteHostId = 0; constructor() { @@ -527,7 +538,7 @@ export class TauriAdapter implements PlatformAdapter { }; private nextRhId(): string { - return `rh-${++this.nextRemoteHostId}`; + return `rh-${this.rhTag}-${++this.nextRemoteHostId}`; } private sendRemoteHostCommand(command: RemoteHostCommand): void { diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs index cd2ada20..697799c0 100644 --- a/vscode-ext/scripts/esbuild.mjs +++ b/vscode-ext/scripts/esbuild.mjs @@ -1,9 +1,9 @@ // Bundles the extension host and the PTY host, and is the single place that -// bakes the webview's remote-server `connect-src` into the build. +// bakes the remote Host's allowed relay origins into the build. // -// The published extension is scoped to the SaaS origin only, so a compromised -// webview cannot exfiltrate to an arbitrary host. A selfhoster whose relay is -// on their own domain or tailnet widens it for their own 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 // @@ -56,20 +56,20 @@ if (watch) { /** * Fail the build if the `define` did not reach the bundle. * - * `webview-html.ts` reads `__DORMOUSE_REMOTE_CONNECT_SRC__` as a `declare const`, + * `remote-host.ts` reads `__DORMOUSE_REMOTE_CONNECT_SRC__` as a `declare const`, * so if the substitution is ever lost — someone re-inlines the esbuild call, or * adds a bundle entry that pulls in that module without the define — TypeScript - * still compiles and the failure only appears at runtime, as a ReferenceError - * inside `getWebviewHtml` that renders an empty webview. The standalone side - * fails loudly on the same class of drift (`standalone/scripts/csp.mjs`), so - * this side should too. + * still compiles and the failure only appears at runtime, where the Host would + * silently fall back to the built-in default instead of the selfhoster's + * origins. The standalone sidecar bakes the same variable, so this side should + * fail on the same class of drift. */ function assertConnectSrcBaked() { const bundle = readFileSync('dist/extension.js', 'utf8'); if (bundle.includes('__DORMOUSE_REMOTE_CONNECT_SRC__')) { throw new Error( 'CSP: __DORMOUSE_REMOTE_CONNECT_SRC__ survived into dist/extension.js — the esbuild ' + - 'define did not apply, and the webview would throw a ReferenceError at render.', + 'define did not apply, and the remote Host would use the built-in default sources.', ); } if (!bundle.includes(remoteSrc)) { diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 1621aa84..ed768466 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -11,8 +11,7 @@ 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 { initRemoteHostStore } from './remote-host-store'; -import { initWindowLease } from './window-lease'; +import { initRemoteHost } from './remote-host'; import { disposePeerLink, initPeerLink } from './peer-link'; type NewTerminalMessage = Extract; @@ -76,15 +75,13 @@ function setupPanel( } export function activate(context: vscode.ExtensionContext) { - // The remote Host's enrollment (SecretStorage) and ACL (globalState) are - // read by the webview through `store:read`; give the store its context - // before any webview can ask. See remote-host-store.ts. - initRemoteHostStore(context); - // Storage location only — the lease itself does not start until a webview - // claims the Host role (window-lease.ts). - initWindowLease(context); + // 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 501aa4c0..a51fa1fc 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -21,22 +21,14 @@ 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 { readStore, REMOTE_HOST_STORE_PREFIX, writeStore } from './remote-host-store'; import { PEER_REPLY_BUDGET_MS } from '../../lib/src/lib/vscode-peer-link-protocol'; -import { ensureWindowLease } from './window-lease'; +import { configurePeerLink, remoteNotifyPeerChange } from './peer-link'; import { - configurePeerLink, - isRemotePty, - remoteNotifyPeerChange, - remoteRequest, - remoteResize, - remoteSubscribe, - remoteUnsubscribe, - remoteWrite, - setPeerLinkRole, -} from './peer-link'; + configureRemoteHost, + handleRemoteHostCommand, + notifyDirectoryChanged, +} from './remote-host'; import { log } from './log'; -import { PtySubscriptions } from './pty-subscriptions'; import type { WebviewChannel } from './webview-messaging'; const clipboardOps = require('../../lib/clipboard-ops.cjs') as { @@ -48,97 +40,11 @@ const clipboardOps = require('../../lib/clipboard-ops.cjs') as { // Prevents reconnecting routers from stealing PTYs owned by other webviews. const globalOwnedPtyIds = new Set(); -/** - * Arbiter for named single-instance roles across this window's webviews — today - * only `remote-host`, so exactly one webview holds the `/ws/host` socket and - * arms alarm push (see `lib/src/remote/host/activation.ts`). The extension host - * arbitrates because it is the only party that sees every webview and outlives - * each one. First claimant wins; when the holder is disposed the role is - * re-offered, so closing the Dormouse view hands the Host to another open one - * instead of dropping it until a reload. - */ -interface SingletonClaimant { - wants: Set; - notify(name: string, held: boolean): void; -} -const singletonClaimants = new Set(); -/** Who currently holds each role — the one place the answer is stored. */ -const singletonHolders = new Map(); - -/** - * Whether this *window* may hold single-instance roles at all. - * - * One extension host runs per window, so the arbitration above is blind to - * every other window. Left to itself each window would elect its own Host, all - * of them would connect `/ws/host` with the same enrollment, and the server's - * displacement would turn into an endless reconnect fight. `window-lease.ts` - * arbitrates across windows on shared storage; nothing is granted here until it - * says this window won. - */ -let windowLeaseHeld: boolean | null = null; -let storeReadyForWindowLease = false; - -function wantedSingletonNames(): Set { - const names = new Set(); - for (const claimant of singletonClaimants) { - for (const name of claimant.wants) names.add(name); - } - return names; -} - -function onWindowLeaseChange(held: boolean): void { - if (windowLeaseHeld === held) return; - windowLeaseHeld = held; - storeReadyForWindowLease = false; - // The holder is the Host, so it is also the window every other one reports to. - setPeerLinkRole(held); - if (held) { - // A different window may have committed ACL/enrollment writes since these - // webviews hydrated. Replace their caches before granting the Host role so - // the new holder cannot authorize from, or write back, a stale snapshot. - void refreshStoreCachesForLease().then(() => { - if (windowLeaseHeld !== true) return; - storeReadyForWindowLease = true; - for (const name of wantedSingletonNames()) electSingleton(name); - }); - return; - } - // Lost across windows: whoever held it here must stop, not merely stop being - // re-offered it. - for (const [name, holder] of singletonHolders) holder.notify(name, false); - singletonHolders.clear(); -} - -function electSingleton(name: string): void { - if (windowLeaseHeld !== true || !storeReadyForWindowLease) return; - let holder = singletonHolders.get(name); - if (!holder) { - holder = [...singletonClaimants].find((claimant) => claimant.wants.has(name)); - if (!holder) return; - singletonHolders.set(name, holder); - } - // Idempotent: re-claiming (a webview remounting) re-answers the holder. - holder.notify(name, true); -} - -function releaseSingletons(claimant: SingletonClaimant): void { - claimant.wants.clear(); - singletonClaimants.delete(claimant); - for (const [name, holder] of singletonHolders) { - if (holder !== claimant) continue; - singletonHolders.delete(name); - electSingleton(name); - } -} interface ActiveRouter { flushSessionSave(timeoutMs?: number): Promise; ownsPty(id: string): boolean; forwardDorControlRequest(request: DorControlRequest): void; - notifyStoreChanged(key: string, value: string | null): void; - notifyStoreSnapshot(prefix: string, entries: Record): Thenable; - notifyPeerChanged(topic: string | null): void; - deliverForeignData(ptyId: string, data: string): void; - deliverForeignExit(ptyId: string, exitCode: number): void; + send(message: ExtensionMessage): void; ask(requestId: string, op: string, params: unknown): void; } @@ -157,9 +63,16 @@ const peerRequests = new Map(); // would reach them again, so it only ever gets the in-window broker. configurePeerLink({ brokerRequest, - deliverRemotePeerChange, - deliverRemotePtyData, - deliverRemotePtyExit, + invalidateDirectory: notifyDirectoryChanged, + onProcessedPtyData, + onProcessedPtyExit, + writePty: (ptyId, data) => ptyManager.write(ptyId, data), + resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), +}); + +configureRemoteHost({ + brokerRequest, + broadcastToWebviews, onProcessedPtyData, onProcessedPtyExit, writePty: (ptyId, data) => ptyManager.write(ptyId, data), @@ -167,26 +80,25 @@ configurePeerLink({ }); /** - * Put one peer request to every webview in this window except `exclude`, and - * settle with everything they answered. + * Put one question to every webview in this window and settle with everything + * they answered. * - * The remote Host runs in one webview, but a window's terminals are spread - * across all of them — each webview has its own xterm registry, so the Host can - * neither list nor attach to a sibling's pane without asking. The extension - * host is the only party that can ask, so it brokers. See docs/specs/vscode.md - * → "Peer surfaces". + * 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. Callable from a webview request (the Host asking) and from a peer - * window's socket (tier 2), which is why it is a plain promise rather than - * message plumbing. + * picker. The asker is this window's own Host service, and — after phase 3b — + * a peer window's broker over the link, never a webview; that is why it is a + * plain promise rather than message plumbing. */ -function brokerRequest(op: string, params: unknown, exclude?: ActiveRouter): Promise { - const peers = [...activeRouters].filter((router) => router !== exclude); +function brokerRequest(op: string, params: unknown): Promise { + const peers = [...activeRouters]; if (peers.length === 0) return Promise.resolve([]); const requestId = `broker-${++nextBrokerRequestId}`; @@ -209,54 +121,14 @@ function brokerRequest(op: string, params: unknown, exclude?: ActiveRouter): Pro } /** - * Hand a webview bytes from a PTY in another *window*. + * Post one message to every live webview in this window. * - * Local PTYs reach a subscriber through `onProcessedPtyData`; a PTY in another - * window has no such listener here, so the peer link injects it by the same - * route the subscriber already expects. Only webviews that asked for this PTY - * receive it, exactly as with a local subscription. + * 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 deliverRemotePtyData(ptyId: string, data: string): void { - for (const router of activeRouters) router.deliverForeignData(ptyId, data); -} - -/** As {@link deliverRemotePtyData}, for that PTY ending. */ -function deliverRemotePtyExit(ptyId: string, exitCode: number): void { - for (const router of activeRouters) router.deliverForeignExit(ptyId, exitCode); -} - -function deliverRemotePeerChange(topic: string | null): void { - broadcastPeerChange(topic); -} - -function broadcastPeerChange(topic: string | null, exclude?: ActiveRouter): void { - for (const router of activeRouters) { - if (router !== exclude) router.notifyPeerChanged(topic); - } -} - -/** - * Tell every webview about a committed Host-store write. - * - * Each webview caches the store at boot and serves reads from that cache, so - * without this a second webview keeps a stale snapshot — and since the lease - * can hand it the Host later, it would start from that snapshot and write it - * back, losing every pairing approved by the previous holder. Broadcast to all - * routers including the writer: re-applying your own write is a no-op, and - * skipping self would mean identifying it. - */ -function broadcastStoreChange(key: string, value: string | null): void { - for (const router of activeRouters) router.notifyStoreChanged(key, value); -} - -async function refreshStoreCachesForLease(): Promise { - const entries = await readStore(REMOTE_HOST_STORE_PREFIX).catch(() => ({})); - if (windowLeaseHeld !== true) return; - await Promise.all( - [...activeRouters].map((router) => - Promise.resolve(router.notifyStoreSnapshot(REMOTE_HOST_STORE_PREFIX, entries)), - ), - ); +function broadcastToWebviews(message: ExtensionMessage): void { + for (const router of activeRouters) router.send(message); } const activeRouters = new Set(); @@ -400,21 +272,6 @@ export function attachRouter( // Track which PTY IDs were spawned (or reconnected) through this webview const ownedPtyIds = new Set(); - /** - * PTYs this webview asked to watch without owning them — the remote Host - * streaming a sibling webview's terminal. Kept separate from `ownedPtyIds` so - * it never affects Workspace union status, `killOnDispose`, or which webview - * the host considers the owner. - */ - const subscribedPtyIds = new PtySubscriptions(); - - // This webview's stake in the window-wide single-instance roles. - const claimant: SingletonClaimant = { - wants: new Set(), - notify: (name, held) => - void post({ type: 'singleton:lease', name, held } satisfies ExtensionMessage), - }; - singletonClaimants.add(claimant); const pendingFlushRequests = new Map void; timeout: ReturnType }>(); let disposed = false; @@ -527,17 +384,15 @@ export function attachRouter( */ function connectWebview(): () => void { const removeProcessedListener = onProcessedPtyData((id, visibleData) => { - if (!ownedPtyIds.has(id) && !subscribedPtyIds.has(id)) return; + if (!ownedPtyIds.has(id)) return; post({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); }); const removeSemanticListener = onTerminalSemanticEvents((id, events) => { - // Semantic events drive the *owner's* pane state; a subscriber is - // streaming bytes, not maintaining a second copy of that state. if (!ownedPtyIds.has(id)) return; post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); }); const removeExitListener = onProcessedPtyExit((id, exitCode) => { - if (!ownedPtyIds.has(id) && !subscribedPtyIds.has(id)) return; + if (!ownedPtyIds.has(id)) return; post({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); }); @@ -577,11 +432,10 @@ export function attachRouter( break; } case 'pty:input': - // `remoteWrite` reports false for anything this window owns. - if (!remoteWrite(msg.id, msg.data)) ptyManager.write(msg.id, msg.data); + ptyManager.write(msg.id, msg.data); break; case 'pty:resize': - if (!remoteResize(msg.id, msg.cols, msg.rows)) ptyManager.resize(msg.id, msg.cols, msg.rows); + ptyManager.resize(msg.id, msg.cols, msg.rows); break; case 'pty:kill': release(msg.id); @@ -737,33 +591,6 @@ export function attachRouter( } satisfies ExtensionMessage), ); break; - case 'pty:subscribe': - if (typeof msg.id !== 'string') break; - if (!subscribedPtyIds.subscribe(msg.id)) break; - // A PTY in another window has no local listener to hook; ask its window - // to start sending it. - if (isRemotePty(msg.id)) remoteSubscribe(msg.id); - break; - case 'pty:unsubscribe': - if (typeof msg.id !== 'string') break; - if (!subscribedPtyIds.unsubscribe(msg.id)) break; - if (isRemotePty(msg.id)) remoteUnsubscribe(msg.id); - break; - case 'peer:request': { - // This window's other webviews, plus every window reporting to us. Both - // at once rather than falling through: what is asked about lives in - // exactly one of them, and asking in series would pay a whole tier's - // budget before reaching the tier that owns it. - const requestId = msg.requestId; - const { op, params } = msg; - void Promise.all([brokerRequest(op, params, router), remoteRequest(op, params)]).then( - ([here, elsewhere]) => - post({ - type: 'peer:results', requestId, results: [...here, ...elsewhere], - } 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 @@ -777,40 +604,12 @@ export function attachRouter( } case 'peer:notify': if (typeof msg.topic !== 'string') break; - broadcastPeerChange(msg.topic, router); + notifyDirectoryChanged(); remoteNotifyPeerChange(msg.topic); break; - case 'singleton:claim': - // `WebviewMessage` is a claim about the sender, not a runtime check. - if (typeof msg.name !== 'string') break; - claimant.wants.add(msg.name); - // First claim in this window starts the cross-window arbitration; it - // answers asynchronously, and `onWindowLeaseChange` elects when it does. - ensureWindowLease(onWindowLeaseChange); - electSingleton(msg.name); - break; - case 'store:read': - // The Host's enrollment + ACL live in extension-host storage, not in - // webview localStorage (remote-host-store.ts explains why). Both sides - // gate on the key prefix. - readStore(typeof msg.prefix === 'string' ? msg.prefix : '') - .catch(() => ({})) - .then((entries) => post({ - type: 'store:entries', requestId: msg.requestId, entries, - } satisfies ExtensionMessage)); - break; - case 'store:write': { - // Same bar as `store:read` above: a non-string key would throw inside - // `allowed()` as an unhandled rejection rather than a refused write. - const key = msg.key; - const value = msg.value; - if (typeof key !== 'string') break; - if (typeof value !== 'string' && value !== null) break; - void writeStore(key, value).then((written) => { - if (written) broadcastStoreChange(key, value); - }); + 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 }; @@ -975,44 +774,27 @@ export function attachRouter( flushSessionSave, ownsPty, forwardDorControlRequest, - notifyStoreChanged(key: string, value: string | null) { - if (disposed) return; - void post({ type: 'store:changed', key, value } satisfies ExtensionMessage); - }, - notifyStoreSnapshot(prefix: string, entries: Record) { - return post({ type: 'store:snapshot', prefix, entries } satisfies ExtensionMessage); - }, - notifyPeerChanged(topic: string | null) { + send(message: ExtensionMessage) { if (disposed) return; - void post({ type: 'peer:changed', topic } satisfies ExtensionMessage); + void post(message); }, ask(requestId: string, op: string, params: unknown) { if (disposed) return; void post({ type: 'peer:ask', requestId, op, params } satisfies ExtensionMessage); }, - deliverForeignData(ptyId: string, data: string) { - if (disposed || !subscribedPtyIds.has(ptyId)) return; - void post({ type: 'pty:data', id: ptyId, data } satisfies ExtensionMessage); - }, - deliverForeignExit(ptyId: string, exitCode: number) { - if (disposed || !subscribedPtyIds.has(ptyId)) return; - void post({ type: 'pty:exit', id: ptyId, exitCode } satisfies ExtensionMessage); - }, dispose() { if (disposed) return; disposed = true; activeRouters.delete(router); - broadcastPeerChange(null); + // One fewer webview to ask means the directory's answer changed, even if + // no surface did. + notifyDirectoryChanged(); remoteNotifyPeerChange(null); // 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(); } - subscribedPtyIds.releaseAll((ptyId) => { - if (isRemotePty(ptyId)) remoteUnsubscribe(ptyId); - }); - releaseSingletons(claimant); removeWatchedCommandListener(); removeAlertSettingsListener(); resolveAllFlushRequests(); @@ -1031,7 +813,7 @@ export function attachRouter( }; activeRouters.add(router); - broadcastPeerChange(null, router); + notifyDirectoryChanged(); remoteNotifyPeerChange(null); return router; } diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index adad45c6..a84a3bec 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,19 +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 } - | { type: 'singleton:claim'; name: string } - // Peer surfaces: one webview is the remote 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 + // 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: 'pty:subscribe'; id: string } - | { type: 'pty:unsubscribe'; id: string } - | { type: 'peer:request'; requestId: string; op: string; params: unknown } | { type: 'peer:answer'; requestId: string; results: unknown[] } | { type: 'peer:notify'; topic: string } - | { type: 'store:read'; prefix: string; requestId: string } - | { type: 'store:write'; key: string; value: string | null } + // 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 } @@ -86,13 +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: 'store:entries'; requestId: string; entries: Record } - | { type: 'singleton:lease'; name: string; held: boolean } - | { type: 'store:changed'; key: string; value: string | null } - | { type: 'store:snapshot'; prefix: string; entries: Record } | { type: 'peer:ask'; requestId: string; op: string; params: unknown } - | { type: 'peer:results'; requestId: string; results: unknown[] } - | { type: 'peer:changed'; topic: string | null } + // 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.ts b/vscode-ext/src/peer-link.ts index d8c7b73f..e31ec48a 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -1,29 +1,30 @@ /** - * Peer surfaces across VS Code windows (docs/specs/vscode.md → "Peer surfaces - * across windows"). + * Which VS Code window runs the remote Host, and how the others reach it + * (docs/specs/vscode.md → "Peer surfaces across windows"). * - * Within a window the extension host sees every webview, so brokering is a - * function call (`brokerRequest`). Across windows there is no shared process at - * all — one extension host each — so the window holding the Host lease listens - * on a local socket and every other window connects to it. Because the webview - * lease is itself gated on the window lease, the broker window is always the - * Host window; the broker never has to relay back out to a remote Host, which - * keeps this one-directional. + * 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 follow the lease: acquire it and you become the server, lose it and you - * become a client. The frame shapes, framing, and PTY routing table are in - * `lib/src/lib/vscode-peer-link-protocol.ts`, which is where the fiddly parts - * are tested. + * 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. * - * Trust: the socket is a user-owned unix socket (or named pipe) whose path is - * published only in a mode-0600 rendezvous file, and a client must open with a - * token read from that file. That is the same bar as the `dor` control socket. + * Trust: the socket is a user-owned unix socket (or named pipe) and a client + * must open with a token from a mode-0600 file in the extension's + * `globalStorageUri` — the same bar as the `dor` control socket. */ -import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import { createConnection, createServer, type Server, type Socket } from 'node:net'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { type FSWatcher } from 'node:fs'; +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -40,7 +41,6 @@ import { type PeerLinkResponse, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; -import { watchDirFile } from './watch-dir-file'; /** * What this module needs from the router, injected rather than imported: the @@ -50,9 +50,8 @@ import { watchDirFile } from './watch-dir-file'; export interface PeerLinkDeps { /** Fan out to this window's own webviews — never to other windows. */ brokerRequest(op: string, params: unknown): Promise; - deliverRemotePtyData(ptyId: string, data: string): void; - deliverRemotePtyExit(ptyId: string, exitCode: number): void; - deliverRemotePeerChange(topic: string | null): void; + /** A peer window's answers may have changed, so the directory is stale. */ + invalidateDirectory(): void; onProcessedPtyData(listener: (id: string, data: string) => void): () => void; onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => void; writePty(ptyId: string, data: string): void; @@ -65,7 +64,10 @@ export function configurePeerLink(next: PeerLinkDeps): void { deps = next; } -const RENDEZVOUS_FILE = 'remote-host.peer.json'; +const TOKEN_FILE = 'remote-host.peer-token'; + +/** Floor between contention attempts, so a refused hello cannot become a spin. */ +const RETRY_MS = 1_000; /** * Constant-time token compare, mirroring `tokenMatches` in @@ -81,36 +83,61 @@ function tokenMatches(provided: unknown, expected: string): boolean { return timingSafeEqual(a, b); } -/** Backoff for a client whose broker went away before a new one took the lease. */ -const RECONNECT_MS = 2_000; - -interface Rendezvous { - socketPath: string; - token: string; -} - let context: vscode.ExtensionContext | null = null; export function initPeerLink(ctx: vscode.ExtensionContext): void { context = ctx; } -function rendezvousPath(): string | null { - return context ? join(context.globalStorageUri.fsPath, RENDEZVOUS_FILE) : null; +function tokenPath(): string | null { + return context ? join(context.globalStorageUri.fsPath, TOKEN_FILE) : null; } /** - * Sockets live in the temp dir, not next to the rendezvous file: macOS caps a - * unix socket path near 104 bytes and the extension's globalStorage path is - * most of that on its own. + * 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 newSocketPath(): string { - const id = randomBytes(6).toString('hex'); +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(tmpdir(), `dormouse-peer-${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 interface PeerClient { @@ -119,13 +146,18 @@ interface PeerClient { authenticated: boolean; } -/** The role the lease last asked for; an in-flight transition re-reads it. */ -let brokerRole = false; +/** 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; /** Claimed and cleared with `server`; the two always move together. */ -let rendezvous: Rendezvous | null = null; +let serverToken: string | null = null; const clients = new Set(); const routes = new Map(); +const remoteSinks = new Map(); const pendingRequests = new Map void>(); let nextRequestId = 0; @@ -167,6 +199,9 @@ function authenticatedClients(): PeerClient[] { * {@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. + * + * Nothing calls this yet — the broker serves only its own window's surfaces + * until phase 3b wires the second tier into the service's provider. */ export async function remoteRequest(op: string, params: unknown): Promise { const peers = authenticatedClients(); @@ -194,12 +229,15 @@ export function isRemotePty(ptyId: string): boolean { return routes.get(ptyId) !== undefined; } -export function remoteSubscribe(ptyId: string): void { +export function remoteSubscribe(ptyId: string, sink: RemotePtySink): void { const client = routes.get(ptyId); - if (client) send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); + if (!client) return; + remoteSinks.set(ptyId, sink); + send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); } export function remoteUnsubscribe(ptyId: string): void { + remoteSinks.delete(ptyId); const client = routes.get(ptyId); if (!client) return; send(client, { kind: 'unsubscribe', id: `r${++nextRequestId}`, ptyId }); @@ -224,8 +262,11 @@ function dropClient(client: PeerClient): void { 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 ptyId of forgetPeerRoutes(routes, client)) deps?.deliverRemotePtyExit(ptyId, 0); - if (wasAuthenticated) deps?.deliverRemotePeerChange(null); + for (const ptyId of forgetPeerRoutes(routes, client)) { + remoteSinks.get(ptyId)?.onExit(0); + remoteSinks.delete(ptyId); + } + if (wasAuthenticated) deps?.invalidateDirectory(); client.socket.destroy(); } @@ -236,7 +277,7 @@ function onServerFrame(client: PeerClient, frame: unknown): void { if (!client.authenticated) { // First frame must be the hello; anything else is not a peer of ours. const hello = message as Partial; - if (hello.kind !== 'hello' || !rendezvous || !tokenMatches(hello.token, rendezvous.token)) { + if (hello.kind !== 'hello' || !serverToken || !tokenMatches(hello.token, serverToken)) { log.error('[peer-link] rejected a client with a bad hello'); dropClient(client); return; @@ -244,34 +285,35 @@ function onServerFrame(client: PeerClient, frame: unknown): void { client.authenticated = true; // Joining changes the answer set even if no surface changed while the // socket was down, so every peer-backed snapshot must be reconsidered. - deps?.deliverRemotePeerChange(null); + deps?.invalidateDirectory(); return; } const response = message as PeerLinkResponse; if (response.kind === 'data') { - deps?.deliverRemotePtyData(response.ptyId, response.data); + remoteSinks.get(response.ptyId)?.onData(response.data); return; } if (response.kind === 'exit') { routes.delete(response.ptyId); - deps?.deliverRemotePtyExit(response.ptyId, response.exitCode); + remoteSinks.get(response.ptyId)?.onExit(response.exitCode); + remoteSinks.delete(response.ptyId); return; } if (response.kind === 'notify') { - deps?.deliverRemotePeerChange(response.topic); + deps?.invalidateDirectory(); return; } if ('id' in response) pendingRequests.get(response.id)?.(response); } /** Turn Server.listen's event-based bind failure into a rejecting promise. */ -export function listenServer(nextServer: Server, socketPath: string): 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(socketPath, () => { + nextServer.listen(path, () => { nextServer.off('error', onError); resolve(); }); @@ -282,18 +324,8 @@ export function listenServer(nextServer: Server, socketPath: string): Promise { - if (target.listening) target.close(); - await rm(socketPath, { force: true }).catch(() => {}); -} - -async function startServer(): Promise { - const path = rendezvousPath(); - if (!path || server) return; - - const next: Rendezvous = { socketPath: newSocketPath(), token: randomUUID() }; - const temp = `${path}.${randomUUID()}.tmp`; +/** 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: PeerClient = { socket, decoder: new FrameDecoder(), authenticated: false }; clients.add(client); @@ -304,76 +336,23 @@ async function startServer(): Promise { socket.on('error', () => dropClient(client)); socket.on('close', () => dropClient(client)); }); - // Claimed in the same tick as the guard above, which is what makes - // `server === nextServer` a complete staleness test: nothing can slip in - // between. Everything below awaits, and the lease can flip back to client - // inside any of those gaps. - server = nextServer; - rendezvous = next; - - /** - * Give back what this attempt claimed, leaving whoever holds the role now - * alone — `stopServer` would unlink a newer broker's socket and rendezvous - * along with this one. Anyone who connected in the meantime is left to the - * socket's own 'close' handler. - */ - const abandon = async (): Promise => { - await closeServer(nextServer, next.socketPath); - await rm(temp, { force: true }).catch(() => {}); - }; - try { - // The bind fails hard if anything owns this path. Nothing should — it is - // six fresh random bytes — and clearing it first is one fs call on a path - // taken once per lease acquisition. - await rm(next.socketPath, { force: true }).catch(() => {}); - if (server !== nextServer) { - await abandon(); - return; - } - await listenServer(nextServer, next.socketPath); - await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); - // The token is the only thing standing between another local process and - // this window's terminals, so it is never briefly world-readable: written - // 0600 to a temp file and renamed into place, which also means a reader - // never sees a half-written rendezvous and falls into the retry backoff. - await writeFile(temp, JSON.stringify(next), { encoding: 'utf8', mode: 0o600 }); - // Stopped while we were publishing: the socket named in there is already - // unlinked, so renaming it into place would leave every peer dialing a - // dead path until some later broker rewrote the file. - if (server !== nextServer) { - await abandon(); - return; - } - await rename(temp, path); - log.info('[peer-link] serving peers'); - } catch (err) { - // Started fire-and-forget from the lease callback, so a rejection here - // would surface as an unhandled one rather than as a broken link. An - // unwritable globalStorage means no peers, not a crashed extension host. - log.error(`[peer-link] could not start serving: ${String(err)}`); - await abandon(); - if (server === nextServer) await stopServer(); + 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; } -} - -async function stopServer(): Promise { - const closing = server; - if (!closing) return; - const path = rendezvousPath(); - const socketPath = rendezvous?.socketPath; - for (const client of [...clients]) dropClient(client); - server = null; - rendezvous = null; - if (socketPath) await closeServer(closing, socketPath); - if (path) await rm(path, { force: true }).catch(() => {}); + server = nextServer; + serverToken = token; + return true; } // ---------------------------------------------------------------- client side let client: Socket | null = null; -let clientRetry: ReturnType | null = null; -let rendezvousWatcher: FSWatcher | null = null; const pendingNotifications = new Set(); /** PTYs this window is streaming to the broker, and how to stop. */ const forwarding = new Map void>(); @@ -383,7 +362,7 @@ function respond(frame: PeerLinkResponse): void { } export function remoteNotifyPeerChange(topic: string | null): void { - // The broker is the destination; its in-window routers were notified directly. + // The broker is the destination; its own window was notified directly. if (server) return; if (!client || client.destroyed) { pendingNotifications.add(topic); @@ -439,129 +418,212 @@ function stopForwarding(): void { forwarding.clear(); } -async function readRendezvous(): Promise { - const path = rendezvousPath(); - if (!path) return null; - try { - const parsed: unknown = JSON.parse(await readFile(path, 'utf8')); - const value = parsed as Rendezvous; - return typeof value?.socketPath === 'string' && typeof value?.token === 'string' - ? value - : null; - } catch { - return null; - } -} - -async function connectClient(): Promise { - if (client || server) return; - const rendezvous = await readRendezvous(); - if (!rendezvous) { - scheduleReconnect(); - return; - } - - const socket = createConnection({ path: rendezvous.socketPath }); - const decoder = new FrameDecoder(); - socket.setEncoding('utf8'); - socket.on('connect', () => { - socket.write(encodeFrame({ kind: 'hello', token: rendezvous.token })); - for (const topic of pendingNotifications) socket.write(encodeFrame({ kind: 'notify', topic })); - pendingNotifications.clear(); - log.info('[peer-link] connected to the broker window'); - }); - socket.on('data', (chunk: string) => { - for (const frame of decoder.push(chunk)) void onClientFrame(frame); +/** + * Connect to whoever holds the socket. `'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. + */ +function tryConnect(path: string, token: string): Promise<'connected' | 'refused' | 'failed'> { + return new Promise((resolve) => { + const socket = createConnection({ path }); + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.once('error', (error: NodeJS.ErrnoException) => { + socket.destroy(); + resolve(error.code === 'ECONNREFUSED' || error.code === 'ENOENT' ? 'refused' : 'failed'); + }); + socket.once('connect', () => { + socket.removeAllListeners('error'); + socket.write(encodeFrame({ kind: 'hello', token })); + for (const topic of pendingNotifications) socket.write(encodeFrame({ kind: 'notify', topic })); + pendingNotifications.clear(); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) void onClientFrame(frame); + }); + 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(); + }; + socket.on('error', drop); + socket.on('close', drop); + client = socket; + log.info('[peer-link] connected to the broker window'); + resolve('connected'); + }); }); - const drop = () => { - if (client !== socket) return; - client = null; - stopForwarding(); - scheduleReconnect(); - }; - socket.on('error', drop); - socket.on('close', drop); - client = socket; -} - -function scheduleReconnect(): void { - if (clientRetry || server) return; - clientRetry = setTimeout(() => { - clientRetry = null; - void connectClient(); - }, RECONNECT_MS); } function disconnectClient(): void { - if (clientRetry) { - clearTimeout(clientRetry); - clientRetry = null; - } stopForwarding(); client?.destroy(); client = null; } -// ---------------------------------------------------------------- role switch +// ------------------------------------------------------------ the contend loop + +let disposed = false; +let contending = false; +let nextAttemptAt = 0; +let announceRole: ((broker: boolean) => void) | null = null; +let settledOnce: Promise | null = null; +let markSettled: (() => void) | null = null; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** - * Follow the window lease: the holder serves, everyone else connects to it. - * Called on every lease change, and idempotent for an unchanged role. + * 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. * - * Either direction takes several awaits to settle and another flip can land - * inside them, so each branch re-checks the role it is transitioning into - * rather than assuming it still holds: `brokerRole` on the client side, and - * `server === nextServer` on the broker side, which additionally tells a later - * startup that already claimed the slot from this one. + * There is deliberately no `onRole(false)` after a `true`: a broker is the + * broker for the rest of the process's life. */ -export function setPeerLinkRole(isBroker: boolean): void { - brokerRole = isBroker; - if (isBroker) { - disconnectClient(); - stopWatchingRendezvous(); - void startServer(); - return; +export function ensurePeerNet(onRole: (broker: boolean) => void): Promise { + announceRole = onRole; + if (server) { + onRole(true); + return Promise.resolve(); } - void (async () => { - await stopServer(); - // Flipped back to broker while that was tearing down: a broker must not - // watch the rendezvous, or it wakes itself on its own writes. - if (brokerRole) return; - watchRendezvous(); - await connectClient(); - })(); + // No storage location means no socket to contend for, and no amount of + // retrying would produce one. + if (!context || disposed) return Promise.resolve(); + settledOnce ??= new Promise((resolve) => { + markSettled = resolve; + }); + void contend(); + return settledOnce; +} + +/** Whether this window holds the Host. */ +export function isPeerBroker(): boolean { + return server !== null; +} + +function settle(broker: boolean): void { + if (broker) announceRole?.(true); + markSettled?.(); + markSettled = null; } /** - * Watch the rendezvous rather than only retrying: a new broker publishes a - * fresh socket path, and polling alone would make every handover wait out the - * backoff. Only a client needs it — a broker watching would wake on its own - * writes. + * 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. */ -function watchRendezvous(): void { - if (rendezvousWatcher || !context) return; - const watcher = watchDirFile( - context.globalStorageUri.fsPath, - RENDEZVOUS_FILE, - () => { - disconnectClient(); - void connectClient(); - }, - (error) => { - log.error(`[peer-link] rendezvous watcher failed; the timer converges: ${String(error)}`); - if (rendezvousWatcher === watcher) rendezvousWatcher = null; - }, - ); - rendezvousWatcher = watcher; +async function attempt(): Promise { + const path = socketPath(); + if (!path) return false; + const token = await ensureToken(); + + 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'); + settle(true); + return true; + } + + const outcome = await tryConnect(path, token); + 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; + } + if (outcome === 'refused') { + // The path exists but nothing answers: a broker that died without running + // its disposables. Unlinking is safe because a live broker would have + // accepted the connection above. + 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'); + 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. + await closeServer(false); + } + } + return false; +} + +/** How long to let a competing reclaim land before believing we won it. */ +const RECLAIM_VERIFY_MS = 250; + +/** + * Whether the socket path still names the inode 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 an inode no client can + * reach. Nothing on the bind path detects that, so it is checked afterwards. + * Windows named pipes cannot get here (a pipe dies with its process) and do not + * stat, so an unreadable path is taken as ours. + */ +async function stillOurs(path: string): Promise { + const mine = await stat(path).catch(() => null); + if (!mine) return true; + await delay(RECLAIM_VERIFY_MS); + const now = await stat(path).catch(() => null); + return !now || now.ino === mine.ino; } -function stopWatchingRendezvous(): void { - rendezvousWatcher?.close(); - rendezvousWatcher = null; +async function contend(): Promise { + if (contending || disposed) return; + contending = true; + try { + while (!disposed && !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; + 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 { - stopWatchingRendezvous(); + disposed = true; disconnectClient(); - await stopServer(); + await closeServer(true); } diff --git a/vscode-ext/src/pty-subscriptions.ts b/vscode-ext/src/pty-subscriptions.ts deleted file mode 100644 index 048217df..00000000 --- a/vscode-ext/src/pty-subscriptions.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Reference counts for one router's foreign PTY streams. */ -export class PtySubscriptions { - readonly #counts = new Map(); - - has(ptyId: string): boolean { - return this.#counts.has(ptyId); - } - - /** Add one viewer; true only for the zero-to-one transition. */ - subscribe(ptyId: string): boolean { - const count = this.#counts.get(ptyId) ?? 0; - this.#counts.set(ptyId, count + 1); - return count === 0; - } - - /** Remove one viewer; true only for the one-to-zero transition. */ - unsubscribe(ptyId: string): boolean { - const count = this.#counts.get(ptyId); - if (count === undefined) return false; - if (count > 1) { - this.#counts.set(ptyId, count - 1); - return false; - } - this.#counts.delete(ptyId); - return true; - } - - /** Release every underlying unique stream, regardless of viewer count. */ - releaseAll(release: (ptyId: string) => void): void { - for (const ptyId of this.#counts.keys()) release(ptyId); - this.#counts.clear(); - } -} diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index 03cb4317..7a7fa208 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -1,87 +1,91 @@ /** - * Extension-host storage for the webview's remote-Host keys - * (docs/specs/vscode.md → "Remote Host: store and lease"). + * Where the VS Code Host keeps the two things it must survive a restart with: + * the enrollment and the ACL (`lib/src/host/remote/host-state-store.ts`). * - * The webview cannot keep these in `localStorage`: VS Code's persistence story - * is `setState`/`workspaceState`/`globalState`, and the enrollment blob carries - * `hostToken` — a bearer credential that grants the `/ws/host` socket — so it - * belongs in `SecretStorage` (OS keychain), not in a webview-origin store. + * Split by sensitivity. The enrollment blob carries `hostToken` — a bearer + * credential that grants the `/ws/host` socket — so it goes to `SecretStorage` + * (OS keychain); the ACL is public-key records with no secret in them, so it + * goes to `globalState`. Both are global rather than workspace-scoped, because + * a Host identity belongs to the machine, not to a folder. * - * Split by sensitivity: the enrollment blob goes to `SecretStorage`, the ACL - * (public key records, no secret) to `globalState`. Both are global rather than - * workspace-scoped, because a Host identity belongs to the machine, not to a - * folder. - * - * Everything here is prefix-gated. The webview names keys, so an untrusted - * message must never be able to read or write extension state outside the - * Host's own namespace. + * The keys are the ones the webview-resident Host wrote through this module + * before the service existed, and the values are the same JSON strings, so an + * already-enrolled installation is picked up with no migration step. */ import type * as vscode from 'vscode'; -// Imported, not mirrored: a prefix that drifted between the two sides would -// break the gate in one direction only. `store.ts` is dependency-free so it -// costs the extension bundle nothing. -import { ENROLLMENT_KEY, REMOTE_HOST_STORE_PREFIX } from '../../lib/src/remote/host/store'; - -export { REMOTE_HOST_STORE_PREFIX }; - -/** - * Enough for an enrollment blob or a sizable ACL, small enough that a - * compromised webview cannot bloat the keychain or globalState. - */ -const MAX_VALUE_BYTES = 64 * 1024; - -let context: vscode.ExtensionContext | null = null; +import type { HostAclRecord, HostStateStore } from '../../lib/src/host/remote/host-state-store'; +import { ACL_KEY_PREFIX } from '../../lib/src/remote/host/acl'; +import type { HostEnrollment } from '../../lib/src/remote/host/enrollment'; +// Imported, not mirrored: a key that drifted between the two sides would strand +// an enrollment that is still on disk. +import { ENROLLMENT_KEY } from '../../lib/src/remote/host/store'; -export function initRemoteHostStore(ctx: vscode.ExtensionContext): void { - context = ctx; +function isEnrollment(value: unknown): value is HostEnrollment { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.serverUrl === 'string' && + typeof v.hostId === 'string' && + typeof v.hostToken === 'string' && + typeof v.origin === 'string' && + typeof v.rpId === 'string' + ); } -function allowed(key: string): boolean { - return key.startsWith(REMOTE_HOST_STORE_PREFIX); -} +export class VsCodeHostStateStore implements HostStateStore { + readonly #context: vscode.ExtensionContext; -/** - * Every stored value whose key starts with `prefix`. Returns `{}` for any - * prefix outside the Host namespace, so a webview asking for something else - * learns nothing. - */ -export async function readStore(prefix: string): Promise> { - if (!context || !allowed(prefix)) return {}; - const entries: Record = {}; + constructor(context: vscode.ExtensionContext) { + this.#context = context; + } - // `allowed(prefix)` above already proved every in-range key is in namespace. - for (const key of context.globalState.keys()) { - if (!key.startsWith(prefix) || key === ENROLLMENT_KEY) continue; - const value = context.globalState.get(key); - if (typeof value === 'string') entries[key] = value; + async loadEnrollment(): 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; + } } - // Guard before the read: a narrower prefix must not pay for a keychain hit - // whose result it would discard. - if (ENROLLMENT_KEY.startsWith(prefix)) { - const enrollment = await context.secrets.get(ENROLLMENT_KEY); - if (enrollment !== undefined) entries[ENROLLMENT_KEY] = enrollment; + async saveEnrollment(enrollment: HostEnrollment): Promise { + await this.#context.secrets.store(ENROLLMENT_KEY, JSON.stringify(enrollment)); } - return entries; -} + async clearEnrollment(): Promise { + await this.#context.secrets.delete(ENROLLMENT_KEY); + } -/** - * Write (or, with `null`, delete) one Host-namespace key. Returns whether the - * write happened, so the caller only announces changes that are real. - */ -export async function writeStore(key: string, value: string | null): Promise { - if (!context || !allowed(key)) return false; - if (value !== null && Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) return false; + 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 []; + // `HostAcl.fromRecords` rejects a mismatched hostId, so drop foreign rows + // rather than fail the whole load over one. + return parsed.filter( + (record): record is HostAclRecord => + !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, + ); + } - if (key === ENROLLMENT_KEY) { - if (value === null) await context.secrets.delete(key); - else await context.secrets.store(key, value); - return true; + async saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { + await this.#context.globalState.update(aclKey(hostId), JSON.stringify(records)); } +} - await context.globalState.update(key, value === null ? undefined : value); - return true; +/** 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..9080d2e6 --- /dev/null +++ b/vscode-ext/src/remote-host.ts @@ -0,0 +1,259 @@ +/** + * 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, and answers a losing window's webviews with an + * error rather than a second Host. + * + * 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 { DEFAULT_REMOTE_CONNECT_SRC } from '../../lib/src/host/remote/connect-src'; +import { RemoteHostService } from '../../lib/src/host/remote/service'; +import { + REMOTE_HOST_EVENT_EVENT, + REMOTE_HOST_RESULT_EVENT, + type RemoteHostCommand, + type RemoteHostResult, +} from '../../lib/src/host/remote/service-protocol'; +import type { + DirectoryEntry, + HostSurfaceProvider, + SurfaceHandle, +} from '../../lib/src/remote/host/host-surface-provider'; +import type { PeerSurfaceResult } from '../../lib/src/remote/host/peer-surfaces'; +import type { ExtensionMessage } from './message-types'; +import { ensurePeerNet } from './peer-link'; +import { VsCodeHostStateStore } from './remote-host-store'; +import { log } from './log'; + +/** + * Remote-server `connect-src` sources, substituted by esbuild at build time + * (`scripts/esbuild.mjs`). Declared rather than imported so the value is a + * literal in the bundle and cannot be changed at runtime. The service refuses + * to enroll with, or connect to, anything outside it. + */ +declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; + +/** + * 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; + onProcessedPtyData(listener: (id: string, data: string) => void): () => void; + onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => void; +} + +let deps: RemoteHostDeps | null = null; + +export function configureRemoteHost(next: RemoteHostDeps): void { + deps = next; +} + +let context: vscode.ExtensionContext | null = null; +let service: RemoteHostService | null = null; +const directoryWatchers = new Set<() => void>(); + +/** + * Build the provider the service serves remote-api v1 through. + * + * PTYs 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. + */ +export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProvider { + return { + async collectDirectory(): Promise { + // Each webview answers with its whole snapshot, so the results *are* the + // entries — no per-webview merging to do on this side. + return (await bound.brokerRequest('directory', {})) as DirectoryEntry[]; + }, + + 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). + const [owner] = (await bound.brokerRequest('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 bound.brokerRequest('surfaceOp', { + surfaceId, + op: 'resize', + cols: nextCols, + rows: nextRows, + })) 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: (ptyId, data) => bound.writePty(ptyId, data), + resizePty: (ptyId, cols, rows) => bound.resizePty(ptyId, cols, rows), + + streamPty(ptyId, sink) { + // 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. + const offData = bound.onProcessedPtyData((id, data) => { + if (id === ptyId) sink.onData(data); + }); + const offExit = bound.onProcessedPtyExit((id, exitCode) => { + if (id === ptyId) sink.onExit(exitCode); + }); + return () => { + offData(); + offExit(); + }; + }, + }; +} + +/** Something the directory depends on changed: a pane, an alert, a webview. */ +export function notifyDirectoryChanged(): void { + for (const watcher of [...directoryWatchers]) watcher(); +} + +function startService(): void { + if (service || !context || !deps) return; + const bound = deps; + service = new RemoteHostService({ + store: new VsCodeHostStateStore(context), + provider: createRemoteHostProvider(bound), + sendToUi: (event, data) => { + // Broadcast rather than reply to one webview: `rhId`s carry a per-adapter + // tag, so only the webview that asked finds a pending command to settle. + if (event === REMOTE_HOST_RESULT_EVENT) { + bound.broadcastToWebviews({ type: 'remoteHost:result', payload: data as RemoteHostResult }); + } else if (event === REMOTE_HOST_EVENT_EVENT) { + bound.broadcastToWebviews({ type: 'remoteHost:event', payload: data }); + } + }, + // The `typeof` guard is for the test runner, which has no esbuild define; + // a real build substitutes both halves with the baked literal. + connectSrc: + typeof __DORMOUSE_REMOTE_CONNECT_SRC__ === 'string' + ? __DORMOUSE_REMOTE_CONNECT_SRC__ + : DEFAULT_REMOTE_CONNECT_SRC, + }); + void service.start().catch((error: unknown) => { + log.error(`[remote-host] failed to start: ${String(error)}`); + }); +} + +/** + * Join the contention for the Host and start serving if this window wins it. + * Idempotent; resolves once a role is settled. + */ +function contendForHost(): Promise { + return ensurePeerNet((broker) => { + if (broker) startService(); + }); +} + +/** + * Hand one webview command to the Host. + * + * A window that lost the bind has no service to run it. Until phase 3b forwards + * it over the link, say so rather than answering from a Host that is not there + * — a silent drop would leave the console hook hanging for its whole timeout. + * `enroll` is the exception: it is how an installation with no Host at all + * bootstraps, so it starts the contention first and re-checks. + */ +export function handleRemoteHostCommand(payload: RemoteHostCommand | undefined): void { + if (!payload || typeof payload.rhId !== 'string' || typeof payload.cmd !== 'string') return; + if (service) { + void service.handleCommand(payload); + return; + } + if (payload.cmd === 'enroll') { + void contendForHost().then(() => { + if (service) void service.handleCommand(payload); + else refuse(payload.rhId); + }); + return; + } + refuse(payload.rhId); +} + +function refuse(rhId: string): void { + deps?.broadcastToWebviews({ + type: 'remoteHost:result', + payload: { rhId, error: 'the remote Host runs in another VS Code window' }, + }); +} + +/** + * 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 new VsCodeHostStateStore(ctx) + .loadEnrollment() + .then((enrollment) => { + if (enrollment) return contendForHost(); + }) + .catch((error: unknown) => { + log.error(`[remote-host] could not read the enrollment: ${String(error)}`); + }); + + return { + dispose() { + service?.dispose(); + service = null; + directoryWatchers.clear(); + context = null; + }, + }; +} diff --git a/vscode-ext/src/watch-dir-file.ts b/vscode-ext/src/watch-dir-file.ts deleted file mode 100644 index 62e8eab6..00000000 --- a/vscode-ext/src/watch-dir-file.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Watch one file in a directory, or do without. - * - * Two things in this extension want the same watch over `globalStorageUri` — - * the Host lease and the peer-link rendezvous — and both want it for the same - * reason: their own timer already converges, and the watcher only makes the - * convergence prompt. That is what makes "no watcher" a complete answer here - * rather than a failure to report. - * - * `fs.watch` can fail twice over. Synchronously, when the platform or - * filesystem cannot watch at all; and asynchronously, with an `'error'` event - * once it is running (an inotify handle the kernel invalidated, the directory - * removed or remounted, watch resources exhausted). The second is the - * dangerous one: an `EventEmitter` rethrows an unheard `'error'`, so a watcher - * nobody listens to takes the whole extension host down — every extension in - * it, not just this one. Both failures land in the same place here. - */ - -import { watch, type FSWatcher } from 'node:fs'; - -/** - * Call `onChange` when `file` changes in `dir`, or return `null` if this - * platform will not watch it. A watcher that fails later closes itself and - * reports through `onUnavailable`, which is where the caller drops its handle; - * it never fires more than once. - */ -export function watchDirFile( - dir: string, - file: string, - onChange: () => void, - onUnavailable: (error: Error) => void, -): FSWatcher | null { - try { - const watcher = watch(dir, (_event, filename) => { - // A rename reports no filename on some platforms; take it rather than - // miss the change. - if (filename && filename !== file) return; - onChange(); - }); - watcher.once('error', (error: Error) => { - watcher.close(); - onUnavailable(error); - }); - return watcher; - } catch { - return null; - } -} diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index ae608932..844c7b3d 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -7,13 +7,6 @@ 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'; -/** - * Remote-server `connect-src` sources, substituted by esbuild at build time - * (`scripts/esbuild.mjs`). Declared rather than imported so the value is a - * literal in the bundle and cannot be changed at runtime. - */ -declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; - function serializeForInlineScript(value: unknown): string { return JSON.stringify(value ?? null) .replace(/ | null; - watcher: FSWatcher | null; - onChange: (held: boolean) => void; - /** A cycle is in flight; overlapping them races their temp files. */ - ticking: boolean; -} - -let state: LeaseState | null = null; -let extensionContext: vscode.ExtensionContext | null = null; - -/** - * Hand the lease its storage location. Deliberately does no I/O: a user who - * never enrolls a Host should never see the file or its timer, so arbitration - * does not begin until {@link ensureWindowLease}. - */ -export function initWindowLease(context: vscode.ExtensionContext): void { - extensionContext = context; -} - -async function readRecord(file: string): Promise { - try { - const parsed: unknown = JSON.parse(await readFile(file, 'utf8')); - return isWindowLeaseRecord(parsed) ? parsed : null; - } catch { - // Missing, truncated mid-write, or corrupt — all mean "no live claim". - return null; - } -} - -/** - * Write via temp + rename so a reader never sees a half-written record. The - * temp name is unique per write, not per window: two overlapping writes sharing - * one name make the second rename fail with ENOENT. - */ -async function writeRecord(current: LeaseState, record: WindowLeaseRecord): Promise { - const temp = `${current.file}.${randomUUID()}.tmp`; - await writeFile(temp, JSON.stringify(record), 'utf8'); - await rename(temp, current.file); -} - -function setHeld(current: LeaseState, held: boolean): void { - if (current.held === held || state !== current) return; - current.held = held; - log.info(`[window-lease] ${held ? 'acquired' : 'released'} the remote-host role`); - current.onChange(held); -} - -async function tick(current: LeaseState): Promise { - // `state !== current` is how a disposed lease stops; a separate flag would be - // a second copy of the same fact. - if (state !== current || current.ticking) return; - current.ticking = true; - try { - const held = await runWindowLeaseCycle( - { - read: () => readRecord(current.file), - write: (record) => writeRecord(current, record), - now: () => Date.now(), - settle: () => new Promise((resolve) => setTimeout(resolve, CLAIM_VERIFY_MS)), - }, - current.selfId, - ); - setHeld(current, held); - } catch (err) { - // A lease we cannot write is a lease we cannot hold; stand down rather than - // run a Host this window may not own. - log.error(`[window-lease] cycle failed: ${String(err)}`); - setHeld(current, false); - } finally { - current.ticking = false; - } -} - -/** - * Start arbitrating, and report every change in this window's ownership. - * Idempotent: repeated calls re-use the running lease and re-announce its - * current state to the new listener. - */ -export function ensureWindowLease(onChange: (held: boolean) => void): void { - if (state) { - onChange(state.held ?? false); - return; - } - const context = extensionContext; - if (!context) return; - - const dir = context.globalStorageUri.fsPath; - const current: LeaseState = { - file: join(dir, LEASE_FILE), - selfId: randomUUID(), - held: null, - timer: null, - watcher: null, - onChange, - ticking: false, - }; - state = current; - - void (async () => { - // VS Code does not create globalStorageUri until something writes to it. - await mkdir(dir, { recursive: true }).catch(() => {}); - if (state !== current) return; - await tick(current); - - current.timer = setInterval(() => void tick(current), LEASE_RENEW_MS); - // The heartbeat alone would make a clean handoff take up to a TTL; the - // watcher turns "the holder released it" into a prompt takeover. Purely an - // accelerator — correctness is the timer's job, which is why no watcher at - // all is an acceptable answer. - const watcher = watchDirFile( - dir, - LEASE_FILE, - () => { - // The holder's own heartbeat lands here too, and re-ticking on it turns - // the heartbeat into a write loop that re-arms itself — ~50x the - // intended I/O, with overlapping writes colliding and each failure - // dropping the role. Only a window waiting for the lease needs the - // accelerator. - if (current.held === true) return; - void tick(current); - }, - (error) => { - log.error(`[window-lease] watcher failed; falling back to polling: ${String(error)}`); - if (current.watcher === watcher) current.watcher = null; - }, - ); - current.watcher = watcher; - })(); - - context.subscriptions.push({ dispose: () => void disposeWindowLease() }); -} - -/** Whether this window currently owns the Host role. */ -export function holdsWindowLease(): boolean { - return state?.held === true; -} - -/** - * Stop arbitrating and, if this window is the owner, hand the role over - * immediately rather than making the next window wait out the TTL. - */ -export async function disposeWindowLease(): Promise { - const current = state; - if (!current) return; - state = null; - if (current.timer) clearInterval(current.timer); - current.watcher?.close(); - - if (current.held !== true) return; - const record = await readRecord(current.file); - if (record?.owner !== current.selfId) return; - await unlink(current.file).catch(() => {}); -} diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 50a36a43..17e5ebf8 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -1,21 +1,25 @@ /** - * The cross-window link, driven end to end: two independent module instances - * standing in for two VS Code windows, talking over a real socket in a temp - * directory. The frames and the routing table are unit-tested in + * 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 * `lib/src/lib/vscode-peer-link-protocol.test.ts`; this covers the parts that - * only exist once there is a socket — the rendezvous handshake, role switching, - * PTY routing, and what happens when a window goes away. + * 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 } from 'vitest'; -import { readdir } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, readFile } from 'node:fs/promises'; +import { createConnection, createServer } from 'node:net'; import { join } from 'node:path'; -import { createServer } from 'node:net'; import { fakeContext, 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[] = []; /** Records what a window was asked to do on its own terminals. */ @@ -30,9 +34,7 @@ function fakeWindow(options: { surfaces: options.surfaces ?? {}, writes: [] as Array<{ ptyId: string; data: string }>, resizes: [] as Array<{ ptyId: string; cols: number; rows: number }>, - delivered: [] as Array<{ ptyId: string; data: string }>, - exits: [] as Array<{ ptyId: string; exitCode: number }>, - peerChanges: [] as Array, + invalidations: 0, emitData(id: string, data: string) { for (const listener of dataListeners) listener(id, data); }, @@ -49,11 +51,9 @@ function fakeWindow(options: { const surface = this.surfaces[surfaceId]; return surface ? [surface] : []; }, - deliverRemotePtyData: (ptyId: string, data: string) => - void this.delivered.push({ ptyId, data }), - deliverRemotePtyExit: (ptyId: string, exitCode: number) => - void this.exits.push({ ptyId, exitCode }), - deliverRemotePeerChange: (topic: string | null) => void this.peerChanges.push(topic), + invalidateDirectory: () => { + this.invalidations += 1; + }, onProcessedPtyData: (listener: (id: string, data: string) => void) => { dataListeners.add(listener); return () => dataListeners.delete(listener); @@ -70,6 +70,16 @@ function fakeWindow(options: { }; } +/** + * The one path every window of an installation contends for, mirroring + * `socketPath()`. Duplicated here on purpose: a derivation that drifted would + * silently give each window its own lease and its own Host. + */ +function derivedSocketPath(): string { + const id = createHash('sha256').update(dir).digest('hex').slice(0, 12); + return join(dir, `dormouse-peer-${id}.sock`); +} + async function openWindow(deps: ReturnType): Promise { const mod = await freshModule(() => import('../src/peer-link')); mod.initPeerLink(fakeContext(dir)); @@ -78,8 +88,19 @@ async function openWindow(deps: ReturnType): Promise join(dir, 'remote-host.peer.json'); -const waitForRendezvous = () => waitForFile(rendezvousFile()); +/** A sink standing in for whatever the broker streams a routed PTY into. */ +function fakeSink() { + return { + data: [] as string[], + exits: [] as number[], + onData(chunk: string) { + this.data.push(chunk); + }, + onExit(code: number) { + this.exits.push(code); + }, + }; +} /** Attach to the terminal {@link farWindow} owns, which is what places its route. */ const attachFar = (broker: LinkModule) => @@ -93,7 +114,7 @@ const farWindow = () => }); /** - * Start a broker and a peer, and wait until they can actually talk. The peer + * 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. */ @@ -101,28 +122,41 @@ async function linkedPair( brokerSide = fakeWindow(), peerSide = fakeWindow({ entries: [{ surfaceId: 'far-default' }] }), ) { + const brokerRoles: boolean[] = []; const broker = await openWindow(brokerSide); - broker.setPeerLinkRole(true); - await waitForRendezvous(); + await broker.ensurePeerNet((held) => brokerRoles.push(held)); + expect(brokerRoles).toEqual([true]); + const peerRoles: boolean[] = []; const peer = await openWindow(peerSide); - peer.setPeerLinkRole(false); - // The handshake is asynchronous; the first answered request proves it landed. + 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 }; + return { broker, brokerSide, peer, peerSide, peerRoles }; } beforeEach(async () => { dir = await tempStorageDir(); + realTmp = process.env.TMPDIR; + process.env.TMPDIR = dir; }); afterEach(async () => { - for (const mod of opened) await mod.disposePeerLink(); + // 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('peer link between windows', () => { +describe('bind-as-lease', () => { it('rejects when the peer socket cannot be bound', async () => { const mod = await openWindow(fakeWindow()); const failingServer = createServer(); @@ -131,6 +165,42 @@ describe('peer link between windows', () => { .rejects.toHaveProperty('code'); }); + 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(); + // 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('collects directory entries from the other window', async () => { const peerSide = fakeWindow({ entries: [{ surfaceId: 'far-1' }, { surfaceId: 'far-2' }] }); const { broker } = await linkedPair(fakeWindow(), peerSide); @@ -141,20 +211,18 @@ describe('peer link between windows', () => { ]); }); - it('forwards peer change notifications to the broker window', async () => { + it('invalidates the broker directory when a peer announces a change', async () => { const { brokerSide, peer } = await linkedPair(); - brokerSide.peerChanges.length = 0; + const before = brokerSide.invalidations; peer.remoteNotifyPeerChange('directory'); - await waitFor(() => brokerSide.peerChanges.length > 0); - expect(brokerSide.peerChanges).toEqual(['directory']); + await waitFor(() => brokerSide.invalidations > before); }); it('returns nothing when no other window is connected', async () => { const broker = await openWindow(fakeWindow()); - broker.setPeerLinkRole(true); - await waitForRendezvous(); + await broker.ensurePeerNet(() => {}); expect(await broker.remoteRequest('directory', {})).toEqual([]); }); @@ -184,46 +252,52 @@ describe('peer link between windows', () => { it('streams a subscribed PTY from the owning window', async () => { const peerSide = farWindow(); - const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + const { broker } = await linkedPair(fakeWindow(), peerSide); await attachFar(broker); - broker.remoteSubscribe('pty-far'); + const sink = fakeSink(); + broker.remoteSubscribe('pty-far', sink); await tick(); peerSide.emitData('pty-far', 'output from the other window'); - await waitFor(() => brokerSide.delivered.length > 0); - expect(brokerSide.delivered).toEqual([{ ptyId: 'pty-far', data: '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, brokerSide } = await linkedPair(fakeWindow(), peerSide); + const { broker } = await linkedPair(fakeWindow(), peerSide); await attachFar(broker); + const sink = fakeSink(); + broker.remoteSubscribe('pty-far', sink); + await tick(); peerSide.emitData('pty-other', 'not subscribed'); await tick(100); - expect(brokerSide.delivered).toEqual([]); + expect(sink.data).toEqual([]); }); it('forwards a subscribed PTY exit and forgets its route', async () => { const peerSide = farWindow(); - const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + const { broker } = await linkedPair(fakeWindow(), peerSide); await attachFar(broker); - broker.remoteSubscribe('pty-far'); + const sink = fakeSink(); + broker.remoteSubscribe('pty-far', sink); await tick(); peerSide.emitExit('pty-far', 17); - await waitFor(() => brokerSide.exits.length > 0); - expect(brokerSide.exits).toEqual([{ ptyId: 'pty-far', exitCode: 17 }]); + await waitFor(() => sink.exits.length > 0); + expect(sink.exits).toEqual([17]); expect(broker.isRemotePty('pty-far')).toBe(false); }); it('stops the stream on unsubscribe', async () => { const peerSide = farWindow(); - const { broker, brokerSide } = await linkedPair(fakeWindow(), peerSide); + const { broker } = await linkedPair(fakeWindow(), peerSide); await attachFar(broker); - broker.remoteSubscribe('pty-far'); + const sink = fakeSink(); + broker.remoteSubscribe('pty-far', sink); await tick(); broker.remoteUnsubscribe('pty-far'); @@ -231,7 +305,7 @@ describe('peer link between windows', () => { peerSide.emitData('pty-far', 'after unsubscribe'); await tick(100); - expect(brokerSide.delivered).toEqual([]); + expect(sink.data).toEqual([]); // Unsubscribing also forgets the route, so a later write is not misrouted. expect(broker.isRemotePty('pty-far')).toBe(false); }); @@ -258,55 +332,44 @@ describe('peer link between windows', () => { it('reports terminals as exited when their window disconnects', async () => { const peerSide = farWindow(); - const { broker, brokerSide, peer } = await linkedPair(fakeWindow(), peerSide); + const { broker, peer } = await linkedPair(fakeWindow(), peerSide); await attachFar(broker); - expect(broker.isRemotePty('pty-far')).toBe(true); + const sink = fakeSink(); + broker.remoteSubscribe('pty-far', 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(() => brokerSide.exits.length > 0); - expect(brokerSide.exits).toEqual([{ ptyId: 'pty-far', exitCode: 0 }]); + await waitFor(() => sink.exits.length > 0); + expect(sink.exits).toEqual([0]); expect(broker.isRemotePty('pty-far')).toBe(false); expect(broker.remoteWrite('pty-far', 'x')).toBe(false); }); - it('leaves nothing behind when the lease flips back mid-startup', async () => { - // Peer sockets live in the temp dir; point that at this test's own storage - // dir so a server nobody closed is as visible as a file nobody removed. - const realTmp = process.env.TMPDIR; - process.env.TMPDIR = dir; - try { - const mod = await openWindow(fakeWindow()); - - // Both calls in one tick, so the flip back lands inside startup's awaits. - mod.setPeerLinkRole(true); - mod.setPeerLinkRole(false); - await tick(); - - // No rendezvous (peers would dial a socket the teardown already unlinked - // and back off until some later broker rewrote the file), no listening - // socket, and no temp file from the write that was abandoned. - expect(await readdir(dir)).toEqual([]); - } finally { - // 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; - } + 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('rejects a client that does not know the token', async () => { const brokerSide = fakeWindow(); const broker = await openWindow(brokerSide); - broker.setPeerLinkRole(true); - await waitForRendezvous(); + await broker.ensurePeerNet(() => {}); + + // The socket path is derived from the storage location, so it is guessable; + // the token in the 0600 file beside it is the only secret. + expect((await readFile(join(dir, 'remote-host.peer-token'), 'utf8')).trim()).toBeTruthy(); - const { readFile } = await import('node:fs/promises'); - const { socketPath } = JSON.parse(await readFile(rendezvousFile(), 'utf8')); - const { createConnection } = await import('node:net'); - const socket = createConnection({ path: socketPath }); + const socket = createConnection({ path: derivedSocketPath() }); await new Promise((resolve) => socket.on('connect', resolve)); socket.write(`${JSON.stringify({ kind: 'hello', token: 'wrong' })}\n`); diff --git a/vscode-ext/test/pty-subscriptions.test.ts b/vscode-ext/test/pty-subscriptions.test.ts deleted file mode 100644 index e0fbb87a..00000000 --- a/vscode-ext/test/pty-subscriptions.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PtySubscriptions } from '../src/pty-subscriptions'; - -describe('PtySubscriptions', () => { - it('keeps the stream until the final viewer unsubscribes', () => { - const subscriptions = new PtySubscriptions(); - - expect(subscriptions.subscribe('pty-1')).toBe(true); - expect(subscriptions.subscribe('pty-1')).toBe(false); - expect(subscriptions.has('pty-1')).toBe(true); - - expect(subscriptions.unsubscribe('pty-1')).toBe(false); - expect(subscriptions.has('pty-1')).toBe(true); - - expect(subscriptions.unsubscribe('pty-1')).toBe(true); - expect(subscriptions.has('pty-1')).toBe(false); - }); - - it('ignores an unmatched unsubscribe', () => { - const subscriptions = new PtySubscriptions(); - expect(subscriptions.unsubscribe('pty-missing')).toBe(false); - }); - - it('releases each unique stream once on router disposal', () => { - const subscriptions = new PtySubscriptions(); - subscriptions.subscribe('pty-1'); - subscriptions.subscribe('pty-1'); - subscriptions.subscribe('pty-2'); - const released: string[] = []; - - subscriptions.releaseAll((ptyId) => released.push(ptyId)); - - expect(released.sort()).toEqual(['pty-1', 'pty-2']); - expect(subscriptions.has('pty-1')).toBe(false); - expect(subscriptions.has('pty-2')).toBe(false); - }); -}); diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts new file mode 100644 index 00000000..68901f76 --- /dev/null +++ b/vscode-ext/test/remote-host.test.ts @@ -0,0 +1,342 @@ +/** + * 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 { createHash } from 'node:crypto'; +import { join } from 'node:path'; + +import type { ExtensionMessage } from '../src/message-types'; +import { removeDir, tempStorageDir, waitFor } from './helpers'; + +type HostModule = typeof import('../src/remote-host'); +type LinkModule = typeof import('../src/peer-link'); + +let dir: string; +let realTmp: string | undefined; +let opened: LinkModule | null = null; +let squatter: Server | null = null; +const squatted: Socket[] = []; + +/** Mirrors `socketPath()` — see the note in peer-link.test.ts. */ +function derivedSocketPath(): string { + const id = createHash('sha256').update(dir).digest('hex').slice(0, 12); + return join(dir, `dormouse-peer-${id}.sock`); +} + +/** The slice of `ExtensionContext` the store reads, in memory. */ +function fakeContext() { + const secrets = new Map(); + const global = new Map(); + return { + store: { secrets, global }, + context: { + globalStorageUri: { fsPath: dir }, + subscriptions: [] as unknown[], + secrets: { + get: async (key: string) => secrets.get(key), + store: async (key: string, value: string) => void secrets.set(key, value), + delete: async (key: string) => void secrets.delete(key), + }, + globalState: { + get: (key: string) => global.get(key), + update: async (key: string, value: unknown) => { + if (value === undefined) global.delete(key); + else global.set(key, value as string); + }, + 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>(); + return { + posted, + asked, + emitData: (id: string, data: string) => { + for (const listener of dataListeners) listener(id, data); + }, + emitExit: (id: string, exitCode: number) => { + 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: () => {}, + onProcessedPtyData: (listener) => { + dataListeners.add(listener); + return () => dataListeners.delete(listener); + }, + onProcessedPtyExit: (listener) => { + exitListeners.add(listener); + return () => exitListeners.delete(listener); + }, + }; + }, + }; +} + +/** 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); + return mod; +} + +/** Occupy the socket, so the module under test can only ever be a client. */ +async function otherWindowHoldsTheHost(): Promise { + // 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) => void squatted.push(socket)); + await new Promise((resolve) => server.listen(derivedSocketPath(), resolve)); + squatter = server; +} + +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 () => { + await opened?.disposePeerLink(); + 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; + 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('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([]); + }); +}); + +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('refuses a command while another window holds the Host', async () => { + await otherWindowHoldsTheHost(); + const mod = await freshHost(); + const bound = fakeDeps(); + mod.configureRemoteHost(bound.deps()); + mod.initRemoteHost(fakeContext().context); + + mod.handleRemoteHostCommand({ rhId: 'rh-1', cmd: 'status' }); + expect(results(bound.posted)).toEqual([ + { rhId: 'rh-1', error: 'the remote Host runs in another VS Code window' }, + ]); + + // Even `enroll`, once the contention has answered: the bootstrap exception + // is about there being no Host anywhere, not about outranking one. + mod.handleRemoteHostCommand({ + rhId: 'rh-2', + cmd: 'enroll', + params: { serverUrl: 'https://relay.dormouse.sh', password: 'p', label: 'Laptop' }, + }); + await waitFor(() => results(bound.posted).length > 1); + expect(results(bound.posted)[1]).toEqual({ + rhId: 'rh-2', + error: 'the remote Host runs in another VS Code window', + }); + expect(opened!.isPeerBroker()).toBe(false); + }); + + 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([]); + }); +}); + +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 stop = provider.streamPty('pty-1', { + onData: (data) => void seen.push(data), + onExit: (code) => void exits.push(code), + }); + + 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]); + + stop(); + bound.emitData('pty-1', 'after'); + expect(seen).toHaveLength(1); + }); + + 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('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); + }); +}); diff --git a/vscode-ext/test/watch-dir-file.test.ts b/vscode-ext/test/watch-dir-file.test.ts deleted file mode 100644 index c9f44444..00000000 --- a/vscode-ext/test/watch-dir-file.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * The one thing this helper exists for is failing safely: `fs.watch` can refuse - * up front or die later, and a later death that nobody listens for is rethrown - * and takes the extension host down. Both callers treat their watcher as an - * accelerator over a timer, so both failures have to end as "no watcher". - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { join } from 'node:path'; -import { removeDir, tempStorageDir } from './helpers'; -import { watchDirFile } from '../src/watch-dir-file'; - -let dir: string; - -beforeEach(async () => { - dir = await tempStorageDir(); -}); - -afterEach(async () => { - await removeDir(dir); -}); - -describe('watchDirFile', () => { - it('reports nothing to watch instead of throwing', () => { - expect(watchDirFile(join(dir, 'missing'), 'file.json', () => {}, () => {})).toBe(null); - }); - - it('closes an asynchronously failing watcher and reports it once', () => { - const errors: Error[] = []; - const watcher = watchDirFile(dir, 'file.json', () => {}, (error) => errors.push(error)); - expect(watcher).not.toBe(null); - const close = vi.spyOn(watcher!, 'close'); - - // What the kernel does when it invalidates an inotify handle. Unheard, this - // is an uncaught exception in the extension host. - const failure = new Error('watch resources exhausted'); - watcher!.emit('error', failure); - - expect(close).toHaveBeenCalledOnce(); - expect(errors).toEqual([failure]); - // And the hazard itself, for the record: with the closed watcher's listener - // spent, a further error is rethrown — an uncaught exception in the - // extension host, which is why nothing may watch without this. - expect(() => watcher!.emit('error', new Error('unheard'))).toThrow('unheard'); - }); -}); diff --git a/vscode-ext/test/window-lease.test.ts b/vscode-ext/test/window-lease.test.ts deleted file mode 100644 index 65e3eedb..00000000 --- a/vscode-ext/test/window-lease.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * The lease's filesystem half. The rules are unit-tested in - * `lib/src/lib/vscode-window-lease.test.ts`; this drives two independent module - * instances — standing in for two VS Code windows — against a real directory. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { fakeContext, freshModule, removeDir, tempStorageDir, waitFor } from './helpers'; - -type LeaseModule = typeof import('../src/window-lease'); - -let dir: string; -const opened: LeaseModule[] = []; - -/** A separate module instance, so each behaves like its own extension host. */ -async function openWindow(): Promise { - const mod = await freshModule(() => import('../src/window-lease')); - mod.initWindowLease(fakeContext(dir)); - opened.push(mod); - return mod; -} - -beforeEach(async () => { - dir = await tempStorageDir(); -}); - -afterEach(async () => { - for (const mod of opened) await mod.disposeWindowLease(); - opened.length = 0; - await removeDir(dir); -}); - -describe('window lease over a real directory', () => { - it('acquires when nothing holds it, and records an owner', async () => { - const window = await openWindow(); - window.ensureWindowLease(() => {}); - - await waitFor(() => window.holdsWindowLease()); - const record = JSON.parse(await readFile(join(dir, 'remote-host.lease.json'), 'utf8')); - expect(typeof record.owner).toBe('string'); - expect(record.heartbeatAt).toBeGreaterThan(0); - }); - - it('grants the role to exactly one of two windows', async () => { - const first = await openWindow(); - first.ensureWindowLease(() => {}); - await waitFor(() => first.holdsWindowLease()); - - const second = await openWindow(); - second.ensureWindowLease(() => {}); - // Long enough for a claim-and-verify cycle to have run and lost. - await new Promise((resolve) => setTimeout(resolve, 500)); - - expect(first.holdsWindowLease()).toBe(true); - expect(second.holdsWindowLease()).toBe(false); - }); - - it('reports an initial non-holder result', async () => { - const first = await openWindow(); - first.ensureWindowLease(() => {}); - await waitFor(() => first.holdsWindowLease()); - - const second = await openWindow(); - const changes: boolean[] = []; - second.ensureWindowLease((held) => changes.push(held)); - await waitFor(() => changes.length > 0); - - expect(changes).toEqual([false]); - expect(second.holdsWindowLease()).toBe(false); - }); - - it('hands the role over when the holder disposes', async () => { - const first = await openWindow(); - first.ensureWindowLease(() => {}); - await waitFor(() => first.holdsWindowLease()); - - const second = await openWindow(); - const changes: boolean[] = []; - second.ensureWindowLease((held) => changes.push(held)); - await new Promise((resolve) => setTimeout(resolve, 300)); - expect(second.holdsWindowLease()).toBe(false); - - // Closing the holder must not leave the role stranded until the TTL. - await first.disposeWindowLease(); - await waitFor(() => second.holdsWindowLease()); - expect(changes).toContain(true); - }); - - it('reports the role change to its listener exactly once per transition', async () => { - const window = await openWindow(); - const changes: boolean[] = []; - window.ensureWindowLease((held) => changes.push(held)); - await waitFor(() => window.holdsWindowLease()); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(changes).toEqual([true]); - }); - - it('re-announces the current role to a second caller without restarting', async () => { - const window = await openWindow(); - window.ensureWindowLease(() => {}); - await waitFor(() => window.holdsWindowLease()); - - const seen: boolean[] = []; - window.ensureWindowLease((held) => seen.push(held)); - expect(seen).toEqual([true]); - }); - - it('does not let its own heartbeat re-trigger itself', async () => { - const window = await openWindow(); - window.ensureWindowLease(() => {}); - await waitFor(() => window.holdsWindowLease()); - - // The directory watcher sees the holder's own rename. Re-ticking on that - // turns the 5s heartbeat into a write loop that re-arms itself, and the - // colliding writes drop the role on each failure. - const stamps = new Set(); - for (let i = 0; i < 15; i++) { - const record = JSON.parse(await readFile(join(dir, 'remote-host.lease.json'), 'utf8')); - stamps.add(record.heartbeatAt); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - // 1.5s at a 5s renew is one write, maybe two across a boundary. - expect(stamps.size).toBeLessThanOrEqual(2); - expect(window.holdsWindowLease()).toBe(true); - }); - - it('does nothing before it is told where to store the record', async () => { - const mod = await freshModule(() => import('../src/window-lease')); - opened.push(mod); - mod.ensureWindowLease(() => {}); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(mod.holdsWindowLease()).toBe(false); - }); -}); From 5e1679f76991d292410a889345626d920e327152 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 19:16:00 -0700 Subject: [PATCH 30/56] Serve every window's terminals through the broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second tier goes live: the broker's provider asks its own webviews and every peer window at once (what is asked about lives in exactly one webview of one window, and asking in series would spend a whole tier's budget before reaching the owner), routed PTYs stream over the link already stripped by their owning window, and writes and resizes route by the table with the local manager as the fallback. Any window's webviews can drive the Host now. Three link frames carry it: a losing window forwards a webview command to the broker, the service's answer returns to that window alone (routed by the command's rhId, which is already globally unique), and service events — the pairing queue — broadcast to every window, so the approval modal appears wherever the user is looking. Cross-window streams are reference-counted per PTY: two attachments to the same foreign surface share one stream, only zero-to-one starts the owner forwarding, and one viewer detaching cannot silence the other — the lesson the old design's PtySubscriptions encoded, kept here in the one place it still applies. Co-Authored-By: Claude Fable 5 --- lib/src/lib/platform/vscode-adapter.test.ts | 4 +- lib/src/lib/vscode-peer-link-protocol.test.ts | 26 ++ lib/src/lib/vscode-peer-link-protocol.ts | 26 +- vscode-ext/src/message-router.ts | 16 +- vscode-ext/src/peer-link.ts | 132 ++++++++-- vscode-ext/src/remote-host.ts | 184 +++++++++++--- vscode-ext/test/helpers.ts | 84 +++++++ vscode-ext/test/peer-link.test.ts | 167 ++++++++----- vscode-ext/test/remote-host.test.ts | 226 ++++++++++++++++-- 9 files changed, 729 insertions(+), 136 deletions(-) diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 8b38a6f4..7623ebd5 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -436,9 +436,9 @@ describe('VSCodeAdapter remote host link', () => { const pending = adapter.remoteHost.command('enroll', { serverUrl: 'https://nope' }); deliver({ type: 'remoteHost:result', - payload: { rhId: sent()[0]!.rhId, error: 'the remote Host runs in another VS Code window' }, + payload: { rhId: sent()[0]!.rhId, error: 'no remote Host is reachable' }, }); - await expect(pending).rejects.toThrow('another VS Code window'); + await expect(pending).rejects.toThrow('no remote Host is reachable'); }); it('rejects when the extension host never answers', async () => { diff --git a/lib/src/lib/vscode-peer-link-protocol.test.ts b/lib/src/lib/vscode-peer-link-protocol.test.ts index 22745ec2..6a02369f 100644 --- a/lib/src/lib/vscode-peer-link-protocol.test.ts +++ b/lib/src/lib/vscode-peer-link-protocol.test.ts @@ -47,6 +47,32 @@ describe('FrameDecoder', () => { expect(decoder.push('\n\n')).toEqual([]); }); + 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 a peer that never terminates a frame', () => { const decoder = new FrameDecoder(64); expect(decoder.push('x'.repeat(100))).toEqual([]); diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index ec580069..a1ef7bb3 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -14,6 +14,8 @@ * that vanishes mid-attach) are testable without spawning processes. */ +import type { RemoteHostCommand, RemoteHostResult } from '../host/remote/service-protocol'; + /** How long the broker waits for a window to answer before giving up on it. */ export const PEER_REPLY_BUDGET_MS = 1_000; @@ -31,7 +33,21 @@ export type PeerLinkRequest = | { kind: 'subscribe'; id: string; ptyId: string } | { kind: 'unsubscribe'; id: string; ptyId: string } | { kind: 'write'; id: string; ptyId: string; data: string } - | { kind: 'resizePty'; id: string; ptyId: string; cols: number; rows: number }; + | { kind: 'resizePty'; id: string; 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 = @@ -46,7 +62,13 @@ export type PeerLinkResponse = /** Unsolicited: that PTY ended. */ | { kind: 'exit'; ptyId: string; exitCode: number } /** Unsolicited: future peer-query answers for this topic may differ. */ - | { kind: 'notify'; topic: string | null }; + | { kind: 'notify'; topic: string | null } + /** + * 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; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index a51fa1fc..10beae78 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -25,6 +25,10 @@ import { PEER_REPLY_BUDGET_MS } from '../../lib/src/lib/vscode-peer-link-protoco import { configurePeerLink, remoteNotifyPeerChange } from './peer-link'; import { configureRemoteHost, + deliverCommandResult, + deliverUiEvent, + dropForwardedCommands, + handleForwardedCommand, handleRemoteHostCommand, notifyDirectoryChanged, } from './remote-host'; @@ -68,6 +72,12 @@ configurePeerLink({ onProcessedPtyExit, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), + // 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, }); configureRemoteHost({ @@ -93,9 +103,9 @@ configureRemoteHost({ * 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. The asker is this window's own Host service, and — after phase 3b — - * a peer window's broker over the link, never a webview; that is why it is a - * plain promise rather than message plumbing. + * picker. 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]; diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index e31ec48a..cca895c0 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -17,6 +17,12 @@ * 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 socket is a user-owned unix socket (or named pipe) and a client * must open with a token from a mode-0600 file in the extension's * `globalStorageUri` — the same bar as the `dor` control socket. @@ -30,6 +36,10 @@ 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_REPLY_BUDGET_MS, @@ -45,7 +55,8 @@ 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. + * 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. */ @@ -56,6 +67,18 @@ export interface PeerLinkDeps { onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => 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; } let deps: PeerLinkDeps | null = null; @@ -140,7 +163,13 @@ async function ensureToken(): Promise { // ---------------------------------------------------------------- server side -interface PeerClient { +/** + * 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; @@ -155,19 +184,24 @@ export interface RemotePtySink { let server: Server | null = null; /** Claimed and cleared with `server`; the two always move together. */ let serverToken: string | null = null; -const clients = new Set(); -const routes = new Map(); -const remoteSinks = new Map(); +const clients = new Set(); +const routes = new Map(); +const remoteSinks = new Map>(); const pendingRequests = new Map void>(); let nextRequestId = 0; -function send(client: PeerClient, frame: PeerLinkRequest): void { +function send(client: PeerLinkClient, frame: PeerLinkRequest): 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: PeerClient, frame: PeerLinkRequest): Promise { +function ask( + client: PeerLinkClient, + // Only the correlated frames can be awaited; `commandResult` and `uiEvent` + // carry no frame id because nothing waits on them here. + frame: Extract, +): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { pendingRequests.delete(frame.id); @@ -182,7 +216,7 @@ function ask(client: PeerClient, frame: PeerLinkRequest): Promise client.authenticated); } @@ -199,9 +233,6 @@ function authenticatedClients(): PeerClient[] { * {@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. - * - * Nothing calls this yet — the broker serves only its own window's surfaces - * until phase 3b wires the second tier into the service's provider. */ export async function remoteRequest(op: string, params: unknown): Promise { const peers = authenticatedClients(); @@ -232,11 +263,24 @@ export function isRemotePty(ptyId: string): boolean { export function remoteSubscribe(ptyId: string, sink: RemotePtySink): void { const client = routes.get(ptyId); if (!client) return; - remoteSinks.set(ptyId, sink); - send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); + // 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); + send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); + } + sinks.add(sink); } -export function remoteUnsubscribe(ptyId: string): void { +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 and drop the route — a later + // attach re-places it from the owner's answer. remoteSinks.delete(ptyId); const client = routes.get(ptyId); if (!client) return; @@ -258,19 +302,41 @@ export function remoteResize(ptyId: string, cols: number, rows: number): boolean return true; } -function dropClient(client: PeerClient): void { +/** + * 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()) send(peer, { kind: 'uiEvent', payload }); +} + +function dropClient(client: PeerLinkClient): void { 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 ptyId of forgetPeerRoutes(routes, client)) { - remoteSinks.get(ptyId)?.onExit(0); + for (const sink of remoteSinks.get(ptyId) ?? []) sink.onExit(0); remoteSinks.delete(ptyId); } + // 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: PeerClient, frame: unknown): void { +function onServerFrame(client: PeerLinkClient, frame: unknown): void { const message = frame as (PeerLinkResponse | { kind: 'hello'; token: string }) & { kind: string; }; @@ -291,12 +357,12 @@ function onServerFrame(client: PeerClient, frame: unknown): void { const response = message as PeerLinkResponse; if (response.kind === 'data') { - remoteSinks.get(response.ptyId)?.onData(response.data); + for (const sink of remoteSinks.get(response.ptyId) ?? []) sink.onData(response.data); return; } if (response.kind === 'exit') { routes.delete(response.ptyId); - remoteSinks.get(response.ptyId)?.onExit(response.exitCode); + for (const sink of [...(remoteSinks.get(response.ptyId) ?? [])]) sink.onExit(response.exitCode); remoteSinks.delete(response.ptyId); return; } @@ -304,6 +370,12 @@ function onServerFrame(client: PeerClient, frame: unknown): void { 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) pendingRequests.get(response.id)?.(response); } @@ -327,7 +399,7 @@ export function listenServer(nextServer: Server, path: string): Promise { /** 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: PeerClient = { socket, decoder: new FrameDecoder(), authenticated: false }; + const client: PeerLinkClient = { socket, decoder: new FrameDecoder(), authenticated: false }; clients.add(client); socket.setEncoding('utf8'); socket.on('data', (chunk: string) => { @@ -371,6 +443,20 @@ export function remoteNotifyPeerChange(topic: string | null): void { respond({ kind: 'notify', topic }); } +/** + * 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(frame: unknown): Promise { const request = frame as PeerLinkRequest; switch (request.kind) { @@ -410,6 +496,12 @@ async function onClientFrame(frame: unknown): Promise { 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; } } diff --git a/vscode-ext/src/remote-host.ts b/vscode-ext/src/remote-host.ts index 9080d2e6..f9db9c50 100644 --- a/vscode-ext/src/remote-host.ts +++ b/vscode-ext/src/remote-host.ts @@ -8,8 +8,10 @@ * * 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, and answers a losing window's webviews with an - * error rather than a second Host. + * 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 @@ -33,7 +35,19 @@ import type { } from '../../lib/src/remote/host/host-surface-provider'; import type { PeerSurfaceResult } from '../../lib/src/remote/host/peer-surfaces'; import type { ExtensionMessage } from './message-types'; -import { ensurePeerNet } from './peer-link'; +import { + broadcastUiEvent, + ensurePeerNet, + forwardCommand, + isRemotePty, + remoteRequest, + remoteResize, + remoteSubscribe, + remoteUnsubscribe, + remoteWrite, + sendCommandResult, + type PeerLinkClient, +} from './peer-link'; import { VsCodeHostStateStore } from './remote-host-store'; import { log } from './log'; @@ -70,20 +84,44 @@ let context: vscode.ExtensionContext | null = null; let service: RemoteHostService | null = null; const directoryWatchers = new Set<() => void>(); +/** + * Ask both tiers at once and concatenate what they answer, this window's + * webviews first. + * + * 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 a surface id is unique across every window. + */ +async function askBothTiers( + bound: RemoteHostDeps, + op: string, + params: unknown, +): Promise { + 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 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. + * 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 { return { async collectDirectory(): Promise { // Each webview answers with its whole snapshot, so the results *are* the // entries — no per-webview merging to do on this side. - return (await bound.brokerRequest('directory', {})) as DirectoryEntry[]; + return (await askBothTiers(bound, 'directory', {})) as DirectoryEntry[]; }, watchDirectory(onChange) { @@ -96,8 +134,9 @@ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProv 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). - const [owner] = (await bound.brokerRequest('surfaceOp', { + // second one (docs/specs/remote-api.md). One surface has one owner, so the + // first answer out of both tiers is the answer. + const [owner] = (await askBothTiers(bound, 'surfaceOp', { surfaceId, op: 'attach', cols: size.cols, @@ -119,7 +158,7 @@ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProv // what it reported; a resize nobody answered leaves the last known size // standing. resize: async (nextCols, nextRows) => { - const [settled] = (await bound.brokerRequest('surfaceOp', { + const [settled] = (await askBothTiers(bound, 'surfaceOp', { surfaceId, op: 'resize', cols: nextCols, @@ -137,10 +176,24 @@ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProv }; }, - writePty: (ptyId, data) => bound.writePty(ptyId, data), - resizePty: (ptyId, cols, rows) => bound.resizePty(ptyId, cols, rows), + // The link takes only a PTY it has a route for, and a route is placed only + // by an attach another window answered — so a PTY of this window's own can + // never be taken out from under the manager that owns it. + writePty: (ptyId, data) => { + if (!remoteWrite(ptyId, data)) bound.writePty(ptyId, data); + }, + resizePty: (ptyId, cols, rows) => { + if (!remoteResize(ptyId, cols, rows)) bound.resizePty(ptyId, cols, rows); + }, streamPty(ptyId, sink) { + if (isRemotePty(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. + remoteSubscribe(ptyId, sink); + return () => remoteUnsubscribe(ptyId, sink); + } // 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 @@ -171,12 +224,14 @@ function startService(): void { store: new VsCodeHostStateStore(context), provider: createRemoteHostProvider(bound), sendToUi: (event, data) => { - // Broadcast rather than reply to one webview: `rhId`s carry a per-adapter - // tag, so only the webview that asked finds a pending command to settle. if (event === REMOTE_HOST_RESULT_EVENT) { - bound.broadcastToWebviews({ type: 'remoteHost:result', payload: data as RemoteHostResult }); + 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); } }, // The `typeof` guard is for the test runner, which has no esbuild define; @@ -202,35 +257,109 @@ function contendForHost(): Promise { } /** - * Hand one webview command to the Host. + * 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 }); +} + +/** + * 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. Only a window + * with neither — no service and no broker to dial — refuses, and it says so + * rather than dropping the command silently, which would leave the console hook + * hanging for its whole timeout. * - * A window that lost the bind has no service to run it. Until phase 3b forwards - * it over the link, say so rather than answering from a Host that is not there - * — a silent drop would leave the console hook hanging for its whole timeout. * `enroll` is the exception: it is how an installation with no Host at all - * bootstraps, so it starts the contention first and re-checks. + * bootstraps, so it starts the contention first and re-checks. If that + * contention settles as a client, some other window enrolled first and the + * command belongs to it. */ export function handleRemoteHostCommand(payload: RemoteHostCommand | undefined): void { - if (!payload || typeof payload.rhId !== 'string' || typeof payload.cmd !== 'string') return; + if (!isCommand(payload)) return; if (service) { void service.handleCommand(payload); return; } + if (forwardCommand(payload)) return; if (payload.cmd === 'enroll') { void contendForHost().then(() => { if (service) void service.handleCommand(payload); - else refuse(payload.rhId); + else if (!forwardCommand(payload)) refuse(payload.rhId); }); return; } refuse(payload.rhId); } +/** + * 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 (!isCommand(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 }); +} + +function isCommand(payload: RemoteHostCommand | undefined): payload is RemoteHostCommand { + return !!payload && typeof payload.rhId === 'string' && typeof payload.cmd === 'string'; +} + function refuse(rhId: string): void { - deps?.broadcastToWebviews({ - type: 'remoteHost:result', - payload: { rhId, error: 'the remote Host runs in another VS Code window' }, - }); + deps?.broadcastToWebviews({ type: 'remoteHost:result', payload: { rhId, error: NO_HOST } }); } /** @@ -253,6 +382,7 @@ export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable service?.dispose(); service = null; directoryWatchers.clear(); + commandRoutes.clear(); context = null; }, }; diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index c74cd6f8..a8d3272c 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -9,6 +9,12 @@ 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'; + export async function tempStorageDir(): Promise { return mkdtemp(join(tmpdir(), 'dormouse-ext-')); } @@ -59,3 +65,81 @@ export async function freshModule(loader: () => Promise): Promise { 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; + } = {}, +) { + const dataListeners = new Set<(id: string, data: string) => void>(); + const exitListeners = new Set<(id: string, exitCode: number) => void>(); + return { + entries: options.entries ?? [], + surfaces: options.surfaces ?? {}, + 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[], + emitData(id: string, data: string) { + for (const listener of dataListeners) listener(id, data); + }, + emitExit(id: string, exitCode: number) { + 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; + }, + onProcessedPtyData: (listener) => { + dataListeners.add(listener); + return () => dataListeners.delete(listener); + }, + onProcessedPtyExit: (listener) => { + exitListeners.add(listener); + return () => exitListeners.delete(listener); + }, + 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), + }; + }, + }; +} + +/** 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/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 17e5ebf8..92f7ced3 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -13,7 +13,17 @@ import { createHash } from 'node:crypto'; import { access, readFile } from 'node:fs/promises'; import { createConnection, createServer } from 'node:net'; import { join } from 'node:path'; -import { fakeContext, freshModule, removeDir, tempStorageDir, tick, waitFor, waitForFile } from './helpers'; +import { + fakeContext, + fakeSink, + fakeWindow, + freshModule, + removeDir, + tempStorageDir, + tick, + waitFor, + waitForFile, +} from './helpers'; type LinkModule = typeof import('../src/peer-link'); @@ -22,54 +32,6 @@ let dir: string; let realTmp: string | undefined; const opened: LinkModule[] = []; -/** Records what a window was asked to do on its own terminals. */ -function fakeWindow(options: { - entries?: unknown[]; - surfaces?: Record; -} = {}) { - const dataListeners = new Set<(id: string, data: string) => void>(); - const exitListeners = new Set<(id: string, exitCode: number) => void>(); - return { - entries: options.entries ?? [], - surfaces: options.surfaces ?? {}, - writes: [] as Array<{ ptyId: string; data: string }>, - resizes: [] as Array<{ ptyId: string; cols: number; rows: number }>, - invalidations: 0, - emitData(id: string, data: string) { - for (const listener of dataListeners) listener(id, data); - }, - emitExit(id: string, exitCode: number) { - for (const listener of exitListeners) listener(id, exitCode); - }, - deps() { - 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: string, params: unknown) => { - if (op === 'directory') return this.entries; - const { surfaceId } = params as { surfaceId: string }; - const surface = this.surfaces[surfaceId]; - return surface ? [surface] : []; - }, - invalidateDirectory: () => { - this.invalidations += 1; - }, - onProcessedPtyData: (listener: (id: string, data: string) => void) => { - dataListeners.add(listener); - return () => dataListeners.delete(listener); - }, - onProcessedPtyExit: (listener: (id: string, exitCode: number) => void) => { - exitListeners.add(listener); - return () => exitListeners.delete(listener); - }, - writePty: (ptyId: string, data: string) => void this.writes.push({ ptyId, data }), - resizePty: (ptyId: string, cols: number, rows: number) => - void this.resizes.push({ ptyId, cols, rows }), - }; - }, - }; -} - /** * The one path every window of an installation contends for, mirroring * `socketPath()`. Duplicated here on purpose: a derivation that drifted would @@ -88,20 +50,6 @@ async function openWindow(deps: ReturnType): Promise broker.remoteRequest('surfaceOp', { surfaceId: 'far-1', op: 'attach', cols: 80, rows: 24 }); @@ -300,7 +248,7 @@ describe('bind-as-lease', () => { broker.remoteSubscribe('pty-far', sink); await tick(); - broker.remoteUnsubscribe('pty-far'); + broker.remoteUnsubscribe('pty-far', sink); await tick(); peerSide.emitData('pty-far', 'after unsubscribe'); await tick(100); @@ -310,6 +258,27 @@ describe('bind-as-lease', () => { expect(broker.isRemotePty('pty-far')).toBe(false); }); + it('keeps a second viewer streaming when the first detaches', async () => { + const peerSide = farWindow(); + const { broker } = await linkedPair(fakeWindow(), peerSide); + await attachFar(broker); + const first = fakeSink(); + const second = fakeSink(); + broker.remoteSubscribe('pty-far', first); + broker.remoteSubscribe('pty-far', second); + await tick(); + + // One detach must not stop the shared stream or drop the route. + broker.remoteUnsubscribe('pty-far', 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('pty-far')).toBe(true); + }); + it('routes input and resize to the owning window', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); @@ -360,6 +329,76 @@ describe('bind-as-lease', () => { expect(peer.isPeerBroker()).toBe(true); }); + 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('rejects a client that does not know the token', async () => { const brokerSide = fakeWindow(); const broker = await openWindow(brokerSide); diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index 68901f76..39d095db 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -10,14 +10,25 @@ import { createServer, type Server, type Socket } from 'node:net'; import { createHash } from 'node:crypto'; import { join } from 'node:path'; +import { FrameDecoder } from '../../lib/src/lib/vscode-peer-link-protocol'; import type { ExtensionMessage } from '../src/message-types'; -import { removeDir, tempStorageDir, waitFor } from './helpers'; +import { + 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[] = []; @@ -97,16 +108,61 @@ async function freshHost() { 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, + onProcessedPtyData: local.onProcessedPtyData, + onProcessedPtyExit: local.onProcessedPtyExit, + writePty: local.writePty, + resizePty: local.resizePty, + handleForwardedCommand: mod.handleForwardedCommand, + dropForwardedCommands: mod.dropForwardedCommands, + deliverCommandResult: mod.deliverCommandResult, + deliverUiEvent: mod.deliverUiEvent, + }); +} + +/** 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. */ -async function otherWindowHoldsTheHost(): Promise { +async function otherWindowHoldsTheHost(): Promise<{ frames: Array<{ kind: string }> }> { + const frames: Array<{ kind: string }> = []; // 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) => void squatted.push(socket)); + const server = createServer((socket) => { + squatted.push(socket); + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) frames.push(frame as { kind: string }); + }); + }); await new Promise((resolve) => server.listen(derivedSocketPath(), resolve)); squatter = server; + return { frames }; } function results(posted: ExtensionMessage[]) { @@ -122,7 +178,11 @@ beforeEach(async () => { }); afterEach(async () => { - await opened?.disposePeerLink(); + // 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; @@ -220,31 +280,50 @@ describe('remote host service glue', () => { }); }); - it('refuses a command while another window holds the Host', async () => { - await otherWindowHoldsTheHost(); + 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. Refusing beats a silent drop: the console hook would + // otherwise hang for its whole timeout. mod.handleRemoteHostCommand({ rhId: 'rh-1', cmd: 'status' }); expect(results(bound.posted)).toEqual([ - { rhId: 'rh-1', error: 'the remote Host runs in another VS Code window' }, + { rhId: 'rh-1', error: 'no remote Host is reachable' }, ]); - // Even `enroll`, once the contention has answered: the bootstrap exception - // is about there being no Host anywhere, not about outranking one. - mod.handleRemoteHostCommand({ - rhId: 'rh-2', - cmd: 'enroll', - params: { serverUrl: 'https://relay.dormouse.sh', password: 'p', label: 'Laptop' }, - }); - await waitFor(() => results(bound.posted).length > 1); - expect(results(bound.posted)[1]).toEqual({ - rhId: 'rh-2', - error: 'the remote Host runs in another VS Code window', + // `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 () => { @@ -323,6 +402,26 @@ describe('remote host provider', () => { 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(); @@ -340,3 +439,94 @@ describe('remote host provider', () => { 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. + await waitFor(async () => !!(await provider.resolveSurface('far-1', { cols: 80, rows: 24 }))); + + const sink = fakeSink(); + const stop = provider.streamPty('pty-far', sink); + await tick(); + far.emitData('pty-far', 'from the other window'); + await waitFor(() => sink.data.length > 0); + + provider.writePty('pty-far', 'ls\r'); + provider.resizePty('pty-far', 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 }]); + + stop(); + await tick(); + far.emitData('pty-far', 'after the unsubscribe'); + await tick(100); + expect(sink.data).toEqual(['from the other window']); + }); + + 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); + }); +}); From 28dccc25240d0d90833a551d350c338889114830 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 20:17:10 -0700 Subject: [PATCH 31/56] Consolidate the two Host installations onto shared seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A four-angle cleanup pass over the service refactor. The two big extractions: createAskSurfaceProvider owns the ask-backed directory and the attach-is-the-resize handle construction both installations had copied (and where their notify-topic semantics had already drifted — one rule now), and createRemoteHostLinkClient owns the webview command bridge all three adapters had triplicated, including the fix for the one real divergence (the VS Code adapter never rejected pending commands on shutdown; now it disposes). Guards live once: isEnrollment (was four copies) and filterAclRecords (was three, one of which had dropped its object check). The baked connect-src has one declare, one fallback, and one build assertion. Dead weight from the migration is gone: the detach peer op nobody sends, enrollHost, the generation fence whose reset died with the legacy activation path, and four unread exports. The PTY tap iterates sinks without per-chunk copies and strips once per PTY rather than per attachment (a late joiner now inherits parser state instead of starting mid-escape-sequence); VS Code's local streams dispatch by id through one listener pair instead of taxing every chunk of every terminal per attachment. Volunteering is now gated on enrollment: the service announces {status, enrolled} on lifecycle changes, and webviews arm directory notifications and ring watching only while a Host exists to hear them — a machine that never enrolls pays no crossings at all. Answering stays unconditional. The standalone responder installs after platform.init() so the gate's seed cannot race the adapter's listeners. Co-Authored-By: Claude Fable 5 --- lib/src/host/remote/ask-surface-provider.ts | 120 +++++++++++++ lib/src/host/remote/connect-src.ts | 20 +++ lib/src/host/remote/host-state-store.ts | 20 +-- lib/src/host/remote/link-client.test.ts | 172 ++++++++++++++++++ lib/src/host/remote/link-client.ts | 171 ++++++++++++++++++ lib/src/host/remote/service-protocol.ts | 18 +- lib/src/host/remote/service.test.ts | 65 ++++++- lib/src/host/remote/service.ts | 56 +++--- lib/src/host/remote/sidecar-entry.test.ts | 30 +++- lib/src/host/remote/sidecar-entry.ts | 139 ++++----------- lib/src/lib/platform/vscode-adapter.test.ts | 87 ++------- lib/src/lib/platform/vscode-adapter.ts | 135 +++----------- lib/src/lib/push-devices.ts | 14 +- lib/src/lib/vscode-peer-link-protocol.ts | 14 +- lib/src/remote/host/acl.ts | 24 ++- lib/src/remote/host/activation.test.ts | 54 +++++- lib/src/remote/host/activation.ts | 20 ++- lib/src/remote/host/alert-push.test.ts | 28 +-- lib/src/remote/host/alert-push.ts | 29 +-- lib/src/remote/host/enrolled-gate.ts | 56 ++++++ lib/src/remote/host/enrollment.test.ts | 21 ++- lib/src/remote/host/enrollment.ts | 44 ++--- lib/src/remote/host/pairing-approval.ts | 11 +- lib/src/remote/host/peer-surfaces.test.ts | 41 +++-- lib/src/remote/host/peer-surfaces.ts | 48 +++-- lib/src/remote/host/store.ts | 25 ++- scripts/csp-defaults.mjs | 46 ++++- standalone/scripts/build-sidecar-proxy.mjs | 24 +-- standalone/src/browser-sidecar-adapter.ts | 112 ++---------- standalone/src/main.tsx | 7 +- standalone/src/tauri-adapter.test.ts | 54 +----- standalone/src/tauri-adapter.ts | 140 +++------------ vscode-ext/scripts/esbuild.mjs | 38 +--- vscode-ext/src/message-router.ts | 4 +- vscode-ext/src/peer-link.ts | 10 +- vscode-ext/src/remote-host-store.ts | 50 +++--- vscode-ext/src/remote-host.ts | 188 +++++++++----------- 37 files changed, 1175 insertions(+), 960 deletions(-) create mode 100644 lib/src/host/remote/ask-surface-provider.ts create mode 100644 lib/src/host/remote/link-client.test.ts create mode 100644 lib/src/host/remote/link-client.ts create mode 100644 lib/src/remote/host/enrolled-gate.ts 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..3447ea87 --- /dev/null +++ b/lib/src/host/remote/ask-surface-provider.ts @@ -0,0 +1,120 @@ +/** + * 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. + */ +export type SurfaceAsk = (op: string, params: unknown) => Promise; + +export interface AskSurfaceProvider { + provider: HostSurfaceProvider; + /** + * Something a future {@link HostSurfaceProvider.collectDirectory} could depend + * on changed. `topic` is the webview's own word for what changed; anything but + * `directory` is somebody else's business, while no topic at all (a membership + * change, a peer joining) is always ours — the cheap direction is to + * re-collect. + */ + notifyDirectoryChanged(topic?: string | null): 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 — no per-webview merging to do on this side. + return (await ask('directory', {})) as DirectoryEntry[]; + }, + + 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, + })) 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(topic) { + if (topic !== undefined && topic !== null && topic !== 'directory') return; + // 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.ts b/lib/src/host/remote/connect-src.ts index e018069d..f8f65572 100644 --- a/lib/src/host/remote/connect-src.ts +++ b/lib/src/host/remote/connect-src.ts @@ -21,6 +21,26 @@ */ 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'; diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index 521616eb..dc4223ab 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -12,7 +12,8 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { HostAclRecord } from 'server-lib-common'; -import type { HostEnrollment } from '../../remote/host/enrollment'; +import { filterAclRecords } from '../../remote/host/acl'; +import { isEnrollment, type HostEnrollment } from '../../remote/host/enrollment'; // 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. @@ -39,18 +40,6 @@ function emptyState(): HostStateFile { return { version: 1, enrollment: null, acl: {} }; } -function isEnrollment(value: unknown): value is HostEnrollment { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - return ( - typeof v.serverUrl === 'string' && - typeof v.hostId === 'string' && - typeof v.hostToken === 'string' && - typeof v.origin === 'string' && - typeof v.rpId === 'string' - ); -} - function parseState(raw: string): HostStateFile { const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') throw new Error('not an object'); @@ -97,10 +86,7 @@ export class FileHostStateStore implements HostStateStore { } async loadAcl(hostId: string): Promise { - const records = (await this.#read()).acl[hostId] ?? []; - // `HostAcl.fromRecords` rejects a mismatched hostId, so drop foreign rows - // rather than fail the whole load over one. - return records.filter((record) => !!record && record.hostId === hostId); + return filterAclRecords(hostId, (await this.#read()).acl[hostId] ?? []); } async saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { 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..fa370406 --- /dev/null +++ b/lib/src/host/remote/link-client.test.ts @@ -0,0 +1,172 @@ +/** + * 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[] }> = []; + const notified: string[] = []; + return { + sent, + answers, + notified, + client(): RemoteHostLinkClient { + return createRemoteHostLinkClient({ + sendCommand: (command) => void sent.push(command), + answerAsk: (askId, results) => void answers.push({ askId, results }), + notify: (topic) => void notified.push(topic), + }); + }, + }; +} + +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('directory'); + expect(transport.notified).toEqual(['directory']); + 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'); + expect(notifyCommand('directory')).toMatchObject({ + cmd: 'notify', + params: { topic: 'directory' }, + }); + }); +}); diff --git a/lib/src/host/remote/link-client.ts b/lib/src/host/remote/link-client.ts new file mode 100644 index 00000000..784cceca --- /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 for `topic` may differ. */ + notify(topic: string): 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. */ +export function notifyCommand(topic: string): RemoteHostCommand { + return { rhId: `rh-tunnel-${++envelopeSeq}`, cmd: 'notify', params: { topic } }; +} + +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(topic) { + transport.notify(topic); + }, + + 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/service-protocol.ts b/lib/src/host/remote/service-protocol.ts index 138eda0e..b6271291 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -24,8 +24,7 @@ import type { PairingRequest } from 'server-lib-common'; import type { RemoteHostStatus } from '../../remote/host/remote-host'; -/** Transport event names. The command travels under the first, the rest come back. */ -export const REMOTE_HOST_COMMAND_EVENT = 'remoteHost:command'; +/** 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'; @@ -75,7 +74,16 @@ export interface PairingQueueEvent { queue: PairingQueueItem[]; } -export type RemoteHostEvent = PairingQueueEvent; +/** + * 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 --- @@ -142,10 +150,6 @@ export interface RemoteHostConsoleStatus { pairedClients: number; } -export interface AdoptResult { - adopted: boolean; -} - /** * 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`). diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index a77898ee..05a92d4c 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -12,7 +12,11 @@ import type { HostSurfaceProvider } from '../../remote/host/host-surface-provide import type { WebSocketLike } from '../../remote/host/remote-host'; import type { HostStateStore } from './host-state-store'; import { RemoteHostService } from './service'; -import type { PairingQueueEvent, RemoteHostConsoleStatus } from './service-protocol'; +import type { + HostStatusEvent, + PairingQueueEvent, + RemoteHostConsoleStatus, +} from './service-protocol'; const CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; @@ -188,9 +192,20 @@ async function command(cmd: string, params?: unknown): Promise event.name === 'pairing-queue'); +} + +function uiEvents(): Array { return sent .filter((message) => message.event === 'remoteHost:event') - .map((message) => message.data as unknown as PairingQueueEvent); + .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(() => { @@ -321,7 +336,9 @@ describe('adopt', () => { aclRecords: [aclRecord('device-1')], }); - expect(result.result).toEqual({ adopted: true }); + // Nothing to report: the webview clears its copy either way, because a + // second copy of one hostId is a second ACL. + expect(result.result).toEqual({}); expect(store.enrollment).toEqual(ENROLLMENT); expect(store.acl['host-1']).toHaveLength(1); expect(sockets).toHaveLength(1); @@ -332,9 +349,8 @@ describe('adopt', () => { await service.start(); const other = { ...ENROLLMENT, hostId: 'host-2', hostToken: 'other' }; - const result = await command('adopt', { enrollment: other, aclRecords: [] }); + await command('adopt', { enrollment: other, aclRecords: [] }); - expect(result.result).toEqual({ adopted: false }); expect(store.enrollment).toEqual(ENROLLMENT); expect(sockets).toHaveLength(1); }); @@ -350,9 +366,44 @@ describe('adopt', () => { it('ignores an enrollment that does not have the shape', async () => { createService(); - const result = await command('adopt', { enrollment: { hostId: 'x' }, aclRecords: [] }); - expect(result.result).toEqual({ adopted: false }); + 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]); }); }); diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 67d309b8..a8a5615e 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -8,8 +8,9 @@ * 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 today (`sidecar-entry.ts`) and in the VS Code extension host - * next, and its tests drive it with a fake socket and an in-memory store. + * 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 @@ -17,8 +18,8 @@ * are settled there (`sidecar-entry.ts`), so they never reach this dispatch. */ -import type { HostAclRecord } from 'server-lib-common'; -import { performEnrollment, type HostEnrollment } from '../../remote/host/enrollment'; +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'; @@ -30,11 +31,11 @@ import { REMOTE_HOST_EVENT_EVENT, REMOTE_HOST_RESULT_EVENT, type AdoptParams, - type AdoptResult, type ApproveParams, type DenyParams, type EnrollParams, type EnrollResult, + type HostStatusEvent, type PairingQueueEvent, type PairingQueueItem, type PushDevicesResult, @@ -191,6 +192,7 @@ export class RemoteHostService { // 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.#emitStatus(); return {}; } @@ -223,24 +225,22 @@ export class RemoteHostService { return { devices: await loadPushDevices(deps) }; } - async #adopt(params: AdoptParams): Promise { + async #adopt(params: AdoptParams): Promise> { const existing = await this.#store.loadEnrollment(); - let adopted = false; if (!existing && isEnrollment(params.enrollment)) { const enrollment = params.enrollment; await this.#store.saveEnrollment(enrollment); - const records = (params.aclRecords ?? []).filter( - (record): record is HostAclRecord => - !!record && typeof record === 'object' && (record as HostAclRecord).hostId === enrollment.hostId, - ); + const records = filterAclRecords(enrollment.hostId, params.aclRecords ?? []); if (records.length > 0) await this.#store.saveAcl(enrollment.hostId, records); - adopted = true; } // 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). + // + // The webview is told nothing about which happened: it clears its copy + // regardless, because a second copy of the same hostId is a second ACL. if (!this.#host) await this.start(); - return { adopted }; + return {}; } // --- Host lifecycle --- @@ -280,6 +280,24 @@ export class RemoteHostService { 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 { + this.#sendToUi(REMOTE_HOST_EVENT_EVENT, { + name: 'status', + enrolled: !!this.#enrollment, + } satisfies HostStatusEvent); } #stopHost(): void { @@ -333,15 +351,3 @@ export class RemoteHostService { }; } } - -function isEnrollment(value: unknown): value is HostEnrollment { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - return ( - typeof v.serverUrl === 'string' && - typeof v.hostId === 'string' && - typeof v.hostToken === 'string' && - typeof v.origin === 'string' && - typeof v.rpId === 'string' - ); -} diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index 2d96e4b4..b639b6b4 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -101,9 +101,13 @@ describe('directory invalidation', () => { bridge.onNotify({ topic: 'something-else' }); expect(changes).toHaveBeenCalledTimes(1); + // A notify with no topic at all names no other business, so it is ours. + bridge.onNotify(undefined); + expect(changes).toHaveBeenCalledTimes(2); + unsubscribe(); bridge.onNotify({ topic: 'directory' }); - expect(changes).toHaveBeenCalledTimes(1); + expect(changes).toHaveBeenCalledTimes(2); }); }); @@ -187,19 +191,33 @@ describe('PTYs', () => { expect(one.data).toEqual([]); }); - it('gives each subscription its own parser state', () => { + 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. + // 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']); - // The newcomer never saw the introducer, so it reads the tail as ordinary - // output (its lone BEL stripped as a bell, which is the parser's own rule). - expect(two.data).toEqual(['Ahi']); + 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', () => { diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 14117216..15c06f5e 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -13,14 +13,9 @@ * All logging goes to stderr: stdout is the JSON-lines protocol channel. */ -import type { DirectoryEntry } from 'server-lib-common'; -import type { - HostSurfaceProvider, - PtySink, - SurfaceHandle, -} from '../../remote/host/host-surface-provider'; -import type { PeerSurfaceResult } from '../../remote/host/peer-surfaces'; -import { DEFAULT_REMOTE_CONNECT_SRC } from './connect-src'; +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'; @@ -32,9 +27,6 @@ import { type RemoteHostCommand, } from './service-protocol'; -/** Substituted by esbuild at build time; see `scripts/csp-defaults.mjs`. */ -declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; - /** The slice of `pty-core`'s manager the Host drives. */ export interface SidecarPtyManager { write(id: string, data: string): void; @@ -95,89 +87,38 @@ export function createSidecarSurfaceBridge( }); } - const directoryWatchers = new Set<() => void>(); - - interface Subscription { - sink: PtySink; + 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>(); - - const provider: HostSurfaceProvider = { - async collectDirectory(): Promise { - // The responder answers with its whole snapshot, so the results *are* the - // entries — no per-webview merging to do on this side. - return (await ask('directory', {})) as DirectoryEntry[]; - }, - - 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). - 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, - })) 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: () => {}, - }; - }, + const streams = 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) { - const subscription: Subscription = { sink, strip: createPtyStrip() }; - let subscriptions = streams.get(ptyId); - if (!subscriptions) { - subscriptions = new Set(); - streams.set(ptyId, subscriptions); + let stream = streams.get(ptyId); + if (!stream) { + stream = { strip: createPtyStrip(), sinks: new Set() }; + streams.set(ptyId, stream); } - subscriptions.add(subscription); + const subscribed = stream; + subscribed.sinks.add(sink); return () => { - subscriptions.delete(subscription); - if (subscriptions.size === 0) streams.delete(ptyId); + subscribed.sinks.delete(sink); + // The parser goes with the last attachment: keeping it would carry a + // half-read sequence into a stream that starts over. + if (subscribed.sinks.size === 0) streams.delete(ptyId); }; }, - }; + }); return { provider, @@ -194,38 +135,37 @@ export function createSidecarSurfaceBridge( }, onNotify(params) { - if (params?.topic !== 'directory') return; - for (const watcher of [...directoryWatchers]) watcher(); + notifyDirectoryChanged(params?.topic); }, onPtyEvent(event, data) { + // Nothing is attached: this runs on every chunk of every PTY, and the + // usual state of a machine with no phone on it is exactly this. + if (streams.size === 0) return; const detail = data as { id?: unknown } | null; if (!detail || typeof detail.id !== 'string') return; - const subscriptions = streams.get(detail.id); - if (!subscriptions || subscriptions.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; - for (const subscription of [...subscriptions]) { - // Each attachment strips on its own parser: the state an incomplete - // OSC leaves behind belongs to one stream's byte boundaries, not - // another's. - const visible = subscription.strip(chunk); - if (visible !== '') subscription.sink.onData(visible); - } + 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 exitCode = (detail as { exitCode?: unknown }).exitCode; const code = typeof exitCode === 'number' ? exitCode : 0; - for (const subscription of [...subscriptions]) subscription.sink.onExit(code); + for (const sink of stream.sinks) sink.onExit(code); } }, dispose() { for (const pending of [...asks.values()]) pending.settle([]); asks.clear(); - directoryWatchers.clear(); streams.clear(); }, }; @@ -244,11 +184,6 @@ export interface SidecarRemoteHost { } export function createSidecarRemoteHost(options: SidecarRemoteHostOptions): SidecarRemoteHost { - const connectSrc = - typeof __DORMOUSE_REMOTE_CONNECT_SRC__ === 'string' - ? __DORMOUSE_REMOTE_CONNECT_SRC__ - : DEFAULT_REMOTE_CONNECT_SRC; - const store = options.stateDir ? new FileHostStateStore(options.stateDir) : createEphemeralHostStateStore((message) => console.error(message)); @@ -259,7 +194,7 @@ export function createSidecarRemoteHost(options: SidecarRemoteHostOptions): Side store, provider: bridge.provider, sendToUi: options.send, - connectSrc, + connectSrc: bakedConnectSrc(), }); void service.start().catch((error: unknown) => { console.error(`[remote-host] failed to start: ${String(error)}`); diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 7623ebd5..b0f41744 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -385,6 +385,11 @@ 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); @@ -405,59 +410,17 @@ describe('VSCodeAdapter remote host link', () => { windowTarget.dispatchEvent(hostMessage(data)); } - it('resolves a command by its rhId', async () => { + 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'); - // A result for someone else's rhId must not resolve this one. - deliver({ type: 'remoteHost:result', payload: { rhId: 'other', result: { enrolled: false } } }); deliver({ type: 'remoteHost:result', payload: { rhId: payload.rhId, result: { enrolled: true } } }); expect(await pending).toEqual({ enrolled: true }); }); - it('mints rhIds no sibling webview can collide with', () => { - // Results are broadcast to every webview in the window, so two adapters - // counting from 1 would settle each other's commands. - const a = new VSCodeAdapter(); - const b = new VSCodeAdapter(); - void a.remoteHost.command('status'); - void b.remoteHost.command('status'); - - const ids = sent().map((payload) => payload.rhId); - expect(ids).toHaveLength(2); - expect(new Set(ids).size).toBe(2); - }); - - it('rejects with the error the service reported', async () => { - const adapter = new VSCodeAdapter(); - const pending = adapter.remoteHost.command('enroll', { serverUrl: 'https://nope' }); - deliver({ - type: 'remoteHost:result', - payload: { rhId: sent()[0]!.rhId, error: 'no remote Host is reachable' }, - }); - await expect(pending).rejects.toThrow('no remote Host is reachable'); - }); - - it('rejects when the extension host never answers', async () => { - const adapter = new VSCodeAdapter(); - vi.useFakeTimers(); - try { - const pending = adapter.remoteHost.command('status'); - const rejected = expect(pending).rejects.toThrow(/timed out/); - await vi.advanceTimersByTimeAsync(20_000); - await rejected; - // The late answer finds nothing to settle. - expect(() => - deliver({ type: 'remoteHost:result', payload: { rhId: sent()[0]!.rhId, result: {} } }), - ).not.toThrow(); - } finally { - vi.useRealTimers(); - } - }); - it('answers an ask from the registered responder', () => { const adapter = new VSCodeAdapter(); adapter.remoteHost.respond('surfaceOp', (params) => [ @@ -473,38 +436,13 @@ describe('VSCodeAdapter remote host link', () => { }); }); - it('answers with nothing rather than leaving an ask open', () => { - const adapter = new VSCodeAdapter(); - // Nobody responds to this op, and a handler that throws is the same case: - // the broker would otherwise hold the fan-out for its whole budget. - deliver({ type: 'peer:ask', requestId: 'ask-1', op: 'directory', params: {} }); - adapter.remoteHost.respond('directory', () => { - throw new Error('registry blew up'); - }); - vi.spyOn(console, 'error').mockImplementation(() => {}); - deliver({ type: 'peer:ask', requestId: 'ask-2', op: 'directory', params: {} }); - - const answers = postMessage.mock.calls - .map((call) => call[0]) - .filter((message) => message.type === 'peer:answer'); - expect(answers).toEqual([ - { type: 'peer:answer', requestId: 'ask-1', results: [] }, - { type: 'peer:answer', requestId: 'ask-2', results: [] }, - ]); - }); - - it('fans events out by name, and stops after unsubscribe', () => { + it('fans an extension-host event out by name', () => { const adapter = new VSCodeAdapter(); const seen: unknown[] = []; - const unsubscribe = adapter.remoteHost.on('pairing-queue', (data) => void seen.push(data)); + adapter.remoteHost.on('pairing-queue', (data) => void seen.push(data)); deliver({ type: 'remoteHost:event', payload: { name: 'pairing-queue', queue: [{ clientId: 'c1' }] } }); - deliver({ type: 'remoteHost:event', payload: { name: 'something-else', queue: [] } }); expect(seen).toEqual([{ name: 'pairing-queue', queue: [{ clientId: 'c1' }] }]); - - unsubscribe(); - deliver({ type: 'remoteHost:event', payload: { name: 'pairing-queue', queue: [] } }); - expect(seen).toHaveLength(1); }); it('notifies without waiting for anything', () => { @@ -513,6 +451,15 @@ describe('VSCodeAdapter remote host link', () => { expect(postMessage).toHaveBeenCalledWith({ type: 'peer:notify', topic: 'directory' }); }); + 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(); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 349d1532..d35a54c9 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,6 +1,6 @@ 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 type { RemoteHostCommand, RemoteHostResult } from '../../host/remote/service-protocol'; +import { createRemoteHostLinkClient } from '../../host/remote/link-client'; import type { AlertSettings } from '../alert-settings'; import { readInjectedRecoveryCommands } from '../vscode-recovery-global'; import { setDefaultShellOpts } from '../shell-defaults'; @@ -16,24 +16,6 @@ import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; -/** - * How long a remote-host command may wait for the extension host. Generous — - * `enroll` makes an HTTP round trip to the relay server — but finite, so a - * broker window that went away surfaces as a rejected promise instead of a hung - * console call. Mirrors the standalone adapters' bound. - */ -const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; - -/** - * A short random component for this adapter's `rhId`s. Every webview in the - * window sees every `remoteHost:result`, 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); -} - export class VSCodeAdapter implements PlatformAdapter { // VS Code owns the theme here: it provides --vscode-* itself and has its own // theme UI, so Dormouse hides the Settings dialog's Theme row. @@ -52,17 +34,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 state (the contract is - // lib/src/host/remote/service-protocol.ts). Results are broadcast to every - // webview, so `rhId` carries a per-adapter tag — see `nextRhId`. - private remoteHostPending = new Map< - string, - { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType } - >(); - private remoteHostResponders = new Map unknown[]>(); - private remoteHostListeners = new Map void>>(); - private readonly rhTag = randomTag(); - private nextRemoteHostId = 0; + // --- 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: (topic) => this.vscode.postMessage({ type: 'peer:notify', topic }), + }); + + readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; constructor() { this.vscode = acquireVsCodeApi(); @@ -185,14 +173,11 @@ export class VSCodeAdapter implements PlatformAdapter { }, })); } else if (msg.type === 'peer:ask') { - this.answerRemoteHostAsk(msg.requestId, msg.op, msg.params); + this.remoteHostClient.onAsk(msg.requestId, msg.op, msg.params); } else if (msg.type === 'remoteHost:result') { - this.settleRemoteHostCommand(msg.payload); + this.remoteHostClient.onResult(msg.payload); } else if (msg.type === 'remoteHost:event') { - const name = (msg.payload as { name?: unknown } | null)?.name; - if (typeof name === 'string') { - for (const listener of this.remoteHostListeners.get(name) ?? []) listener(msg.payload); - } + this.remoteHostClient.onEvent(msg.payload); } }); } @@ -232,82 +217,10 @@ export class VSCodeAdapter implements PlatformAdapter { // No initialization needed — the webview is already running } - // --- 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. - - readonly remoteHost: RemoteHostLink = { - command: (cmd, params) => this.remoteHostCommand(cmd, params), - respond: (op, handler) => { - this.remoteHostResponders.set(op, handler); - }, - notify: (topic) => { - this.vscode.postMessage({ type: 'peer:notify', topic }); - }, - on: (name, listener) => { - let listeners = this.remoteHostListeners.get(name); - if (!listeners) { - listeners = new Set(); - this.remoteHostListeners.set(name, listeners); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; - - private nextRhId(): string { - return `rh-${this.rhTag}-${++this.nextRemoteHostId}`; - } - - private remoteHostCommand(cmd: string, params?: unknown): Promise { - const rhId = this.nextRhId(); - return new Promise((resolve, reject) => { - // Bounded: a broker window that closed mid-command must reject rather - // than leave the console hook (or the device dialog) waiting forever. - const timer = setTimeout(() => { - this.remoteHostPending.delete(rhId); - reject(new Error(`remote host command timed out: ${cmd}`)); - }, REMOTE_HOST_COMMAND_TIMEOUT_MS); - this.remoteHostPending.set(rhId, { resolve, reject, timer }); - this.vscode.postMessage({ type: 'remoteHost:command', payload: { rhId, cmd, params } satisfies RemoteHostCommand }); - }); - } - - private settleRemoteHostCommand(result: RemoteHostResult | undefined): void { - const pending = result ? this.remoteHostPending.get(result.rhId) : undefined; - if (!pending || !result) return; - this.remoteHostPending.delete(result.rhId); - clearTimeout(pending.timer); - if (typeof result.error === 'string') pending.reject(new Error(result.error)); - else pending.resolve(result.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 broker settles once every webview 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. - */ - private answerRemoteHostAsk(requestId: string, op: string, params: unknown): void { - const handler = this.remoteHostResponders.get(op); - let results: unknown[] = []; - try { - results = handler ? handler(params) : []; - } catch (err) { - console.error(`[dormouse] remote host ask ${op} failed:`, err); - } - this.vscode.postMessage({ type: 'peer:answer', requestId, results }); - } - 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..e970a6db 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`. */ @@ -77,19 +76,8 @@ export function refreshPushDevicesNow(): void { refresh?.(); } -/** - * 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. - */ -export function getPushDevicesGeneration(): number { - return generation; -} - -/** Back to `no-host`, for a Host that stopped or a test that finished. */ +/** Back to `no-host`, for a story or a test that finished. */ export function resetPushDevices(): void { - generation += 1; refresh = null; setPushDevices(EMPTY); } diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index a1ef7bb3..7651c342 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -14,10 +14,18 @@ * that vanishes mid-attach) are testable without spawning processes. */ -import type { RemoteHostCommand, RemoteHostResult } from '../host/remote/service-protocol'; +import { + ASK_BUDGET_MS, + type RemoteHostCommand, + type RemoteHostResult, +} from '../host/remote/service-protocol'; -/** How long the broker waits for a window to answer before giving up on it. */ -export const PEER_REPLY_BUDGET_MS = 1_000; +/** + * How long the broker waits for a window to answer before giving up on it. The + * same budget as the service's own ask, because it is the same wait seen one + * layer down: the webview that has to answer is at the far end of both. + */ +export const PEER_REPLY_BUDGET_MS = ASK_BUDGET_MS; /** * Broker → peer window. diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts index 882d0c2a..fe3ce676 100644 --- a/lib/src/remote/host/acl.ts +++ b/lib/src/remote/host/acl.ts @@ -17,17 +17,29 @@ 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, ); } +/** 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)); +} + export function saveAclRecords(hostId: string, records: readonly HostAclRecord[]): void { saveJson(aclKey(hostId), records); } diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index f2a2580f..a0d63d89 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -28,12 +28,16 @@ const enrollmentState = vi.hoisted(() => ({ const pushWatch = vi.hoisted(() => ({ fire: undefined as ((sessionId: string, title: string) => void) | undefined, + stopped: 0, loads: [] as Array<() => Promise>, })); vi.mock('./alert-push', () => ({ watchPushRings: (fire: (sessionId: string, title: string) => void) => { pushWatch.fire = fire; - return () => {}; + return () => { + pushWatch.fire = undefined; + pushWatch.stopped += 1; + }; }, commitPushDevices: async (load: () => Promise) => { pushWatch.loads.push(load); @@ -69,6 +73,7 @@ vi.mock('../../lib/platform', () => ({ beforeEach(() => { remoteHostLink = undefined; pushWatch.fire = undefined; + pushWatch.stopped = 0; pushWatch.loads.length = 0; pushRefreshers.current.length = 0; aclState.records = []; @@ -129,19 +134,29 @@ function fakeLink(): FakeLink { return link; } -/** Install in bridge mode and hand back the module's fresh pairing store. */ +/** + * 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 }; 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. - await Promise.resolve(); - await Promise.resolve(); + // 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?: { @@ -297,6 +312,35 @@ describe('remote host bridge mode', () => { 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); + }); + it('is idempotent under a StrictMode double mount', async () => { const link = fakeLink(); const { mod } = await installBridge(link); diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index bb762e91..c57e512e 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -30,6 +30,7 @@ import { setPushDevicesRefresher } from '../../lib/push-devices'; import { clearAclRecords, loadAclRecords } from './acl'; import { commitPushDevices, watchPushRings } from './alert-push'; import { clearEnrollment, getEnrollment } from './enrollment'; +import { armWhileEnrolled } from './enrolled-gate'; import { enqueuePairingApproval, getPairingApprovalSnapshot, @@ -77,20 +78,25 @@ function installBridgeMode(link: RemoteHostLink): void { .catch(() => {}); }); - // Rings are detected here — the activity store and the pane labels are - // webview state — and delivered there, where the ACL is. - watchPushRings((sessionId, title) => { - void link.command('push', { sessionId, title }).catch(() => {}); - }); - const refresh = (): void => { void commitPushDevices(async () => { const result = (await link.command('pushDevices')) as PushDevicesResult; return result ? result.devices : null; }); }; + // Installed unconditionally: the dialog may open on an un-enrolled machine, + // and asking then is one command that answers `no-host`. setPushDevicesRefresher(refresh); - 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(); + return stopRings; + }); const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; if (target.dormouseRemoteHost) return; diff --git a/lib/src/remote/host/alert-push.test.ts b/lib/src/remote/host/alert-push.test.ts index 33cbfbaa..e8e6d055 100644 --- a/lib/src/remote/host/alert-push.test.ts +++ b/lib/src/remote/host/alert-push.test.ts @@ -5,10 +5,10 @@ vi.mock('../../lib/platform', () => ({ })); import type { HostAclRecord } from 'server-lib-common'; -import { commitPushDevices, watchPushRings, type AlertPushDeps } from './alert-push'; +import { commitPushDevices, 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 } from './push-delivery'; +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'; @@ -324,30 +324,6 @@ 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({ - enrollment: ENROLLMENT, - activeRecords: () => records, - fetch: (() => - new Promise((resolve) => { - resolveFetch = resolve; - })) as unknown as typeof globalThis.fetch, - }); - - resetPushDevices(); - resolveFetch({ - ok: true, - json: async () => ({ devices: [{ devicePublicKey: 'device-phone', subscribedAt: 1 }] }), - } as Response); - await pending; - - expect(getPushDevices()).toEqual({ status: 'no-host', devices: [] }); - }); - it('keeps a newer refresh when an older request resolves last', async () => { records = [ aclRecord('device-phone', 'iPhone Safari'), diff --git a/lib/src/remote/host/alert-push.ts b/lib/src/remote/host/alert-push.ts index 305336b3..b5b0ea22 100644 --- a/lib/src/remote/host/alert-push.ts +++ b/lib/src/remote/host/alert-push.ts @@ -18,20 +18,12 @@ 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 { AlertPushDeps } from './push-delivery'; - -export type { AlertPushDeps }; +import { setPushDevices, type PushDevice, type PushDevicesState } from '../../lib/push-devices'; let pushDevicesRefreshSequence = 0; /** - * Run `load` and publish its result to the dialog's store with the fences below. + * 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 @@ -41,19 +33,14 @@ let pushDevicesRefreshSequence = 0; export async function commitPushDevices( load: () => Promise, ): 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(); + // 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); }; commit({ status: 'loading', devices: [] }); try { 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..ff25050c 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,7 +31,7 @@ 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', @@ -51,17 +47,20 @@ 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('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 763f9b48..450a6012 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -6,16 +6,16 @@ * Host's `ConnectionPolicy` — the Server tells the Host what it must enforce, * and the Host enforces it as final authority regardless. * - * Persisted through `local-json-store` (browser-only, no platform adapter - * dependency) so the standalone app can rehydrate and reconnect on the next - * launch. The VS Code webview claims the `dormouse.remote-host.` prefix and - * backs it with the extension host's `SecretStorage` — `hostToken` is a bearer - * credential, and webview `localStorage` is not the VS Code persistence story - * (docs/specs/vscode.md). + * 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, removeJson, saveJson } from '../../lib/local-json-store'; +import { loadJson, removeJson } from '../../lib/local-json-store'; import { ENROLLMENT_KEY } from './store'; export interface HostEnrollment { @@ -30,9 +30,13 @@ export interface HostEnrollment { rpId: string; } -export { ENROLLMENT_KEY } from './store'; - -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 ( @@ -53,18 +57,13 @@ export function clearEnrollment(): void { removeJson(ENROLLMENT_KEY); } -function saveEnrollment(enrollment: HostEnrollment): void { - saveJson(ENROLLMENT_KEY, enrollment); -} - /** * `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: where the credentials live differs by Host — `localStorage` - * for the webview-resident one below, a 0600 file for the Node-resident service - * (`lib/src/host/remote/host-state-store.ts`) — while the exchange itself is one + * 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 performEnrollment( @@ -91,14 +90,3 @@ export async function performEnrollment( rpId: body.rpId, }; } - -/** {@link performEnrollment}, persisted to the webview's own store. */ -export async function enrollHost( - serverUrl: string, - password: string, - label: string, -): Promise { - const enrollment = await performEnrollment(serverUrl, password, label); - saveEnrollment(enrollment); - return enrollment; -} diff --git a/lib/src/remote/host/pairing-approval.ts b/lib/src/remote/host/pairing-approval.ts index 60f35375..30b9a92f 100644 --- a/lib/src/remote/host/pairing-approval.ts +++ b/lib/src/remote/host/pairing-approval.ts @@ -1,9 +1,12 @@ /** * 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 `clientId` — so the closures + * that can actually write the ACL never leave that process. */ import type { PairingRequest } from 'server-lib-common'; diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 786856ef..22715712 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -23,9 +23,11 @@ interface Responder { class ServicePlatform { readonly responders = new Map(); readonly notified: string[] = []; + /** What `status` answers — the gate the notify sources arm on. */ + enrolled = true; readonly remoteHost = { - command: async () => undefined, + command: async (cmd: string) => (cmd === 'status' ? { enrolled: this.enrolled } : undefined), respond: (op: string, handler: Responder) => { this.responders.set(op, handler); }, @@ -60,6 +62,12 @@ function registerSurface(surfaceId: string, ptyId: string, cols = 80, rows = 24) 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()); @@ -119,27 +127,34 @@ describe('surface responder', () => { expect(clamped[0]!.rows).toBeGreaterThan(0); }); - it('leaves the pane alone on detach', () => { - // Last-attach-wins: the Host stops streaming on its side and the pane keeps - // whatever size it was left at. - const terminal = registerSurface('surface-1', 'pty-1', 90, 25); - - expect(platform.answer('surfaceOp', { surfaceId: 'surface-1', op: 'detach' })).toEqual([ - { ptyId: 'pty-1', cols: 90, rows: 25 }, - ]); - expect(terminal.resize).not.toHaveBeenCalled(); - }); - 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', () => { + 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' }); expect(platform.notified).toContain('directory'); }); + + 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' }); + expect(quiet.notified).toEqual([]); + // 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 index 966f1d46..d655d0f9 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -25,9 +25,14 @@ 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. */ -export type PeerSurfaceOp = 'attach' | 'detach' | 'resize'; +/** + * 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 = 'attach' | 'resize'; export interface PeerSurfaceParams { surfaceId: string; @@ -74,19 +79,13 @@ function answerPeers( * `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. `detach` has nothing to undo here: the Host stops - * streaming on its side, and the pane keeps whatever size it was left at, which - * is what last-attach-wins means. + * size the phone asked for. */ -function driveOwnSurface({ surfaceId, op, cols, rows }: PeerSurfaceParams): PeerSurfaceResult[] { +function driveOwnSurface({ surfaceId, cols, rows }: PeerSurfaceParams): PeerSurfaceResult[] { const entry = registry.get(surfaceId); if (!entry) return []; const term = entry.terminal; - if (op === 'detach') { - return [{ ptyId: entry.ptyId, cols: term.cols, rows: term.rows }]; - } - const nextCols = clampTerminalDimension(cols, term.cols); const nextRows = clampTerminalDimension(rows, term.rows); if (term.cols !== nextCols || term.rows !== nextRows) { @@ -101,16 +100,31 @@ function driveOwnSurface({ surfaceId, op, cols, rows }: PeerSurfaceParams): Peer * 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) return; - const notifyDirectory = () => link.notify('directory'); - subscribeToTerminalPaneState(notifyDirectory); - subscribeToActivity(notifyDirectory); - if (typeof document !== 'undefined') { - document.addEventListener('focusin', notifyDirectory); - document.addEventListener('focusout', notifyDirectory); - } + // 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, () => { + const notifyDirectory = () => link.notify('directory'); + const unsubscribePaneState = subscribeToTerminalPaneState(notifyDirectory); + const unsubscribeActivity = subscribeToActivity(notifyDirectory); + const hasDocument = typeof document !== 'undefined'; + if (hasDocument) { + document.addEventListener('focusin', notifyDirectory); + document.addEventListener('focusout', notifyDirectory); + } + return () => { + unsubscribePaneState(); + unsubscribeActivity(); + if (!hasDocument) return; + document.removeEventListener('focusin', notifyDirectory); + document.removeEventListener('focusout', notifyDirectory); + }; + }); } diff --git a/lib/src/remote/host/store.ts b/lib/src/remote/host/store.ts index eebf8126..e8080db7 100644 --- a/lib/src/remote/host/store.ts +++ b/lib/src/remote/host/store.ts @@ -1,17 +1,14 @@ /** - * The one key prefix every Host-side persisted value lives under - * (`enrollment.ts` → `ENROLLMENT_KEY`, `acl.ts` → `ACL_KEY_PREFIX`). + * 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`. * - * One prefix rather than a scatter of keys so a host can name the whole Host - * store at once — which is what lets a Node-resident Host adopt what a webview - * persisted before it existed, and what keys the VS Code extension host writes - * its own copy under (`vscode-ext/src/remote-host-store.ts`). + * `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 REMOTE_HOST_STORE_PREFIX = 'dormouse.remote-host.'; - -/** - * The enrollment blob's key. It lives here rather than in `enrollment.ts` so - * the extension host can import it without pulling `server-lib-common` into the - * extension bundle; `enrollment.ts` re-exports it for its own callers. - */ -export const ENROLLMENT_KEY = `${REMOTE_HOST_STORE_PREFIX}enrollment`; +export const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs index fc893cb2..8cec8ad4 100644 --- a/scripts/csp-defaults.mjs +++ b/scripts/csp-defaults.mjs @@ -1,15 +1,18 @@ // The one definition of where a Host may reach a relay server, shared by both // Hosts' build scripts. // -// The two Hosts bake it in at different places, because their Hosts run in -// different processes: standalone's runs in the sidecar, so esbuild substitutes -// it into that bundle (`standalone/scripts/build-sidecar-proxy.mjs`) and the -// service refuses any origin outside it; the VS Code extension still hosts the -// Host in its webview, so esbuild substitutes it into the webview's CSP -// (`vscode-ext/scripts/esbuild.mjs`) until that Host migrates too. Either way -// 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". +// 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'; @@ -25,3 +28,28 @@ export function resolveRemoteConnectSrc(env = process.env, label = 'build') { 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/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 76640b47..2f525996 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -6,10 +6,13 @@ // - 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 { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import { resolveRemoteConnectSrc } from '../../scripts/csp-defaults.mjs'; +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'); @@ -18,7 +21,6 @@ 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 CONNECT_SRC_PLACEHOLDER = '__DORMOUSE_REMOTE_CONNECT_SRC__'; const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, @@ -43,20 +45,6 @@ for (const { entry, out, define, assertBaked } of bundles) { logLevel: 'warning', ...(define ? { define } : {}), }); - // The source reads the placeholder as a `declare const`, so a lost define - // compiles fine and only fails at runtime — as a Host that silently falls back - // to the shipped default allowlist. Fail the build instead, like the VS Code - // side does (vscode-ext/scripts/esbuild.mjs). - if (assertBaked) { - const bundled = readFileSync(outfile, 'utf8'); - if (bundled.includes(CONNECT_SRC_PLACEHOLDER)) { - throw new Error( - `connect-src: ${CONNECT_SRC_PLACEHOLDER} survived into ${out} — the esbuild define did not apply.`, - ); - } - if (!bundled.includes(remoteSrc)) { - throw new Error(`connect-src: ${out} does not contain the resolved sources (${remoteSrc}).`); - } - } + if (assertBaked) assertConnectSrcBaked(outfile, remoteSrc); console.log(`[sidecar] built ${path.relative(process.cwd(), outfile)}`); } diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 97168540..aff580fe 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -13,6 +13,11 @@ import type { 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, @@ -38,15 +43,6 @@ import { BrowserSidecarHost } from "./browser-sidecar-host"; const errMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); -/** Mirrors the Tauri adapter's bound; `enroll` makes an HTTP round trip. */ -const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; - -/** See TauriAdapter: `rhId`s must be unique across every webview, not per adapter. */ -function randomTag(): string { - const uuid = globalThis.crypto?.randomUUID?.(); - return uuid ? uuid.slice(0, 8) : Math.random().toString(36).slice(2, 10); -} - function decodeBase64Bytes(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); @@ -66,18 +62,13 @@ export class BrowserSidecarAdapter implements PlatformAdapter { 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 remoteHostPending = new Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType; - } - >(); - private remoteHostResponders = new Map unknown[]>(); - private remoteHostListeners = new Map void>>(); - private readonly rhTag = randomTag(); - private nextRemoteHostId = 0; + private readonly remoteHostClient = createRemoteHostLinkClient({ + sendCommand: (command) => this.sendRemoteHostCommand(command), + answerAsk: (askId, results) => this.sendRemoteHostCommand(answerAskCommand(askId, results)), + notify: (topic) => this.sendRemoteHostCommand(notifyCommand(topic)), + }); + + readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; constructor(private readonly host: BrowserSidecarHost) { this.alertManager.onStateChange((id, state) => { @@ -109,82 +100,15 @@ export class BrowserSidecarAdapter implements PlatformAdapter { this.protocolParsers.clear(); this.unlistenHost?.(); this.unlistenHost = null; - for (const pending of this.remoteHostPending.values()) { - clearTimeout(pending.timer); - pending.reject(new Error("remote host bridge closed")); - } - this.remoteHostPending.clear(); + this.remoteHostClient.dispose(); this.host.send("kill_sidecar_now"); this.host.close(); } - // --- Remote host bridge (see TauriAdapter for the contract) --- - - readonly remoteHost: RemoteHostLink = { - command: (cmd, params) => this.remoteHostCommand(cmd, params), - respond: (op, handler) => { - this.remoteHostResponders.set(op, handler); - }, - notify: (topic) => { - this.sendRemoteHostCommand({ rhId: this.nextRhId(), cmd: "notify", params: { topic } }); - }, - on: (name, listener) => { - let listeners = this.remoteHostListeners.get(name); - if (!listeners) { - listeners = new Set(); - this.remoteHostListeners.set(name, listeners); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; - - private nextRhId(): string { - return `rh-${this.rhTag}-${++this.nextRemoteHostId}`; - } - private sendRemoteHostCommand(command: RemoteHostCommand): void { this.host.send("remote_host_command", { payload: command }); } - private remoteHostCommand(cmd: string, params?: unknown): Promise { - const rhId = this.nextRhId(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.remoteHostPending.delete(rhId); - reject(new Error(`remote host command timed out: ${cmd}`)); - }, REMOTE_HOST_COMMAND_TIMEOUT_MS); - this.remoteHostPending.set(rhId, { resolve, reject, timer }); - this.sendRemoteHostCommand({ rhId, cmd, params }); - }); - } - - private settleRemoteHostCommand(result: RemoteHostResult): void { - const pending = this.remoteHostPending.get(result?.rhId); - if (!pending) return; - this.remoteHostPending.delete(result.rhId); - clearTimeout(pending.timer); - if (typeof result.error === "string") pending.reject(new Error(result.error)); - else pending.resolve(result.result); - } - - private answerRemoteHostAsk(ask: RemoteHostAsk): void { - const handler = this.remoteHostResponders.get(ask?.op); - let results: unknown[] = []; - try { - results = handler ? handler(ask.params) : []; - } catch (err) { - console.error(`[browser-sidecar] remote host ask ${ask?.op} failed:`, err); - } - this.sendRemoteHostCommand({ - rhId: this.nextRhId(), - cmd: "answer", - params: { rhId: ask.rhId, results }, - }); - } - async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> { try { return await this.host.invoke("get_available_shells"); @@ -364,14 +288,12 @@ export class BrowserSidecarAdapter implements PlatformAdapter { applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData }); } else if (event === REMOTE_HOST_RESULT_EVENT) { - this.settleRemoteHostCommand(data as RemoteHostResult); + this.remoteHostClient.onResult(data as RemoteHostResult); } else if (event === REMOTE_HOST_ASK_EVENT) { - this.answerRemoteHostAsk(data as RemoteHostAsk); + const ask = data as RemoteHostAsk; + this.remoteHostClient.onAsk(ask.rhId, ask.op, ask.params); } else if (event === REMOTE_HOST_EVENT_EVENT) { - const name = (data as { name?: string } | null)?.name; - if (typeof name === "string") { - for (const listener of this.remoteHostListeners.get(name) ?? []) listener(data); - } + 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 945dcbd9..7d74d6e6 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -84,12 +84,17 @@ async function createPlatform(): Promise { 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(); - await platform.init(); // Quit orchestrator (docs/specs/standalone.md §Quit flow). Tauri-only: the // browser-dev harness has no Rust quit interception, and quit.ts pulls the // Tauri APIs. !BROWSER_DEV_HOST is exactly the createPlatform branch that diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 3e020932..761e24e0 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -103,6 +103,11 @@ describe("TauriAdapter legacy session cleanup", () => { // (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 }; @@ -146,28 +151,6 @@ describe("TauriAdapter remote host link", () => { expect(await pending).toEqual({ enrolled: true }); }); - it("rejects with the error the service reported", async () => { - const { adapter, sent, deliver } = await bridged(); - const pending = adapter.remoteHost.command("enroll", { serverUrl: "https://nope" }); - deliver("remoteHost:result", { rhId: sent()[0]!.rhId, error: "outside the allowed sources" }); - await expect(pending).rejects.toThrow("outside the allowed sources"); - }); - - it("rejects when the sidecar never answers", async () => { - const { adapter, deliver } = await bridged(); - vi.useFakeTimers(); - try { - const pending = adapter.remoteHost.command("status"); - const rejected = expect(pending).rejects.toThrow(/timed out/); - await vi.advanceTimersByTimeAsync(20_000); - await rejected; - // The late answer finds nothing to settle. - expect(() => deliver("remoteHost:result", { rhId: "rh-1", result: {} })).not.toThrow(); - } finally { - vi.useRealTimers(); - } - }); - it("answers an ask from the registered responder", async () => { const { adapter, sent, deliver } = await bridged(); adapter.remoteHost.respond("surfaceOp", (params) => [ @@ -182,34 +165,13 @@ describe("TauriAdapter remote host link", () => { }); }); - it("answers with nothing rather than leaving an ask open", async () => { - const { adapter, sent, deliver } = await bridged(); - // Nobody responds to this op, and a handler that throws is the same case: - // the service would otherwise hold the ask for its whole budget. - deliver("remoteHost:ask", { rhId: "ask-1", op: "directory", params: {} }); - adapter.remoteHost.respond("directory", () => { - throw new Error("registry blew up"); - }); - deliver("remoteHost:ask", { rhId: "ask-2", op: "directory", params: {} }); - - expect(sent().map((p) => p.params)).toEqual([ - { rhId: "ask-1", results: [] }, - { rhId: "ask-2", results: [] }, - ]); - }); - - it("fans events out by name, and stops after unsubscribe", async () => { + it("fans a sidecar event out by name", async () => { const { adapter, deliver } = await bridged(); const seen: unknown[] = []; - const unsubscribe = adapter.remoteHost.on("pairing-queue", (data) => void seen.push(data)); + adapter.remoteHost.on("pairing-queue", (data) => void seen.push(data)); deliver("remoteHost:event", { name: "pairing-queue", queue: [{ clientId: "c1" }] }); - deliver("remoteHost:event", { name: "something-else", queue: [] }); expect(seen).toEqual([{ name: "pairing-queue", queue: [{ clientId: "c1" }] }]); - - unsubscribe(); - deliver("remoteHost:event", { name: "pairing-queue", queue: [] }); - expect(seen).toHaveLength(1); }); it("notifies without waiting for anything", async () => { @@ -218,7 +180,7 @@ describe("TauriAdapter remote host link", () => { expect(sent()[0]).toMatchObject({ cmd: "notify", params: { topic: "directory" } }); }); - it("rejects what is still in flight when the bridge closes", async () => { + 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(); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 56b5a293..856f77f0 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -16,6 +16,11 @@ import type { 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, @@ -51,23 +56,6 @@ function invoke(cmd: string, args?: Record): void { const errMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); -/** - * How long a remote-host command may wait for the sidecar. Generous — `enroll` - * makes an HTTP round trip to the relay server — but finite, so a dead sidecar - * surfaces as a rejected promise instead of a hung console call. - */ -const REMOTE_HOST_COMMAND_TIMEOUT_MS = 15_000; - -/** - * A short random component for this adapter'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); -} - /** * Platform adapter for the Tauri standalone app. * @@ -98,21 +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 state (docs/specs/server.md; the contract is - // lib/src/host/remote/service-protocol.ts). Correlation is `rhId`, never - // `requestId` — Rust swallows any sidecar line carrying the latter. - private remoteHostPending = new Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType; - } - >(); - private remoteHostResponders = new Map unknown[]>(); - private remoteHostListeners = new Map void>>(); - private readonly rhTag = randomTag(); - private nextRemoteHostId = 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: (topic) => this.sendRemoteHostCommand(notifyCommand(topic)), + }); + + readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; constructor() { // Wire alert manager state changes to handlers @@ -189,21 +177,20 @@ export class TauriAdapter implements PlatformAdapter { this.unlistenFns.push( await listen(REMOTE_HOST_RESULT_EVENT, (event) => { - this.settleRemoteHostCommand(event.payload); + this.remoteHostClient.onResult(event.payload); }), ); this.unlistenFns.push( await listen(REMOTE_HOST_ASK_EVENT, (event) => { - this.answerRemoteHostAsk(event.payload); + const ask = event.payload; + this.remoteHostClient.onAsk(ask.rhId, ask.op, ask.params); }), ); this.unlistenFns.push( await listen<{ name?: string }>(REMOTE_HOST_EVENT_EVENT, (event) => { - const name = event.payload?.name; - if (typeof name !== "string") return; - for (const listener of this.remoteHostListeners.get(name) ?? []) listener(event.payload); + this.remoteHostClient.onEvent(event.payload); }), ); @@ -258,12 +245,8 @@ export class TauriAdapter implements PlatformAdapter { unlisten(); } this.unlistenFns = []; - // Nothing will answer these once the sidecar is gone. - for (const pending of this.remoteHostPending.values()) { - clearTimeout(pending.timer); - pending.reject(new Error("remote host bridge closed")); - } - this.remoteHostPending.clear(); + // Nothing will answer what is outstanding once the sidecar is gone. + this.remoteHostClient.dispose(); invoke("kill_sidecar_now"); } @@ -510,83 +493,12 @@ export class TauriAdapter implements PlatformAdapter { ); } - // --- 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. - - readonly remoteHost: RemoteHostLink = { - command: (cmd, params) => this.remoteHostCommand(cmd, params), - respond: (op, handler) => { - this.remoteHostResponders.set(op, handler); - }, - notify: (topic) => { - this.sendRemoteHostCommand({ rhId: this.nextRhId(), cmd: "notify", params: { topic } }); - }, - on: (name, listener) => { - let listeners = this.remoteHostListeners.get(name); - if (!listeners) { - listeners = new Set(); - this.remoteHostListeners.set(name, listeners); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; - - private nextRhId(): string { - return `rh-${this.rhTag}-${++this.nextRemoteHostId}`; - } - private sendRemoteHostCommand(command: RemoteHostCommand): void { rawInvoke("remote_host_command", { payload: command }).catch((err) => console.error("[tauri-adapter] remote_host_command failed:", err), ); } - private remoteHostCommand(cmd: string, params?: unknown): Promise { - const rhId = this.nextRhId(); - return new Promise((resolve, reject) => { - // Bounded: a sidecar that died mid-command must reject rather than leave - // the console hook (or the device dialog) waiting forever. - const timer = setTimeout(() => { - this.remoteHostPending.delete(rhId); - reject(new Error(`remote host command timed out: ${cmd}`)); - }, REMOTE_HOST_COMMAND_TIMEOUT_MS); - this.remoteHostPending.set(rhId, { resolve, reject, timer }); - this.sendRemoteHostCommand({ rhId, cmd, params }); - }); - } - - private settleRemoteHostCommand(result: RemoteHostResult): void { - const pending = this.remoteHostPending.get(result?.rhId); - if (!pending) return; - this.remoteHostPending.delete(result.rhId); - clearTimeout(pending.timer); - if (typeof result.error === "string") pending.reject(new Error(result.error)); - else pending.resolve(result.result); - } - - private answerRemoteHostAsk(ask: RemoteHostAsk): void { - const handler = this.remoteHostResponders.get(ask?.op); - let results: unknown[] = []; - try { - results = handler ? handler(ask.params) : []; - } catch (err) { - console.error(`[tauri-adapter] remote host ask ${ask?.op} failed:`, err); - } - // Always answer, even with nothing: the service holds the ask open for its - // whole budget otherwise, and an attach waits on it. - this.sendRemoteHostCommand({ - rhId: this.nextRhId(), - cmd: "answer", - params: { rhId: ask.rhId, results }, - }); - } - // --- Alert management (local AlertManager) --- alertRemove(id: string): void { diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs index 697799c0..01139ff7 100644 --- a/vscode-ext/scripts/esbuild.mjs +++ b/vscode-ext/scripts/esbuild.mjs @@ -11,11 +11,13 @@ // (`standalone/scripts/tauri.mjs` + `csp.mjs`) so both Hosts widen the same way // with the same variable. See docs/specs/server.md → "Host webview CSP". -import { readFileSync } from 'node:fs'; - import * as esbuild from 'esbuild'; -import { resolveRemoteConnectSrc } from '../../scripts/csp-defaults.mjs'; +import { + assertConnectSrcBaked, + CONNECT_SRC_PLACEHOLDER, + resolveRemoteConnectSrc, +} from '../../scripts/csp-defaults.mjs'; const remoteSrc = resolveRemoteConnectSrc(process.env, 'esbuild'); @@ -33,7 +35,7 @@ const builds = [ ...common, entryPoints: ['src/extension.ts'], outdir: 'dist', - define: { __DORMOUSE_REMOTE_CONNECT_SRC__: JSON.stringify(remoteSrc) }, + define: { [CONNECT_SRC_PLACEHOLDER]: JSON.stringify(remoteSrc) }, }, { ...common, @@ -50,31 +52,5 @@ if (watch) { console.error('[esbuild] watching'); } else { await Promise.all(builds.map((options) => esbuild.build(options))); - assertConnectSrcBaked(); -} - -/** - * Fail the build if the `define` did not reach the bundle. - * - * `remote-host.ts` reads `__DORMOUSE_REMOTE_CONNECT_SRC__` as a `declare const`, - * so if the substitution is ever lost — someone re-inlines the esbuild call, or - * adds a bundle entry that pulls in that module without the define — TypeScript - * still compiles and the failure only appears at runtime, where the Host would - * silently fall back to the built-in default instead of the selfhoster's - * origins. The standalone sidecar bakes the same variable, so this side should - * fail on the same class of drift. - */ -function assertConnectSrcBaked() { - const bundle = readFileSync('dist/extension.js', 'utf8'); - if (bundle.includes('__DORMOUSE_REMOTE_CONNECT_SRC__')) { - throw new Error( - 'CSP: __DORMOUSE_REMOTE_CONNECT_SRC__ survived into dist/extension.js — the esbuild ' + - 'define did not apply, and the remote Host would use the built-in default sources.', - ); - } - if (!bundle.includes(remoteSrc)) { - throw new Error( - `CSP: dist/extension.js does not contain the resolved connect-src sources (${remoteSrc}).`, - ); - } + assertConnectSrcBaked('dist/extension.js', remoteSrc); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 10beae78..a01d00b2 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -614,7 +614,9 @@ export function attachRouter( } case 'peer:notify': if (typeof msg.topic !== 'string') break; - notifyDirectoryChanged(); + // The topic travels: what a webview announced is what the broker's + // watchers filter on, here and at the far end of the link alike. + notifyDirectoryChanged(msg.topic); remoteNotifyPeerChange(msg.topic); break; case 'remoteHost:command': diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index cca895c0..1035d702 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -61,8 +61,12 @@ import { log } from './log'; export interface PeerLinkDeps { /** Fan out to this window's own webviews — never to other windows. */ brokerRequest(op: string, params: unknown): Promise; - /** A peer window's answers may have changed, so the directory is stale. */ - invalidateDirectory(): void; + /** + * A peer window's answers may have changed, so the directory is stale. + * `topic` is the webview's own word for what changed where there is one; a + * membership change carries none, and is always the directory's business. + */ + invalidateDirectory(topic?: string | null): void; onProcessedPtyData(listener: (id: string, data: string) => void): () => void; onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => void; writePty(ptyId: string, data: string): void; @@ -367,7 +371,7 @@ function onServerFrame(client: PeerLinkClient, frame: unknown): void { return; } if (response.kind === 'notify') { - deps?.invalidateDirectory(); + deps?.invalidateDirectory(response.topic); return; } if (response.kind === 'command') { diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index 7a7fa208..ac4439ba 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -16,32 +16,39 @@ import type * as vscode from 'vscode'; import type { HostAclRecord, HostStateStore } from '../../lib/src/host/remote/host-state-store'; -import { ACL_KEY_PREFIX } from '../../lib/src/remote/host/acl'; -import type { HostEnrollment } from '../../lib/src/remote/host/enrollment'; +import { ACL_KEY_PREFIX, filterAclRecords } from '../../lib/src/remote/host/acl'; +import { isEnrollment, type HostEnrollment } from '../../lib/src/remote/host/enrollment'; // Imported, not mirrored: a key that drifted between the two sides would strand // an enrollment that is still on disk. import { ENROLLMENT_KEY } from '../../lib/src/remote/host/store'; -function isEnrollment(value: unknown): value is HostEnrollment { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - return ( - typeof v.serverUrl === 'string' && - typeof v.hostId === 'string' && - typeof v.hostToken === 'string' && - typeof v.origin === 'string' && - typeof v.rpId === 'string' - ); -} - export class VsCodeHostStateStore implements HostStateStore { readonly #context: vscode.ExtensionContext; + #enrollment: Promise | null = null; constructor(context: vscode.ExtensionContext) { this.#context = context; } async loadEnrollment(): Promise { + // Read once and keep it, like `FileHostStateStore`: `SecretStorage` is a + // keychain round trip, this extension host is the only writer of the key, + // and the activation probe and the service both want the same answer. + this.#enrollment ??= this.#readEnrollment(); + return this.#enrollment; + } + + async saveEnrollment(enrollment: HostEnrollment): Promise { + await this.#context.secrets.store(ENROLLMENT_KEY, JSON.stringify(enrollment)); + this.#enrollment = Promise.resolve(enrollment); + } + + async clearEnrollment(): Promise { + 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 { @@ -54,14 +61,6 @@ export class VsCodeHostStateStore implements HostStateStore { } } - async saveEnrollment(enrollment: HostEnrollment): Promise { - await this.#context.secrets.store(ENROLLMENT_KEY, JSON.stringify(enrollment)); - } - - async clearEnrollment(): Promise { - await this.#context.secrets.delete(ENROLLMENT_KEY); - } - async loadAcl(hostId: string): Promise { const raw = this.#context.globalState.get(aclKey(hostId)); if (typeof raw !== 'string') return []; @@ -72,12 +71,7 @@ export class VsCodeHostStateStore implements HostStateStore { return []; } if (!Array.isArray(parsed)) return []; - // `HostAcl.fromRecords` rejects a mismatched hostId, so drop foreign rows - // rather than fail the whole load over one. - return parsed.filter( - (record): record is HostAclRecord => - !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, - ); + return filterAclRecords(hostId, parsed); } async saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { diff --git a/vscode-ext/src/remote-host.ts b/vscode-ext/src/remote-host.ts index f9db9c50..aa33a99a 100644 --- a/vscode-ext/src/remote-host.ts +++ b/vscode-ext/src/remote-host.ts @@ -20,7 +20,11 @@ import type * as vscode from 'vscode'; -import { DEFAULT_REMOTE_CONNECT_SRC } from '../../lib/src/host/remote/connect-src'; +import { + createAskSurfaceProvider, + type AskSurfaceProvider, +} from '../../lib/src/host/remote/ask-surface-provider'; +import { bakedConnectSrc } from '../../lib/src/host/remote/connect-src'; import { RemoteHostService } from '../../lib/src/host/remote/service'; import { REMOTE_HOST_EVENT_EVENT, @@ -28,12 +32,7 @@ import { type RemoteHostCommand, type RemoteHostResult, } from '../../lib/src/host/remote/service-protocol'; -import type { - DirectoryEntry, - HostSurfaceProvider, - SurfaceHandle, -} from '../../lib/src/remote/host/host-surface-provider'; -import type { PeerSurfaceResult } from '../../lib/src/remote/host/peer-surfaces'; +import type { HostSurfaceProvider, PtySink } from '../../lib/src/remote/host/host-surface-provider'; import type { ExtensionMessage } from './message-types'; import { broadcastUiEvent, @@ -51,14 +50,6 @@ import { import { VsCodeHostStateStore } from './remote-host-store'; import { log } from './log'; -/** - * Remote-server `connect-src` sources, substituted by esbuild at build time - * (`scripts/esbuild.mjs`). Declared rather than imported so the value is a - * literal in the bundle and cannot be changed at runtime. The service refuses - * to enroll with, or connect to, anything outside it. - */ -declare const __DORMOUSE_REMOTE_CONNECT_SRC__: string; - /** * What this module needs from the router, injected rather than imported: the * router routes commands here, so importing back would be a cycle. @@ -81,8 +72,13 @@ export function configureRemoteHost(next: RemoteHostDeps): void { } 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; -const directoryWatchers = new Set<() => void>(); +let askProvider: AskSurfaceProvider | null = null; /** * Ask both tiers at once and concatenate what they answer, this window's @@ -117,65 +113,7 @@ async function askBothTiers( * terminal on the machine rather than the broker window's alone. */ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProvider { - return { - async collectDirectory(): Promise { - // Each webview answers with its whole snapshot, so the results *are* the - // entries — no per-webview merging to do on this side. - return (await askBothTiers(bound, 'directory', {})) as DirectoryEntry[]; - }, - - 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 out of both tiers is the answer. - const [owner] = (await askBothTiers(bound, '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 askBothTiers(bound, 'surfaceOp', { - surfaceId, - op: 'resize', - cols: nextCols, - rows: nextRows, - })) 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: () => {}, - }; - }, - + askProvider = createAskSurfaceProvider((op, params) => askBothTiers(bound, op, params), { // The link takes only a PTY it has a route for, and a route is placed only // by an attach another window answered — so a PTY of this window's own can // never be taken out from under the manager that owns it. @@ -194,34 +132,80 @@ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProv remoteSubscribe(ptyId, sink); return () => remoteUnsubscribe(ptyId, sink); } - // 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. - const offData = bound.onProcessedPtyData((id, data) => { - if (id === ptyId) sink.onData(data); - }); - const offExit = bound.onProcessedPtyExit((id, exitCode) => { - if (id === ptyId) sink.onExit(exitCode); - }); - return () => { - offData(); - offExit(); - }; + return streamLocalPty(bound, ptyId, sink); }, + }); + return askProvider.provider; +} + +/** Sinks on this window's own PTYs, keyed by the id they are watching. */ +const localStreams = new Map>(); +let stopLocalListeners: (() => void) | null = null; + +/** + * Stream a PTY this window owns, through one listener pair for the whole window + * rather than one per attachment: these run on every chunk of every terminal in + * the window, so a listener per attachment would tax every keystroke of every + * PTY once per attached surface. + * + * 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. + */ +function streamLocalPty(bound: RemoteHostDeps, ptyId: string, sink: PtySink): () => void { + let sinks = localStreams.get(ptyId); + if (!sinks) { + sinks = new Set(); + localStreams.set(ptyId, sinks); + } + const subscribed = sinks; + subscribed.add(sink); + + if (!stopLocalListeners) { + const offData = bound.onProcessedPtyData((id, data) => { + const targets = localStreams.get(id); + if (!targets) return; + for (const target of targets) target.onData(data); + }); + const offExit = bound.onProcessedPtyExit((id, exitCode) => { + const targets = localStreams.get(id); + if (!targets) return; + // Iterated live rather than copied: an exit tears its own attachment + // down, which a Set tolerates mid-iteration. + for (const target of targets) target.onExit(exitCode); + }); + stopLocalListeners = () => { + offData(); + offExit(); + }; + } + + return () => { + subscribed.delete(sink); + if (subscribed.size > 0) return; + localStreams.delete(ptyId); + // Nothing attached: back to costing this window's terminals nothing. + if (localStreams.size > 0) return; + stopLocalListeners?.(); + stopLocalListeners = null; }; } -/** Something the directory depends on changed: a pane, an alert, a webview. */ -export function notifyDirectoryChanged(): void { - for (const watcher of [...directoryWatchers]) watcher(); +/** + * Something a future directory answer could depend on changed: a pane, an + * alert, a webview, a peer window. `topic` is a webview's own word for what + * changed; a change with no topic is always the directory's business. + */ +export function notifyDirectoryChanged(topic?: string | null): void { + askProvider?.notifyDirectoryChanged(topic); } function startService(): void { if (service || !context || !deps) return; const bound = deps; service = new RemoteHostService({ - store: new VsCodeHostStateStore(context), + store: hostStateStore(context), provider: createRemoteHostProvider(bound), sendToUi: (event, data) => { if (event === REMOTE_HOST_RESULT_EVENT) { @@ -234,12 +218,7 @@ function startService(): void { broadcastUiEvent(data); } }, - // The `typeof` guard is for the test runner, which has no esbuild define; - // a real build substitutes both halves with the baked literal. - connectSrc: - typeof __DORMOUSE_REMOTE_CONNECT_SRC__ === 'string' - ? __DORMOUSE_REMOTE_CONNECT_SRC__ - : DEFAULT_REMOTE_CONNECT_SRC, + connectSrc: bakedConnectSrc(), }); void service.start().catch((error: unknown) => { log.error(`[remote-host] failed to start: ${String(error)}`); @@ -368,7 +347,7 @@ function refuse(rhId: string): void { */ export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable { context = ctx; - void new VsCodeHostStateStore(ctx) + void hostStateStore(ctx) .loadEnrollment() .then((enrollment) => { if (enrollment) return contendForHost(); @@ -381,9 +360,16 @@ export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable dispose() { service?.dispose(); service = null; - directoryWatchers.clear(); + askProvider = null; commandRoutes.clear(); + store = null; context = null; }, }; } + +/** The window's one store, made on first use. */ +function hostStateStore(ctx: vscode.ExtensionContext): VsCodeHostStateStore { + store ??= new VsCodeHostStateStore(ctx); + return store; +} From 39ce162d48f4ca91b0ba01ac9a91427e08a3f66a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 01:50:29 -0700 Subject: [PATCH 32/56] Bring the remote specs onto the service architecture vscode.md's Remote Host sections now describe what is built: the shared RemoteHostService in the extension host, enrollment in SecretStorage and the ACL in globalState read in-process, bind-as-lease with its invariants (the bind is the arbitration, roles never flip downward, corpse-clearing re-checks the inode), webviews as responders plus UI over the RemoteHostLink bridge, enrollment-gated volunteering, and the cross-window tier with command forwarding. standalone.md gains the sidecar service section (state dir, the rhId-not-requestId rule, the per-PTY strip parser and why responses are discarded). server.md's CSP section becomes the baked relay-origin allowlist enforced at enroll/connect in both Node hosts. remote-api.md states where the Host runs and the one-snapshot-per-collect directory; transport.md tables the per-host bridge dialects; alert.md documents the push detection/delivery split and that a webview cannot choose recipients. Also caught by the sweep: remote-security-model.md still said the ACL persists in webview localStorage. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 +- docs/specs/alert.md | 8 +- docs/specs/remote-api.md | 36 ++++++- docs/specs/remote-security-model.md | 5 +- docs/specs/server.md | 146 ++++++++++++++++++---------- docs/specs/standalone.md | 113 ++++++++++++++++++--- docs/specs/transport.md | 11 ++- docs/specs/vscode.md | 125 ++++++++++-------------- 8 files changed, 300 insertions(+), 151 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c71e22b..7b1269d6 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, 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 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/docs/specs/alert.md b/docs/specs/alert.md index 62d92cd4..8eedc5ae 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -167,19 +167,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. No generation fence is needed on top of that: the service reads its own ACL at request time, and a Host that stopped answers `no-host` like any other state. ### Settings dialog diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index cd27a317..82f17a64 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -53,6 +53,27 @@ 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. + ## Terminology `docs/specs/glossary.md` is canonical for **Pane** and **Surface**; the wire @@ -164,10 +185,17 @@ 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. -In VS Code, peer webviews and windows signal directory invalidation whenever -their pane state, activity, focus, or membership changes. The Host feeds that -signal through the same coalescer and re-queries all peers before sending the -replacement snapshot. +**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. + +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; diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 154ddd75..233dd525 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 { diff --git a/docs/specs/server.md b/docs/specs/server.md index 16bfe1a4..bf361449 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -63,36 +63,53 @@ and `readConfig` is pure. clientData checks, passkey assertion verification, and the Host enrollment policy all use that normalized origin. -## Host webview CSP (self-host builds) +## Where a Host may reach a relay server (self-host builds) -Both Hosts — the standalone Tauri app and the VS Code extension — render the -webview that holds the relay socket, so in both the webview `connect-src` bounds -where the Host can reach a relay server. Both default to the SaaS origin only -and take the same build-time override, `DORMOUSE_REMOTE_CONNECT_SRC`: +> 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 ``` -The standalone path is described below; the VS Code path substitutes the sources -into the extension bundle at build time (`docs/specs/vscode.md` → "CSP policy"), -and the rest of that Host's selfhost story — where its enrollment and ACL live, -and which webview owns the socket — is in `docs/specs/vscode.md` → "Remote Host: -store and lease". - -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. +`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. + +**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. + +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. The default is deliberately not internet-wide — widening it is an +explicit, per-build opt-in. Reserved: the `https://*.dormouse.sh wss://*.dormouse.sh` entries are *wildcards* on purpose. The BYOT posture (`## Future`, Scope: saas-multitenant) @@ -115,8 +132,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 @@ -319,7 +337,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`). @@ -398,14 +416,25 @@ 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. * **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 @@ -419,19 +448,28 @@ A `remote-host` module in `lib`, active in standalone: `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; + `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, request, requestedAt }[]`, pushed + whole on every change) and answer by `clientId`, so the approve/deny closures + never leave the Host's process. 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 @@ -504,8 +542,10 @@ once from the devtools console of the standalone webview: 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 @@ -585,16 +625,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 0a1041b4..5b070acb 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,16 +40,23 @@ 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 AppBar dropdown and +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 AppBar dropdown and `setDefaultShellOpts` (the default-shell slot used by split/spawn/restore paths, `docs/specs/layout.md`). -5. `resumeOrRestore(platform)` runs the priority-based recovery from +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 @@ -63,7 +71,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 @@ -105,6 +114,78 @@ 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. +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 — usable for the session, nothing +survives a restart (the browser dev harness takes the same path). + +**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. + +**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. + +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 the tap returns on the first line — the usual +state of a machine with no phone on it. + +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`, @@ -154,7 +235,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 @@ -447,8 +530,9 @@ root `package.json` for the `dev:standalone*` orchestration. 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 (`docs/specs/server.md`, Host webview - CSP). + 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`). @@ -467,7 +551,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) | @@ -481,5 +565,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/transport.md b/docs/specs/transport.md index aa0c33b1..7a7bab8b 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -106,7 +106,16 @@ 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. -Host-owned storage and peer coordination are VS Code-only additions to the adapter surface, both optional on `PlatformAdapter`. `hydrateScopedStore(prefix)` (`store:read` → `store:entries`, then fire-and-forget `store:write`) moves every key under one prefix into extension-host storage and installs a synchronous write-through cache over it, because `local-json-store` is synchronous by contract and the remote Host's bearer credential must not sit in webview `localStorage`. `peers` is present only on a host that can show several webviews over one backend, and carries both halves of that condition: `claimSingleton(name, onChange)` (`singleton:claim` → `singleton:lease`) asks the host to arbitrate a role that at most one webview may hold, since only the extension host sees every webview, and a generic `request` / `respond` / `streamPty` seam reaches surfaces the other webviews own (`docs/specs/vscode.md` → "Peer surfaces"). Adapters that omit either are single-instance with local storage, which is correct for standalone and the website. Both are prefix/name gated on the host side — the webview names the key, so the host decides what that name may reach. See `docs/specs/vscode.md` → "Remote Host: store and lease". +**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 for a topic may differ), 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 { topic }` | +| 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`). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index efbb72e1..ecbd2074 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -22,12 +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-store.ts — SecretStorage/globalState backing for the webview's remote-Host keys -├── window-lease.ts — cross-window Host lease: heartbeat record in globalStorageUri -├── peer-link.ts — socket between windows: broker serves, other windows report in -│ (peer-surface brokering lives in message-router.ts) -├── watch-dir-file.ts — fs.watch on one file, degrading to no watcher instead of an uncaught error -├── (../scripts/esbuild.mjs) — outside src/: extension + pty-host bundles; bakes the webview's remote `connect-src` +├── 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 @@ -220,7 +219,9 @@ 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 remote-server `connect-src` sources are a build-time constant, not a runtime value: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into the bundle, defaulting to the SaaS origin (`https://*.dormouse.sh wss://*.dormouse.sh`). Without them a VS Code Host cannot hold its `/ws/host` socket at all. 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` → "Host webview CSP"). It is a `declare const` rather than an import so the value is a literal in the bundle and nothing at runtime can move it. +**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. @@ -245,120 +246,102 @@ 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: store and lease +### Remote Host: a service in the extension host -VS Code is a first-class remote Host. Two things have to be true that standalone gets for free, because standalone is one webview per app and VS Code is many webviews over one 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. -**The store.** The Host's enrollment (`{ serverUrl, hostId, hostToken, origin, rpId }`) and its ACL persist through `local-json-store`, which defaults to `localStorage`. That is wrong here twice over: webview `localStorage` is not VS Code's persistence story, and `hostToken` is a bearer credential that grants the `/ws/host` socket. So the webview claims the `dormouse.remote-host.` prefix and backs it with the extension host — enrollment in `SecretStorage` (OS keychain), ACL in `globalState`, both global because a Host identity belongs to the machine and not to a folder. +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`). -`local-json-store` is synchronous by contract, so the store is pulled across at boot and installed as an in-memory, write-through backend. First paint deliberately does not wait on it: the read is gated on an OS keychain unlock, and a blank terminal for that long reads as a hang. The real constraint is narrower — hydrated before anything reads a `dormouse.remote-host.` key — so `lib/src/main.tsx` starts the read and publishes it with `setHostStoreReady`, and the lazily-mounted `RemotePairingModalHost` awaits `hostStoreReady()` before calling `installRemoteHostConsoleHook`. A read that never answers installs an empty cache and warns: the Host reads as un-enrolled, which is fail-safe for the data but would otherwise be silent. +**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. -A broadcast that lands while a webview is still hydrating is buffered and applied on top of the snapshot, because the host reads `globalState` before it waits on the keychain — so the snapshot in flight can be older than a write that has already committed. +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. -Both sides gate on the prefix. The webview names the keys, so `remote-host-store.ts` refuses any key outside the Host namespace and caps values at 64 KiB; a compromised webview can neither read nor write unrelated extension state. +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. -A boot-time snapshot alone would be wrong, because the lease hands the Host between webviews and windows: a webview that hydrated before another approved a pairing could later take the lease, read its stale ACL, and write that back — dropping the pairing permanently. A committed write is therefore broadcast to every webview in its window (`store:changed`) and applied to each cache. Before a newly elected window grants the webview-level Host role, it also rereads the whole prefix and sends every webview a replacement `store:snapshot`; the snapshot is ordered before the `singleton:lease { held: true }` grant and clears keys deleted by the previous holder. The per-write broadcast goes to the writer too; re-applying your own write is a no-op, and skipping self would mean identifying it. Only writes that actually happened are announced, which is why `writeStore` returns whether it wrote. +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`. -Source of truth: `vscode-ext/src/remote-host-store.ts`, `lib/src/lib/platform/vscode-adapter.ts` (`hydrateScopedStore`), `lib/src/lib/local-json-store.ts` (prefix claims), `lib/src/remote/host/store.ts` (the shared prefix). +**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. -**The lease.** A window can show a `WebviewView` and any number of `WebviewPanel`s at once. Each mounts the same Wall, so each would start its own `RemoteHost` against the same enrollment — they would displace each other on the single `/ws/host` socket (`server/test/relay-displaced.test.mjs`) and each would arm its own alarm push. The extension host arbitrates instead, because it is the only party that sees every webview and outlives each one: `message-router.ts` grants the named role `remote-host` to the first claimant and re-offers it when the holder is disposed, so closing the Dormouse view hands the Host to another open one rather than dropping it until a reload. +Arbitration is therefore the socket itself: **the bind is the lease**. Every contending window tries to bind one fixed path — `dormouse-peer-.sock` 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. -On the webview side `activation.ts` starts un-owned whenever the adapter offers `peers`, so two webviews racing to mount cannot both activate before the first answer arrives. Adapters without it (standalone, the website) are single-instance and stay owned from the start. Having peers at all is exactly the condition that needs arbitrating, which is why the role lease and the sibling RPC hang off one optional member (`PeerBridge`) rather than two that a host could implement half of. +The invariants are what make this simpler than the heartbeat lease it replaced: -**Across windows.** The election above is per-window, because the extension host is — but the enrollment it guards is machine-wide, so window-local arbitration alone is not enough. Left there, every window would elect its own Host, all of them would connect `/ws/host` with the same enrollment, 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. +- **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), so unlink and bind again. Unlinking is safe precisely because a live broker would have accepted that connection. But two windows can find the same corpse, both unlink, and the second bind silently displaces the first — leaving the loser serving an inode no client can reach. Nothing on the bind path detects that, so `stillOurs` re-stats the path after `RECLAIM_VERIFY_MS` and compares inodes; a window whose inode was replaced stands down rather than run a second Host. (Windows named pipes cannot reach this: a pipe dies with its process.) +- **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. -So there is a second tier. A window may grant the role only while it holds a lease recorded in the extension's `globalStorageUri` — per-extension, shared by every window, and (unlike `globalState`) with no cross-window change event to depend on, so ownership is a heartbeat with a TTL rather than a flag. The holder re-stamps every 5s; a record unstamped for 15s is free. That TTL is what recovers the role from a window that died without running its disposables; a clean dispose deletes the record so the handoff is prompt, and a filesystem watcher makes the next window notice without waiting for its poll. Both watchers in the extension — this one and the rendezvous — go through `watch-dir-file.ts`, which turns either kind of `fs.watch` failure (refused up front, or an `'error'` event later, which an unheard `EventEmitter` rethrows and would kill the extension host) into no watcher at all; that is safe precisely because each caller's timer converges on its own. -The watcher is only an accelerator: construction failures and later asynchronous -`error` events close and clear it, while the interval continues to arbitrate. +Trust is the same bar as the `dor` control socket: a user-owned unix socket (or named pipe) plus a token a client must present in its first frame, read from a mode-0600 `remote-host.peer-token` in `globalStorageUri`. It is 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 — and it is compared in constant time (`tokenMatches`). A first frame that is not a matching hello drops the socket. -A fresh claim is confirmed by re-reading: two windows can judge the same record stale in the same instant and both write, and the loser must not believe it won. Renewing skips that round trip, since the record already named the renewer. A heartbeat stamped far in the *future* counts as stale too — otherwise a clock jump would lock every window out of the role until the skew elapsed. +**Nothing starts until there is a Host to run.** Contention begins when activation finds an enrollment in `SecretStorage`, or on the first `enroll` command from any webview — the bootstrap for an un-enrolled machine, which calls the idempotent `ensurePeerNet()` first and then re-checks (if that settles as a client, another window enrolled first and the command belongs to it). 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. -Losing the window lease is not merely losing the right to be re-offered the role: any webview holding it is told `held: false` and stops its Host. That is the one path that sends a revocation, and it is why the lease is a boolean rather than a one-way grant. +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`. -Nothing here starts until a hydrated webview finds a persisted enrollment, or -a first enrollment succeeds and initiates the claim. A user who never enrolls -a Host therefore gets no heartbeat file, timer, or peer socket merely by -opening Dormouse. +**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. -Source of truth: `ensurePeerNet` in `vscode-ext/src/peer-link.ts` (tested in `vscode-ext/test/peer-link.test.ts`), and the service it gates in `vscode-ext/src/remote-host.ts`. +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. -Source of truth: the `SingletonClaimant` arbiter in `vscode-ext/src/message-router.ts`, `PeerBridge.claimSingleton` in `lib/src/lib/platform/types.ts`, `setRemoteHostOwnership` in `lib/src/remote/host/activation.ts`, tested in `lib/src/remote/host/activation.test.ts`. +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 }`. -**Lifetime.** The Host lives as long as a Dormouse webview exists in the window. `retainContextWhenHidden: true` is set on both hosting modes, so hiding the panel keeps it connected; only disposing every Dormouse view, or closing the window, takes it offline. +**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. -### Peer surfaces +**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. -The Host runs in one webview, but a window's terminals are spread across all of them: each webview is its own JS realm with its own xterm registry (`lib/src/lib/terminal-store.ts`). Left alone the phone would see one webview's panes — not the window's — because `collectDirectorySnapshot` iterates the local registry and `surface.attach` resolves against it. +### Peer surfaces -The extension host brokers, since it is the only party that can see every webview. Three things make it work, and two of them were already true: +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. -- **PTY input and resize are not ownership-gated.** `pty:input` and `pty:resize` go straight to `ptyManager`, so the Host webview can already drive a sibling's PTY. -- **Pane ids are unique across webviews.** They are minted `pane--` (`lib/src/components/Wall.tsx`), so surface ids need no namespacing to be routed. -- **Streaming needed one change.** `pty:data` and `pty:exit` were delivered only to the owning webview; a webview may now also `pty:subscribe` to a PTY it does not own. Subscriptions are tracked separately from `ownedPtyIds`, so they never affect Workspace union status, `killOnDispose`, or who the host considers the owner. Semantic events stay owner-only — they drive the owner's pane state, and a subscriber is streaming bytes plus process lifetime, not keeping a second copy of that state. +`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 { topic }`. 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 a responder (`lib/src/remote/host/peer-surfaces.ts`) whether or not it is the Host, so its terminals are reachable from whichever one is. It carries none of the relay, enrollment, or pairing machinery — a registry lookup, the directory collector, and a resize. +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, and a resize. **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. -Absence *is* the miss: 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, which is what lets the broker settle a fan-out as fast on a miss as on a hit; it settles when all of them have replied or a 1s budget expires, so a webview with no live content cannot hang the picker. +**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 `PEER_REPLY_BUDGET_MS` (= the service's `ASK_BUDGET_MS`, 1s) budget expires, so a webview mid-reload cannot hang an attach or the phone's picker. A webview disposed mid-fan-out is removed from the outstanding set, which can settle the request immediately. 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. -Peer query results are snapshots, so the same bridge carries generic topic -invalidations. Every webview announces `directory` when local pane state, -activity, or focus changes; webview/window membership changes invalidate all -topics. The Host subscribes to that topic and coalesces a fresh fan-out rather -than retaining the old directory indefinitely. +Directory answers are snapshots, so the same seam carries invalidation. A webview announces the topic `directory` when its pane state, activity, or focus changes; a membership change (a webview attaching or disposing, a peer window joining or dropping) carries no topic and is always the directory's business. `notifyDirectoryChanged` fans that to the service's watchers, which coalesce a fresh collect rather than retaining the old directory. -`attach` and `resize` on a foreign surface go to the owner rather than to the PTY, because attach-is-the-resize has to drive the live xterm or the owning pane's own view drifts from the size the phone set. The owner replies with the size it settled at and the `ptyId`; the Host then subscribes and streams. `detach` has nothing to undo on the owner — the Host stops streaming and the pane keeps its size, which is what last-attach-wins means. +**Attach-is-the-resize goes through the live xterm.** `attach` and `resize` are the same 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. 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` — `ptyId`, the size it stands at, `resize`, `release` — or `null` if nobody owns it, and `remote-api.ts` holds one of those per attachment. That is the same trick the rest of the feature already plays: foreign `pty:data` is injected into the ordinary data path and `pty:input` / `pty:resize` route by table before falling back to the local manager, so `terminal.write` has no branch either. It makes local attach asynchronous too, which is the honest shape — a pane in another window *is* a round trip away, and the alternative was one path that answered synchronously and one that did not. +**Which webview owns a pane never reaches the protocol layer.** `resolveSurface(surfaceId, size)` answers with a `SurfaceHandle` — `ptyId`, 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 has one owner, so the first answer is the answer. 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. -Resolving a peer's surface *is* the attach: the requested size travels with it, because the owner has to apply it inside that round trip — there is no reaching into its xterm afterwards without a second one. A local pane is left alone at resolve and resized by the caller, which subscribes to the PTY first so a synchronous repaint is not lost. Either way the handle reports the size as it stands and the caller reconciles, which is why the same-size repaint bounce fires for a peer attach (its owner already applied the size) and the resize path fires for a local one. +**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`.) -Subscribing is a subscription, not a pair of calls: `peers.streamPty(ptyId)` returns its own unsubscribe, so a caller cannot leak a stream by losing track of the id it opened it with. -The router reference-counts those handles per PTY: only zero-to-one starts -cross-window forwarding and only one-to-zero stops it, so detaching one of two -concurrent viewers cannot silence the other. -Router disposal releases every still-counted cross-window PTY once before -clearing the counts, so document teardown cannot leave an owner forwarding to -a webview that no longer exists. - -The directory emits **twice**: the local entries immediately, then a merged snapshot once the peers answer. The phone should not wait on a round trip to see the panes that are already here. +Local streams go through **one listener pair for the whole window**, dispatching by id to the sinks registered for it, rather than one listener per attachment: these run on every chunk of every terminal, so per-attachment listeners would tax every keystroke of every PTY once per attached surface. The pair is installed on the first attachment and removed when the last one goes, so a window with no phone on it pays nothing. ### 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 window holding the Host lease therefore listens on a local socket and every other window connects to it. +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. -The lease makes this one-directional. Because the webview lease is gated on the window lease, the broker window *is* the Host window — so the broker never has to relay a request back out to a remote Host, and a peer window only ever answers. +**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 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, and a surface id is unique across every window. Within the remote tier, all peers are asked at once for the same reason. -Roles follow the lease: acquire it and the window starts serving and publishes a rendezvous file (`remote-host.peer.json`, mode 0600, in `globalStorageUri`) naming the socket path and a token; lose it and the window tears the server down and connects as a client instead. Clients watch that file, so a handover does not wait out the reconnect backoff. Neither transition is instant — serving binds a socket and then writes and renames the rendezvous, standing down tears that back down — so a flip can land inside one, and each direction re-checks the role it is transitioning into before its last step: the broker claims the server slot in the same tick it decides to serve and abandons a half-started server rather than publishing a rendezvous naming a socket the teardown already unlinked (peers would dial it, fail, and back off until a later broker rewrote the file), and the client side skips installing the rendezvous watcher if it is the broker again by the time its teardown finishes (a broker watching would wake on its own writes). The socket lives in the temp dir rather than beside the rendezvous file because macOS caps a unix socket path near 104 bytes and the extension's `globalStorage` path is most of that on its own. +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). -The first arbitration result is a role transition even when it is `false`: a -window that starts while another owns the lease immediately enters the client -role and watches/connects to that broker. +**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. -A peer 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 `configurePeerLink` is handed only `brokerRequest`, and why the link is injected with what it needs rather than importing the router (which imports the link). +**Cross-window streams are reference-counted per 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 last unsubscribe also drops the route, which a later attach re-places from the owner's answer. -Both tiers are asked at once rather than one after the other: what is asked about lives in exactly one webview of one window, and asking in series would pay a whole tier's budget — or a hung window's — before reaching the tier that owns it. +Once an answer names a `ptyId` the broker records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `writePty` / `resizePty` consult that table and fall back to this window's `ptyManager` — the link takes only a PTY it has a route for, and a route is placed only by an attach another window answered, so a local PTY can never be taken out from under the manager that owns it. When a peer disconnects, every PTY 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. -Once an answer names a `ptyId` the broker records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `pty:input` and `pty:resize` consult that table first and fall back to the local `ptyManager`; `pty:subscribe` asks the owning window to start streaming, and its bytes are injected into the subscriber's normal `pty:data` path, so the Host webview cannot tell a remote terminal from a local one. When a peer disconnects, every PTY routed to it is dropped and reported as exited — 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. -Trust: the socket is user-owned, its path is published only in a mode-0600 file, and a client's first frame must carry the token from that file — the same bar as the `dor` control socket. +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. -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. +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 service and no broker to dial refuses a command with an error rather than dropping it, so the console hook fails fast instead of hanging for its whole timeout. -Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and roles, `lib/src/lib/vscode-peer-link-protocol.ts` for the frames, framing, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`), and the `remote*` calls in `vscode-ext/src/message-router.ts`. +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: the broker in `vscode-ext/src/message-router.ts` (`brokerRequest`, the `peer:*` cases, `subscribedPtyIds`), `PeerBridge` in `lib/src/lib/platform/types.ts` with its VS Code implementation in `vscode-adapter.ts`, the operation map and responder in `lib/src/remote/host/peer-surfaces.ts`, the resolver in `vscode-ext/src/remote-host.ts`, and the attachment it backs in `lib/src/remote/host/remote-api.ts`, tested in `lib/src/remote/host/peer-surfaces.test.ts`. +Source of truth: `vscode-ext/src/peer-link.ts` for the sockets, arbitration, and frames in flight; `lib/src/lib/vscode-peer-link-protocol.ts` for the frame shapes, framing, budget, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`); `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`: `test/window-lease.test.ts` drives two module instances against a real directory (two windows contending, and a handover on dispose), and `test/peer-link.test.ts` stands up a broker and a peer over a real socket to cover the rendezvous handshake, a lease that flips back mid-startup, PTY routing, streaming, token rejection, and what a disconnect does to in-flight terminals. Separate module instances come from `vi.resetModules()` plus a dynamic import, which is what makes one process able to play two windows. +The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`. `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, handing the Host to a surviving window when the broker dies), cross-window directory and surface ops, PTY routing and streaming with two viewers, token rejection, and what a disconnect does to in-flight terminals and forwarded commands. `test/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), the enroll bootstrap, command forwarding and answering, and the provider's streaming, asking, and directory invalidation. `test/helpers.ts` holds what both need — a throwaway `globalStorageUri`, 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`. From c0c4642f03331efeadce2cc6b8669316b42a9c29 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 05:58:49 -0700 Subject: [PATCH 33/56] Document the dev-loop allowlist override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baked default allowlist carries no localhost entry and http/ws is a different scheme class from https/wss, so a default build's Host refuses to enroll against the plaintext local dev server — the end-to-end loop in server.md needs DORMOUSE_REMOTE_CONNECT_SRC at build time, which dev:standalone picks up because it re-stages the sidecar bundles. Also sharpens the ephemeral-store fallback description and notes that the browser dev harness runs a real Host against a per-run temp state dir. Co-Authored-By: Claude Fable 5 --- docs/specs/server.md | 27 ++++++++++++++++++++++----- docs/specs/standalone.md | 6 ++++-- docs/specs/transport.md | 4 +++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index bf361449..3098927f 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -107,9 +107,17 @@ 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. The default is deliberately not internet-wide — widening it is an -explicit, per-build opt-in. +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) @@ -535,8 +543,17 @@ 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') diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 5b070acb..bfdc4b3a 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -134,8 +134,10 @@ 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 — usable for the session, nothing -survives a restart (the browser dev harness takes the same path). +sidecar falls back to an ephemeral store — a Host can be enrolled and used for +the session, but nothing survives a restart, and it warns once when a write is +dropped. 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", diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 7a7bab8b..6b13c504 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -37,7 +37,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 From c28dc587d97f5a87d3aed30f77ec288b18b8ad84 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 07:36:58 -0700 Subject: [PATCH 34/56] Fix fifteen verified findings from the adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two that mattered most: the extension-host Host now falls back to the bundled ws implementation where the runtime has no global WebSocket (engines ^1.85 spans Node versions without one — the service silently never connected there), and the peer link no longer trusts whoever binds the socket path. The path lives in a per-user 0700 directory, lstat-verified, and the plaintext-token hello is replaced by a mutual challenge handshake: the server speaks first, each side proves token knowledge with a domain-separated HMAC over the other's fresh nonce, proofs compare in constant time, and a client serves nothing until the welcome verifies — so a squatter that takes the path learns nothing and drives nothing. The service lifecycle is serialized, so a start racing an adopt can no longer build a second unstoppable relay socket. Adopt persists ACL before enrollment, refuses disallowed origins, and reports whether it persisted — the webview keeps its copy otherwise, and the ephemeral store now really runs a session Host in memory. The enrollment memo invalidates on secrets.onDidChange, which also lets an un-enrolled window join the contention when another window enrolls. Client windows arm reliably: commands queue while contention settles, and the broker hands each joining window the current status. Also fixed: the cross-window ask budget again exceeds the fan-out it contains (the unification had inverted a documented invariant); routes survive unsubscribe so attach-over-attach cannot orphan itself; stale directory collects can no longer overwrite fresh ones; the pairing mirror re-renders when the service replaces a request in place, so the modal always shows exactly what approval would write; store writes are serialized onto unique temp files; color queries are swallowed instead of leaking to the phone; the push-devices dialog resets on un-enroll; and the frame decoder drains complete frames before discarding an oversized one. Co-Authored-By: Claude Fable 5 --- docs/specs/alert.md | 2 +- docs/specs/remote-api.md | 6 +- docs/specs/server.md | 11 +- docs/specs/standalone.md | 18 +- docs/specs/terminal-escapes.md | 2 + docs/specs/vscode.md | 34 +- lib/src/host/remote/host-state-store.test.ts | 87 +++- lib/src/host/remote/host-state-store.ts | 95 ++++- lib/src/host/remote/pty-strip.test.ts | 14 +- lib/src/host/remote/pty-strip.ts | 20 +- lib/src/host/remote/service-protocol.ts | 12 + lib/src/host/remote/service.test.ts | 101 ++++- lib/src/host/remote/service.ts | 94 ++++- lib/src/lib/push-devices.ts | 7 +- lib/src/lib/vscode-peer-link-protocol.test.ts | 44 +- lib/src/lib/vscode-peer-link-protocol.ts | 102 ++++- .../remote/host/RemotePairingModalHost.tsx | 6 +- lib/src/remote/host/activation.test.ts | 86 +++- lib/src/remote/host/activation.ts | 62 ++- lib/src/remote/host/alert-push.test.ts | 28 +- lib/src/remote/host/alert-push.ts | 14 + lib/src/remote/host/remote-api.test.ts | 36 ++ lib/src/remote/host/remote-api.ts | 10 +- pnpm-lock.yaml | 29 +- vscode-ext/package.json | 8 +- vscode-ext/scripts/esbuild.mjs | 12 +- vscode-ext/src/message-router.ts | 14 +- vscode-ext/src/peer-link.ts | 389 ++++++++++++++---- vscode-ext/src/remote-host-store.ts | 31 +- vscode-ext/src/remote-host.ts | 169 ++++++-- vscode-ext/test/helpers.ts | 16 + vscode-ext/test/peer-link.test.ts | 374 +++++++++++++++-- vscode-ext/test/remote-host.test.ts | 272 +++++++++++- 33 files changed, 1931 insertions(+), 274 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 8eedc5ae..7ba3e40a 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -181,7 +181,7 @@ Push and speech are independent: both fire when both are on, each on its own del - **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. 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. No generation fence is needed on top of that: the service reads its own ACL at request time, and a Host that stopped answers `no-host` like any other state. +- 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 resets the store to `no-host`, so a request already on the wire cannot resolve afterwards and repopulate the dialog with phones there is no longer anything to push to. (The refresher itself is re-installed after the reset — it stays installed on an un-enrolled machine so the dialog can still ask and be told `no-host`.) ### Settings dialog diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 82f17a64..1526c7a5 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -189,7 +189,11 @@ current reason to pay for. 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. +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. Invalidation reaches the session through `watchDirectory`: webviews announce that their pane state, activity, or focus changed, and membership changes (a diff --git a/docs/specs/server.md b/docs/specs/server.md index 3098927f..d8533781 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -467,7 +467,16 @@ away. * **Pairing approval modal**: the queue is service-side; webviews mirror a serializable projection of it (`{ clientId, request, requestedAt }[]`, pushed whole on every change) and answer by `clientId`, so the approve/deny closures - never leave the Host's process. The modal shows the requested label + account; + never leave the Host's process. **The mirror is compared by content, not by + id.** The service coalesces a re-sent pair under one `clientId` by *replacing* + what it holds, so the same id can come to name a different device — and + Approve authorizes what the service holds. A mirror that skipped an item whose + id it already showed would put the user's consent on a device they were never + shown, so an item whose `requestedAt` or request fields differ replaces the + mirrored one and the modal remounts (it is keyed on `clientId:requestedAt`). + 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 diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index bfdc4b3a..9126ef53 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -135,9 +135,14 @@ 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, but nothing survives a restart, and it warns once when a write is -dropped. The browser dev harness passes a per-run temp directory instead, so a -dev enrollment lives and dies with that run. +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. 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", @@ -177,6 +182,13 @@ 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 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/vscode.md b/docs/specs/vscode.md index ecbd2074..8543b812 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -256,24 +256,38 @@ A webview is a **surface responder plus UI**: it answers what its own panes are 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. 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 — `dormouse-peer-.sock` 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. +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), so unlink and bind again. Unlinking is safe precisely because a live broker would have accepted that connection. But two windows can find the same corpse, both unlink, and the second bind silently displaces the first — leaving the loser serving an inode no client can reach. Nothing on the bind path detects that, so `stillOurs` re-stats the path after `RECLAIM_VERIFY_MS` and compares inodes; a window whose inode was replaced stands down rather than run a second Host. (Windows named pipes cannot reach this: a pipe dies with its process.) +- **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 an inode no client can reach; nothing on the bind path detects that, so `stillOurs` re-stats the path after `RECLAIM_VERIFY_MS` and compares inodes. A window whose inode 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. - **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. -Trust is the same bar as the `dor` control socket: a user-owned unix socket (or named pipe) plus a token a client must present in its first frame, read from a mode-0600 `remote-host.peer-token` in `globalStorageUri`. It is 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 — and it is compared in constant time (`tokenMatches`). A first frame that is not a matching hello drops the socket. +**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. 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)`. -**Nothing starts until there is a Host to run.** Contention begins when activation finds an enrollment in `SecretStorage`, or on the first `enroll` command from any webview — the bootstrap for an un-enrolled machine, which calls the idempotent `ensurePeerNet()` first and then re-checks (if that settles as a client, another window enrolled first and the command belongs to it). 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. +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. A first frame that is not a valid hello drops the socket. + +**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`. @@ -285,6 +299,8 @@ Two events are pushed rather than answered: `pairing-queue` (the complete queue **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 @@ -297,7 +313,7 @@ Every webview installs the responder (`installPeerSurfaceResponder` in `lib/src/ **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 `PEER_REPLY_BUDGET_MS` (= the service's `ASK_BUDGET_MS`, 1s) budget expires, so a webview mid-reload cannot hang an attach or the phone's picker. A webview disposed mid-fan-out is removed from the outstanding set, which can settle the request immediately. +**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 `lib/src/lib/vscode-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. 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. @@ -323,7 +339,7 @@ A client window answers a `request` frame by running its **own in-window** fan-o **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 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 last unsubscribe also drops the route, which a later attach re-places from the owner's answer. +**Cross-window streams are reference-counted per 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 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 records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `writePty` / `resizePty` consult that table and fall back to this window's `ptyManager` — the link takes only a PTY it has a route for, and a route is placed only by an attach another window answered, so a local PTY can never be taken out from under the manager that owns it. When a peer disconnects, every PTY 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. @@ -331,7 +347,9 @@ Once an answer names a `ptyId` the broker records which window it came from, bec 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. -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 service and no broker to dial refuses a command with an error rather than dropping it, so the console hook fails fast instead of hanging for its whole timeout. +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 that is neither contending nor connected refuses a command 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. @@ -341,7 +359,7 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets, arbitration, and `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`. `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, handing the Host to a surviving window when the broker dies), cross-window directory and surface ops, PTY routing and streaming with two viewers, token rejection, and what a disconnect does to in-flight terminals and forwarded commands. `test/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), the enroll bootstrap, command forwarding and answering, and the provider's streaming, asking, and directory invalidation. `test/helpers.ts` holds what both need — a throwaway `globalStorageUri`, 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. +The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`. `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, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies), 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, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, and what a disconnect does to in-flight terminals and forwarded commands. `test/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 and re-reading after a cross-window change), the enroll bootstrap, commands held while the contention settles, 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, asking, and directory invalidation. `test/helpers.ts` holds what both 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`. diff --git a/lib/src/host/remote/host-state-store.test.ts b/lib/src/host/remote/host-state-store.test.ts index 1cd5f8d2..ab0e102f 100644 --- a/lib/src/host/remote/host-state-store.test.ts +++ b/lib/src/host/remote/host-state-store.test.ts @@ -2,6 +2,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { 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 })); + +vi.mock('node:fs/promises', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + 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'; @@ -33,6 +60,8 @@ 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; }); afterEach(async () => { @@ -102,6 +131,44 @@ describe('FileHostStateStore', () => { 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); + await rm(dir, { recursive: true, force: true }); + const blocker = join(dir); + await writeFile(blocker, 'not a directory'); + + await expect(store.saveEnrollment(ENROLLMENT)).rejects.toBeTruthy(); + await rm(blocker, { force: true }); + await expect(store.saveEnrollment(ENROLLMENT)).resolves.toBeUndefined(); + }); + 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(() => {}); @@ -127,14 +194,30 @@ describe('FileHostStateStore', () => { }); describe('createEphemeralHostStateStore', () => { - it('reads empty, drops writes, and says so once', async () => { + 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([]); - expect(warnings).toHaveLength(1); }); }); diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index dc4223ab..60e7601b 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -9,6 +9,7 @@ * is the sidecar's: one file, 0600, under a directory the app passes in. */ +import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { HostAclRecord } from 'server-lib-common'; @@ -20,6 +21,13 @@ import { isEnrollment, type HostEnrollment } from '../../remote/host/enrollment' export type { HostAclRecord }; export interface HostStateStore { + /** + * Whether a write survives this process. Absent means yes; only the + * dev-harness store (no state directory) says otherwise, and an adopting + * webview reads it to decide whether it may drop its own copy of the Host + * (`service.ts` → `#adopt`). + */ + readonly persistent?: boolean; loadEnrollment(): Promise; saveEnrollment(enrollment: HostEnrollment): Promise; clearEnrollment(): Promise; @@ -60,9 +68,17 @@ function parseState(raw: string): HostStateFile { * 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. + */ + #tail: Promise = Promise.resolve(); constructor(stateDir: string) { this.#dir = stateDir; @@ -73,26 +89,43 @@ export class FileHostStateStore implements HostStateStore { return (await this.#read()).enrollment; } - async saveEnrollment(enrollment: HostEnrollment): Promise { - const state = await this.#read(); - state.enrollment = enrollment; - await this.#write(state); + saveEnrollment(enrollment: HostEnrollment): Promise { + return this.#mutate(async (state) => { + state.enrollment = enrollment; + }); } - async clearEnrollment(): Promise { - const state = await this.#read(); - state.enrollment = null; - await this.#write(state); + clearEnrollment(): Promise { + return this.#mutate(async (state) => { + state.enrollment = null; + }); } async loadAcl(hostId: string): Promise { return filterAclRecords(hostId, (await this.#read()).acl[hostId] ?? []); } - async saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { - const state = await this.#read(); - state.acl[hostId] = [...records]; - await this.#write(state); + saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { + return this.#mutate(async (state) => { + state.acl[hostId] = [...records]; + }); + } + + /** Apply one change to the in-memory state and flush it, one at a time. */ + #mutate(change: (state: HostStateFile) => Promise): Promise { + const run = async (): Promise => { + const state = await this.#read(); + await change(state); + await this.#write(state); + }; + const result = this.#tail.then(run, run); + // The chain survives a failed write — one unwritable moment must not stop + // every later save — while the caller still sees the rejection. + this.#tail = result.then( + () => {}, + () => {}, + ); + return result; } #read(): Promise { @@ -119,29 +152,47 @@ export class FileHostStateStore implements HostStateStore { await mkdir(this.#dir, { recursive: true, mode: 0o700 }); // 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". - const tmp = `${this.#path}.${process.pid}.tmp`; + // 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`; await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); await rename(tmp, this.#path); } } /** - * The store for a run with no state directory (the browser dev harness). Reads - * answer empty and writes are dropped, so a Host can be enrolled and used for - * the session but nothing survives a restart. + * 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; enrollment will not survive a restart'); + 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 { - loadEnrollment: async () => null, - saveEnrollment: async () => warnOnce(), - clearEnrollment: async () => {}, - loadAcl: async () => [], - saveAcl: async () => warnOnce(), + 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/pty-strip.test.ts b/lib/src/host/remote/pty-strip.test.ts index dd1f103c..8aa9d7da 100644 --- a/lib/src/host/remote/pty-strip.test.ts +++ b/lib/src/host/remote/pty-strip.test.ts @@ -41,10 +41,16 @@ describe('createPtyStrip', () => { expect(second('plain')).toBe('plain'); }); - it('leaves a color query for the client to answer', () => { + it('swallows a color query rather than passing it to the phone', () => { const strip = createPtyStrip(); - // No theme lives here, so the query falls through exactly as it does in a - // webview whose provider declines. - expect(strip(`${ESC}]11;?${BEL}`)).toBe(`${ESC}]11;?${BEL}`); + // 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 index 2d4ed5a5..9d47fe67 100644 --- a/lib/src/host/remote/pty-strip.ts +++ b/lib/src/host/remote/pty-strip.ts @@ -13,17 +13,31 @@ * 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 { - // No color provider: OSC 10/11/12 queries fall through untouched, exactly as - // they do for a webview whose theme cannot answer them. - const parser = new TerminalProtocolParser(); + const parser = new TerminalProtocolParser(CONSUME_COLOR_QUERIES); return (data) => parser.process(data).visibleData; } diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts index b6271291..0d30b9d2 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -114,6 +114,18 @@ export interface AdoptParams { 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; diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 05a92d4c..72469192 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -10,7 +10,7 @@ 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 type { WebSocketLike } from '../../remote/host/remote-host'; -import type { HostStateStore } from './host-state-store'; +import { createEphemeralHostStateStore, type HostStateStore } from './host-state-store'; import { RemoteHostService } from './service'; import type { HostStatusEvent, @@ -315,6 +315,33 @@ describe('start', () => { 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('clearEnrollment stops the Host and forgets it, keeping the records', async () => { createService({ enrollment: ENROLLMENT, acl: { 'host-1': [aclRecord('device-1')] } }); await service.start(); @@ -336,9 +363,8 @@ describe('adopt', () => { aclRecords: [aclRecord('device-1')], }); - // Nothing to report: the webview clears its copy either way, because a - // second copy of one hostId is a second ACL. - expect(result.result).toEqual({}); + // `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); @@ -349,10 +375,75 @@ describe('adopt', () => { await service.start(); const other = { ...ENROLLMENT, hostId: 'host-2', hostToken: 'other' }; - await command('adopt', { enrollment: other, aclRecords: [] }); + 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 () => { diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index a8a5615e..0b290d45 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -31,6 +31,7 @@ import { REMOTE_HOST_EVENT_EVENT, REMOTE_HOST_RESULT_EVENT, type AdoptParams, + type AdoptResult, type ApproveParams, type DenyParams, type EnrollParams, @@ -67,6 +68,17 @@ export class RemoteHostService { #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. + */ + #lifecycle: Promise = Promise.resolve(); /** * Pairings awaiting local approval, service-side. The webview mirrors a * serializable projection of this and answers by clientId; the approve/deny @@ -84,8 +96,27 @@ export class RemoteHostService { this.#now = options.now ?? (() => Date.now()); } + /** + * Append `work` to the lifecycle chain and hand back its result. + * + * The chain continues through a failure — a refused enroll must not wedge + * every later command — so the tail swallows what the caller is still given. + */ + #serialize(work: () => Promise): Promise { + const result = this.#lifecycle.then(work, work); + this.#lifecycle = result.then( + () => {}, + () => {}, + ); + return result; + } + /** Start from a persisted enrollment, if there is one this build may reach. */ - async start(): Promise { + start(): Promise { + return this.#serialize(() => this.#start()); + } + + async #start(): Promise { const enrollment = await this.#store.loadEnrollment(); if (!enrollment) return; if (!this.#allowed(enrollment.serverUrl)) { @@ -121,14 +152,16 @@ export class RemoteHostService { 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.#enroll(params as EnrollParams); + return this.#serialize(() => this.#enroll(params as EnrollParams)); case 'status': return this.#status(); case 'reconnect': - return this.#reconnect(); + return this.#serialize(() => this.#reconnect()); case 'clearEnrollment': - return this.#clearEnrollment(); + return this.#serialize(() => this.#clearEnrollment()); case 'approve': return this.#approve(params as ApproveParams); case 'deny': @@ -140,7 +173,7 @@ export class RemoteHostService { case 'pairingQueue': return this.#queueSnapshot(); case 'adopt': - return this.#adopt(params as AdoptParams); + return this.#serialize(() => this.#adopt(params as AdoptParams)); default: throw new Error(`unknown remote-host command: ${cmd}`); } @@ -181,7 +214,7 @@ export class RemoteHostService { */ async #reconnect(): Promise { if (this.#host) this.#host.start(); - else await this.start(); + else await this.#start(); return this.#status(); } @@ -225,22 +258,39 @@ export class RemoteHostService { return { devices: await loadPushDevices(deps) }; } - async #adopt(params: AdoptParams): Promise> { + 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 !== false; + let persisted = existing ? durable : false; + if (!existing && isEnrollment(params.enrollment)) { const enrollment = params.enrollment; - await this.#store.saveEnrollment(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). - // - // The webview is told nothing about which happened: it clears its copy - // regardless, because a second copy of the same hostId is a second ACL. - if (!this.#host) await this.start(); - return {}; + if (!this.#host) await this.#start(); + return { persisted }; } // --- Host lifecycle --- @@ -254,6 +304,10 @@ export class RemoteHostService { } async #startHost(enrollment: HostEnrollment): Promise { + // 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 @@ -294,10 +348,16 @@ export class RemoteHostService { * name, which is how a webview seeds before any event arrives. */ #emitStatus(): void { - this.#sendToUi(REMOTE_HOST_EVENT_EVENT, { - name: 'status', - enrolled: !!this.#enrollment, - } satisfies HostStatusEvent); + 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 { diff --git a/lib/src/lib/push-devices.ts b/lib/src/lib/push-devices.ts index e970a6db..26eae726 100644 --- a/lib/src/lib/push-devices.ts +++ b/lib/src/lib/push-devices.ts @@ -76,7 +76,12 @@ export function refreshPushDevicesNow(): void { refresh?.(); } -/** Back to `no-host`, for a story or a test that finished. */ +/** + * Back to `no-host`: a story or test that finished, and the enrolled gate's + * disarm when the Host goes away — the dialog must not keep naming devices + * nothing can reach. It drops the refresher too, so a caller that still wants + * one installed re-installs it afterwards (`lib/src/remote/host/activation.ts`). + */ export function resetPushDevices(): void { refresh = null; setPushDevices(EMPTY); diff --git a/lib/src/lib/vscode-peer-link-protocol.test.ts b/lib/src/lib/vscode-peer-link-protocol.test.ts index 6a02369f..f81e480a 100644 --- a/lib/src/lib/vscode-peer-link-protocol.test.ts +++ b/lib/src/lib/vscode-peer-link-protocol.test.ts @@ -1,5 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { FrameDecoder, encodeFrame, forgetPeerRoutes, routedPtyId } from './vscode-peer-link-protocol'; +import { ASK_BUDGET_MS } from '../host/remote/service-protocol'; +import { + FrameDecoder, + PEER_REPLY_BUDGET_MS, + encodeFrame, + forgetPeerRoutes, + routedPtyId, +} from './vscode-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', () => { @@ -73,10 +91,30 @@ describe('FrameDecoder', () => { ]); }); - it('drops a peer that never terminates a frame', () => { + 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([]); - // The buffer was reset, so a well-formed frame still gets through after. + expect(decoder.push(`${'x'.repeat(100)}\n`)).toEqual([]); expect(decoder.push(encodeFrame({ kind: 'result', id: 'a', results: [] }))).toEqual([ { kind: 'result', id: 'a', results: [] }, ]); diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/lib/src/lib/vscode-peer-link-protocol.ts index 7651c342..e090166c 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/lib/src/lib/vscode-peer-link-protocol.ts @@ -21,11 +21,15 @@ import { } from '../host/remote/service-protocol'; /** - * How long the broker waits for a window to answer before giving up on it. The - * same budget as the service's own ask, because it is the same wait seen one - * layer down: the webview that has to answer is at the far end of both. + * 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; +export const PEER_REPLY_BUDGET_MS = ASK_BUDGET_MS + 2_000; /** * Broker → peer window. @@ -80,13 +84,56 @@ export type PeerLinkResponse = export type PeerLinkFrame = PeerLinkRequest | PeerLinkResponse; -/** The first frame a client sends; the server drops the socket if it mismatches. */ +/** + * 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`). + * + * The HMAC itself is computed in the socket module, which may import + * `node:crypto`; this one stays Node-free so the webview can share its types. + */ +export interface PeerLinkChallenge { + kind: 'challenge'; + /** Server nonce, base64url. The client's proof is over this. */ + nonce: string; +} + export interface PeerLinkHello { kind: 'hello'; - token: string; + /** 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; } -export function encodeFrame(frame: PeerLinkFrame | PeerLinkHello): 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; + +export function encodeFrame(frame: PeerLinkFrame | PeerLinkHandshake): string { return `${JSON.stringify(frame)}\n`; } @@ -99,6 +146,13 @@ export function encodeFrame(frame: PeerLinkFrame | PeerLinkHello): string { */ 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. */ @@ -108,25 +162,31 @@ export class FrameDecoder { push(chunk: string): unknown[] { this.#buffer += chunk; - if (this.#buffer.length > this.#maxFrameBytes) { - // A peer that will not terminate a frame is not one we can talk to. - this.#buffer = ''; - return []; - } const frames: unknown[] = []; - let newline = this.#buffer.indexOf('\n'); - while (newline !== -1) { + 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 (line.trim()) { - try { - frames.push(JSON.parse(line)); - } catch { - // Malformed frame: skip it, keep the link. - } + 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. } - newline = this.#buffer.indexOf('\n'); } + // 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; } } diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx index d5b408da..5f4110b7 100644 --- a/lib/src/remote/host/RemotePairingModalHost.tsx +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -32,7 +32,11 @@ export function RemotePairingModalHost({ return ( head.approve()} onDeny={() => head.deny()} diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index a0d63d89..52ef11c0 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -29,6 +29,7 @@ const enrollmentState = vi.hoisted(() => ({ 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', () => ({ @@ -43,10 +44,16 @@ vi.mock('./alert-push', () => ({ pushWatch.loads.push(load); await load(); }, + invalidatePushDeviceRefreshes: () => { + pushWatch.invalidated += 1; + }, })); -const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void> })); +const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void>, resets: 0 })); vi.mock('../../lib/push-devices', () => ({ setPushDevicesRefresher: (refresh: () => void) => void pushRefreshers.current.push(refresh), + resetPushDevices: () => { + pushRefreshers.resets += 1; + }, })); const aclState = vi.hoisted(() => ({ records: [] as unknown[], @@ -74,8 +81,10 @@ beforeEach(() => { remoteHostLink = undefined; pushWatch.fire = undefined; pushWatch.stopped = 0; + pushWatch.invalidated = 0; pushWatch.loads.length = 0; pushRefreshers.current.length = 0; + pushRefreshers.resets = 0; aclState.records = []; aclState.cleared.length = 0; enrollmentState.current = { @@ -141,6 +150,9 @@ function fakeLink(): FakeLink { */ 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'); @@ -209,12 +221,26 @@ describe('remote host bridge mode', () => { enrollment: { hostId: 'host-1' }, aclRecords: [{ hostId: 'host-1' }], }); - // Whatever the service decided, this copy is obsolete — leaving it would be - // a second ACL for the same hostId. + // 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(); @@ -280,6 +306,49 @@ describe('remote host bridge mode', () => { 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', request: PAIRING_REQUEST, requestedAt: 5 }], + }); + link.emit('pairing-queue', { + name: 'pairing-queue', + queue: [{ clientId: 'c1', request: second, requestedAt: 9 }], + }); + + const head = pairing.getPairingApprovalSnapshot(); + expect(head).toHaveLength(1); + expect(head[0]).toMatchObject({ clientId: 'c1', request: second, requestedAt: 9 }); + }); + + 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', 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); + }); + it('seeds the mirror once, for a webview that reloaded mid-pairing', async () => { const link = fakeLink(); link.results.pairingQueue = [{ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }]; @@ -339,6 +408,17 @@ describe('remote host bridge mode', () => { 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.resets).toBe(1); + // And the refresher goes back in: the dialog may still open on an + // un-enrolled machine, where asking is one command that answers `no-host`. + expect(pushRefreshers.current.at(-1)).toBeDefined(); + 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 () => { diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index c57e512e..b6b5a07f 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -18,7 +18,9 @@ * window.dormouseRemoteHost.clearEnrollment() */ +import type { PairingRequest } from 'server-lib-common'; import type { + AdoptResult, PairingQueueEvent, PairingQueueItem, PushDevicesResult, @@ -26,9 +28,9 @@ import type { } from '../../host/remote/service-protocol'; import { getPlatform } from '../../lib/platform'; import type { RemoteHostLink } from '../../lib/platform/types'; -import { setPushDevicesRefresher } from '../../lib/push-devices'; +import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; import { clearAclRecords, loadAclRecords } from './acl'; -import { commitPushDevices, watchPushRings } from './alert-push'; +import { commitPushDevices, invalidatePushDeviceRefreshes, watchPushRings } from './alert-push'; import { clearEnrollment, getEnrollment } from './enrollment'; import { armWhileEnrolled } from './enrolled-gate'; import { @@ -95,7 +97,17 @@ function installBridgeMode(link: RemoteHostLink): void { void link.command('push', { sessionId, title }).catch(() => {}); }); refresh(); - return stopRings; + 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. + invalidatePushDeviceRefreshes(); + resetPushDevices(); + // `resetPushDevices` also drops the refresher, which stays installed on + // an un-enrolled machine so the dialog can still ask and be told `no-host`. + setPushDevicesRefresher(refresh); + }; }); const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; @@ -114,24 +126,30 @@ function installBridgeMode(link: RemoteHostLink): void { /** * 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; either way the webview's copy is obsolete afterwards and is cleared — - * leaving it would be a second ACL for the same hostId, diverging from the - * moment the next device pairs. + * 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; + let result: AdoptResult | null; try { - await link.command('adopt', { + 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); } @@ -142,11 +160,20 @@ function mirrorPairingQueue(link: RemoteHostLink, queue: readonly PairingQueueIt for (const pending of getPairingApprovalSnapshot()) { if (!present.has(pending.clientId)) resolvePairingApproval(pending.clientId); } - const mirrored = new Set(getPairingApprovalSnapshot().map((pending) => 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 approve/deny closures only need the clientId. - if (mirrored.has(item.clientId)) continue; + if (showing && 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, request: item.request, @@ -156,3 +183,18 @@ function mirrorPairingQueue(link: RemoteHostLink, queue: readonly PairingQueueIt }); } } + +/** + * 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 ( + a.accountId === b.accountId && + a.passkeyCredentialId === b.passkeyCredentialId && + a.passkeyPublicKeyHash === b.passkeyPublicKeyHash && + a.devicePublicKey === b.devicePublicKey && + a.requestedLabel === b.requestedLabel + ); +} diff --git a/lib/src/remote/host/alert-push.test.ts b/lib/src/remote/host/alert-push.test.ts index e8e6d055..db2d3755 100644 --- a/lib/src/remote/host/alert-push.test.ts +++ b/lib/src/remote/host/alert-push.test.ts @@ -5,7 +5,7 @@ vi.mock('../../lib/platform', () => ({ })); import type { HostAclRecord } from 'server-lib-common'; -import { commitPushDevices, watchPushRings } 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'; @@ -324,6 +324,32 @@ describe('push device list', () => { expect(getPushDevices()).toEqual({ status: 'error', devices: [] }); }); + 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) => { + land = resolve; + })) as unknown as typeof globalThis.fetch, + }); + + invalidatePushDeviceRefreshes(); + resetPushDevices(); + + land({ + ok: true, + json: async () => ({ devices: [{ devicePublicKey: 'device-phone', subscribedAt: 1 }] }), + } as Response); + await inFlight; + + expect(getPushDevices()).toEqual({ status: 'no-host', devices: [] }); + }); + it('keeps a newer refresh when an older request resolves last', async () => { records = [ aclRecord('device-phone', 'iPhone Safari'), diff --git a/lib/src/remote/host/alert-push.ts b/lib/src/remote/host/alert-push.ts index b5b0ea22..7fa94e85 100644 --- a/lib/src/remote/host/alert-push.ts +++ b/lib/src/remote/host/alert-push.ts @@ -42,6 +42,8 @@ export async function commitPushDevices( const commit = (next: PushDevicesState) => { 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 { const devices = await load(); @@ -51,6 +53,18 @@ export async function commitPushDevices( } } +/** + * 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 hand the unattended ones to * `fire`, with the Session's display label already derived. Returns a disposer diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index e20bd45e..56e222a4 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -345,6 +345,42 @@ describe('RemoteApiSession directory.watch', () => { ]); }); + 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); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index ad7f46b7..e9d973e6 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -81,6 +81,7 @@ export class RemoteApiSession { #directorySubId: string | null = null; #unsubDirectory: (() => void) | null = null; #directoryTimer: ReturnType | null = null; + #directoryGeneration = 0; #attachment: Attachment | null = null; #attachGeneration = 0; #disposed = false; @@ -197,13 +198,20 @@ export class RemoteApiSession { async #emitDirectory(): Promise { if (this.#directorySubId === null) return; 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. const entries = await this.#provider.collectDirectory(); // The subscription may have been replaced or torn down while we waited. - if (this.#directorySubId !== subId) return; + if (this.#directorySubId !== subId || this.#directoryGeneration !== generation) return; this.#event(subId, REMOTE_EVENTS.directorySnapshot, { entries }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0b31fdf..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)) @@ -2257,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'} @@ -4433,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'} @@ -4987,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 @@ -6086,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) @@ -8175,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/vscode-ext/package.json b/vscode-ext/package.json index ab3cd63c..999c4e78 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1,7 +1,7 @@ { "name": "dormouse", - "displayName": "Dormouse — Terminal Multiplexer", - "description": "A persistent multitasking terminal — tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", + "displayName": "Dormouse \u2014 Terminal Multiplexer", + "description": "A persistent multitasking terminal \u2014 tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", "version": "1.1.0", "publisher": "diffplug", "license": "FSL-1.1-MIT", @@ -113,12 +113,14 @@ "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", diff --git a/vscode-ext/scripts/esbuild.mjs b/vscode-ext/scripts/esbuild.mjs index 01139ff7..3df4fb7b 100644 --- a/vscode-ext/scripts/esbuild.mjs +++ b/vscode-ext/scripts/esbuild.mjs @@ -8,8 +8,10 @@ // DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode // // This mirrors the standalone binary's build-time override -// (`standalone/scripts/tauri.mjs` + `csp.mjs`) so both Hosts widen the same way -// with the same variable. See docs/specs/server.md → "Host webview CSP". +// (`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'; @@ -27,7 +29,11 @@ const common = { bundle: true, format: 'cjs', platform: 'node', - external: ['vscode', 'node-pty'], + // `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 = [ diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index a01d00b2..1e6feae2 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -21,13 +21,14 @@ 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 { PEER_REPLY_BUDGET_MS } from '../../lib/src/lib/vscode-peer-link-protocol'; +import { ASK_BUDGET_MS } from '../../lib/src/host/remote/service-protocol'; import { configurePeerLink, remoteNotifyPeerChange } from './peer-link'; import { configureRemoteHost, deliverCommandResult, deliverUiEvent, dropForwardedCommands, + greetPeerWindow, handleForwardedCommand, handleRemoteHostCommand, notifyDirectoryChanged, @@ -78,6 +79,7 @@ configurePeerLink({ dropForwardedCommands, deliverCommandResult, deliverUiEvent, + onClientAuthenticated: greetPeerWindow, }); configureRemoteHost({ @@ -103,9 +105,11 @@ configureRemoteHost({ * 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. 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. + * 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]; @@ -124,7 +128,7 @@ function brokerRequest(op: string, params: unknown): Promise { pending: new Set(peers), results: [], settle, - timer: setTimeout(settle, PEER_REPLY_BUDGET_MS), + timer: setTimeout(settle, ASK_BUDGET_MS), }); for (const peer of peers) peer.ask(requestId, op, params); }); diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 1035d702..85f31264 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -23,14 +23,23 @@ * webviews' Host commands to the broker, which is the only process running a * service, and take back its results and UI events. * - * Trust: the socket is a user-owned unix socket (or named pipe) and a client - * must open with a token from a mode-0600 file in the extension's - * `globalStorageUri` — the same bar as the `dor` control socket. + * 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 { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; +import { chmod, lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { createConnection, createServer, type Server, type Socket } from 'node:net'; -import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -42,13 +51,17 @@ import type { } from '../../lib/src/host/remote/service-protocol'; import { FrameDecoder, + PEER_CLIENT_PROOF_DOMAIN, PEER_REPLY_BUDGET_MS, + PEER_SERVER_PROOF_DOMAIN, encodeFrame, forgetPeerRoutes, routedPtyId, + type PeerLinkChallenge, type PeerLinkHello, type PeerLinkRequest, type PeerLinkResponse, + type PeerLinkWelcome, } from '../../lib/src/lib/vscode-peer-link-protocol'; import { log } from './log'; @@ -83,6 +96,13 @@ export interface PeerLinkDeps { 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; @@ -97,19 +117,46 @@ const TOKEN_FILE = 'remote-host.peer-token'; const RETRY_MS = 1_000; /** - * Constant-time token compare, mirroring `tokenMatches` in - * `standalone/sidecar/dor-control-server.js`. That module is CommonJS and the - * shared protocol module must stay Node-free for the webview, so this is a - * deliberate second copy — but the property cannot differ: `!==` leaks the - * token byte-by-byte to a co-resident local process that can time the response. + * 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. */ -function tokenMatches(provided: unknown, expected: string): boolean { +const HANDSHAKE_BUDGET_MS = 5_000; + +/** + * 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. + */ +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 the shared protocol module must stay Node-free + * for the webview, so the compare is a deliberate second copy.) + */ +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. */ +function freshNonce(): string { + return randomBytes(16).toString('base64url'); +} + let context: vscode.ExtensionContext | null = null; export function initPeerLink(ctx: vscode.ExtensionContext): void { @@ -120,6 +167,54 @@ 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. * @@ -136,7 +231,7 @@ function socketPath(): string | null { .slice(0, 12); return process.platform === 'win32' ? `\\\\.\\pipe\\dormouse-peer-${id}` - : join(tmpdir(), `dormouse-peer-${id}.sock`); + : join(peerDirPath(), `${id}.sock`); } /** @@ -177,6 +272,8 @@ 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; } /** Where bytes from another window's PTY go, once something asks for them. */ @@ -194,7 +291,10 @@ const remoteSinks = new Map>(); const pendingRequests = new Map void>(); let nextRequestId = 0; -function send(client: PeerLinkClient, frame: PeerLinkRequest): void { +function send( + client: PeerLinkClient, + frame: PeerLinkRequest | PeerLinkChallenge | PeerLinkWelcome, +): void { if (client.socket.destroyed) return; client.socket.write(encodeFrame(frame)); } @@ -283,13 +383,17 @@ export function remoteSubscribe(ptyId: string, sink: RemotePtySink): void { 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 and drop the route — a later - // attach re-places it from the owner's answer. + // 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); if (!client) return; send(client, { kind: 'unsubscribe', id: `r${++nextRequestId}`, ptyId }); - routes.delete(ptyId); } export function remoteWrite(ptyId: string, data: string): boolean { @@ -321,7 +425,16 @@ export function sendCommandResult(client: PeerLinkClient, payload: RemoteHostRes * may be answered from any of them, so the queue cannot be addressed. */ export function broadcastUiEvent(payload: unknown): void { - for (const peer of authenticatedClients()) send(peer, { kind: 'uiEvent', payload }); + 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 { @@ -341,21 +454,39 @@ function dropClient(client: PeerLinkClient): void { } function onServerFrame(client: PeerLinkClient, frame: unknown): void { - const message = frame as (PeerLinkResponse | { kind: 'hello'; token: string }) & { - kind: string; - }; + const message = frame as (PeerLinkResponse | PeerLinkHello) & { kind: string }; if (!client.authenticated) { - // First frame must be the hello; anything else is not a peer of ours. + // 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' || !serverToken || !tokenMatches(hello.token, serverToken)) { + 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; } 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; } @@ -403,7 +534,12 @@ export function listenServer(nextServer: Server, path: string): Promise { /** 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 }; + const client: PeerLinkClient = { + socket, + decoder: new FrameDecoder(), + authenticated: false, + challenge: freshNonce(), + }; clients.add(client); socket.setEncoding('utf8'); socket.on('data', (chunk: string) => { @@ -411,6 +547,9 @@ async function tryBind(path: string, token: string): Promise { }); 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); @@ -515,39 +654,104 @@ function stopForwarding(): void { } /** - * Connect to whoever holds the socket. `'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. + * 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(frame); + return; + } + // The two handshake frames, read loosely: nothing here is trusted enough + // yet to be typed as one of them. + 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; + for (const topic of pendingNotifications) socket.write(encodeFrame({ kind: 'notify', topic })); + pendingNotifications.clear(); + 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) => { - socket.destroy(); - resolve(error.code === 'ECONNREFUSED' || error.code === 'ENOENT' ? 'refused' : 'failed'); + 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.removeAllListeners('error'); - socket.write(encodeFrame({ kind: 'hello', token })); - for (const topic of pendingNotifications) socket.write(encodeFrame({ kind: 'notify', topic })); - pendingNotifications.clear(); socket.on('data', (chunk: string) => { - for (const frame of decoder.push(chunk)) void onClientFrame(frame); + for (const frame of decoder.push(chunk)) onFrame(frame); }); - 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(); - }; - socket.on('error', drop); - socket.on('close', drop); - client = socket; - log.info('[peer-link] connected to the broker window'); - resolve('connected'); }); }); } @@ -562,6 +766,13 @@ function disconnectClient(): void { 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; let settledOnce: Promise | null = null; @@ -584,8 +795,8 @@ export function ensurePeerNet(onRole: (broker: boolean) => void): Promise return Promise.resolve(); } // No storage location means no socket to contend for, and no amount of - // retrying would produce one. - if (!context || disposed) return Promise.resolve(); + // retrying would produce one; neither would an unsafe socket directory. + if (!context || disposed || refused) return Promise.resolve(); settledOnce ??= new Promise((resolve) => { markSettled = resolve; }); @@ -613,6 +824,17 @@ function settle(broker: boolean): void { 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; + } const token = await ensureToken(); if (await tryBind(path, token)) { @@ -627,7 +849,40 @@ async function attempt(): Promise { return true; } - const outcome = await tryConnect(path, token); + 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 an inode 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'); + 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. @@ -635,32 +890,13 @@ async function attempt(): Promise { else settle(false); return true; } - if (outcome === 'refused') { - // The path exists but nothing answers: a broker that died without running - // its disposables. Unlinking is safe because a live broker would have - // accepted the connection above. - 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'); - 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. - await closeServer(false); - } - } 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 inode we just bound. @@ -668,22 +904,27 @@ const RECLAIM_VERIFY_MS = 250; * Two windows can find the same corpse and both unlink it, and the second bind * silently displaces the first — the loser keeps serving an inode no client can * reach. Nothing on the bind path detects that, so it is checked afterwards. - * Windows named pipes cannot get here (a pipe dies with its process) and do not - * stat, so an unreadable path is taken as ours. + * + * 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. */ async function stillOurs(path: string): Promise { + const unstattable = process.platform === 'win32'; const mine = await stat(path).catch(() => null); - if (!mine) return true; + if (!mine) return unstattable; await delay(RECLAIM_VERIFY_MS); const now = await stat(path).catch(() => null); - return !now || now.ino === mine.ino; + if (!now) return unstattable; + return now.ino === mine.ino; } async function contend(): Promise { if (contending || disposed) return; contending = true; try { - while (!disposed && !server && !client) { + 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 diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index ac4439ba..331b86c0 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -23,17 +23,42 @@ import { isEnrollment, type HostEnrollment } from '../../lib/src/remote/host/enr import { ENROLLMENT_KEY } from '../../lib/src/remote/host/store'; export class VsCodeHostStateStore implements HostStateStore { + /** Writes here survive a restart, so an adopting webview may drop its copy. */ + readonly persistent = true; + readonly #context: vscode.ExtensionContext; #enrollment: Promise | null = null; + #watch: vscode.Disposable | undefined; - constructor(context: vscode.ExtensionContext) { + /** + * @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, this extension host is the only writer of the key, - // and the activation probe and the service both want the same answer. + // 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. this.#enrollment ??= this.#readEnrollment(); return this.#enrollment; } diff --git a/vscode-ext/src/remote-host.ts b/vscode-ext/src/remote-host.ts index aa33a99a..bac7567a 100644 --- a/vscode-ext/src/remote-host.ts +++ b/vscode-ext/src/remote-host.ts @@ -25,6 +25,7 @@ import { 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, @@ -33,6 +34,7 @@ import { type RemoteHostResult, } from '../../lib/src/host/remote/service-protocol'; import type { HostSurfaceProvider, PtySink } from '../../lib/src/remote/host/host-surface-provider'; +import type { WebSocketLike } from '../../lib/src/remote/host/remote-host'; import type { ExtensionMessage } from './message-types'; import { broadcastUiEvent, @@ -45,6 +47,7 @@ import { remoteUnsubscribe, remoteWrite, sendCommandResult, + sendUiEvent, type PeerLinkClient, } from './peer-link'; import { VsCodeHostStateStore } from './remote-host-store'; @@ -201,12 +204,32 @@ export function notifyDirectoryChanged(topic?: string | null): void { askProvider?.notifyDirectoryChanged(topic); } +/** + * 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); @@ -225,14 +248,33 @@ function startService(): void { }); } +/** + * Whether a contention is running right now. While it is, this window is + * neither a broker nor a client: {@link handleRemoteHostCommand} holds commands + * instead of refusing them, because a refusal here is indistinguishable to the + * caller from "this machine has no Host at all". + */ +let settling: Promise | null = null; + /** * Join the contention for the Host and start serving if this window wins it. * Idempotent; resolves once a role is settled. */ function contendForHost(): Promise { - return ensurePeerNet((broker) => { + settling ??= ensurePeerNet((broker) => { if (broker) startService(); - }); + }).then( + () => { + settling = null; + drainQueuedCommands(); + }, + (error: unknown) => { + settling = null; + log.error(`[remote-host] contention failed: ${String(error)}`); + drainQueuedCommands(); + }, + ); + return settling; } /** @@ -264,19 +306,62 @@ function answer(payload: RemoteHostResult): void { 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)) refuse(payload.rhId); + } +} + /** * 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. Only a window - * with neither — no service and no broker to dial — refuses, and it says so - * rather than dropping the command silently, which would leave the console hook - * hanging for its whole timeout. + * broker's answer back as a `remoteHost:result` like any other. * - * `enroll` is the exception: it is how an installation with no Host at all - * bootstraps, so it starts the contention first and re-checks. If that - * contention settles as a client, some other window enrolled first and the - * command belongs to it. + * A window that is still contending has neither yet, and the contention takes + * as long as a bind and a handshake — so the command is held and drained when a + * role settles rather than refused. Refusing then would tell an enrolled + * machine's webview that it has no Host, seconds before it gets one, and the + * gates that arm on that answer would stay 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 — nothing contending, no service, + * no broker. */ export function handleRemoteHostCommand(payload: RemoteHostCommand | undefined): void { if (!isCommand(payload)) return; @@ -286,10 +371,15 @@ export function handleRemoteHostCommand(payload: RemoteHostCommand | undefined): } if (forwardCommand(payload)) return; if (payload.cmd === 'enroll') { - void contendForHost().then(() => { - if (service) void service.handleCommand(payload); - else if (!forwardCommand(payload)) refuse(payload.rhId); - }); + // 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); + void contendForHost(); + return; + } + if (settling) { + enqueueCommand(payload); return; } refuse(payload.rhId); @@ -333,6 +423,21 @@ 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 isCommand(payload: RemoteHostCommand | undefined): payload is RemoteHostCommand { return !!payload && typeof payload.rhId === 'string' && typeof payload.cmd === 'string'; } @@ -347,14 +452,7 @@ function refuse(rhId: string): void { */ export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable { context = ctx; - void hostStateStore(ctx) - .loadEnrollment() - .then((enrollment) => { - if (enrollment) return contendForHost(); - }) - .catch((error: unknown) => { - log.error(`[remote-host] could not read the enrollment: ${String(error)}`); - }); + void contendIfEnrolled(ctx); return { dispose() { @@ -362,14 +460,37 @@ export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable service = null; askProvider = null; commandRoutes.clear(); + for (const { timer } of queued.splice(0)) clearTimeout(timer); + store?.dispose(); store = null; context = null; }, }; } -/** The window's one store, made on first use. */ +function contendIfEnrolled(ctx: vscode.ExtensionContext): Promise { + return hostStateStore(ctx) + .loadEnrollment() + .then((enrollment) => { + if (enrollment) return 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); + store ??= new VsCodeHostStateStore(ctx, () => { + void contendIfEnrolled(ctx); + }); return store; } diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index a8d3272c..ff1c880f 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -5,6 +5,7 @@ */ 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'; @@ -19,6 +20,18 @@ 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 }); } @@ -92,6 +105,8 @@ export function fakeWindow( /** 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) { for (const listener of dataListeners) listener(id, data); }, @@ -125,6 +140,7 @@ export function fakeWindow( 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), }; }, }; diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 92f7ced3..da7f4837 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -9,11 +9,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { access, readFile } from 'node:fs/promises'; -import { createConnection, createServer } from 'node:net'; -import { join } from 'node:path'; +import { createHmac } from 'node:crypto'; +import { access, chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createConnection, createServer, type Server } from 'node:net'; +import { dirname, join } from 'node:path'; import { + FrameDecoder, + PEER_CLIENT_PROOF_DOMAIN, + PEER_SERVER_PROOF_DOMAIN, + encodeFrame, +} from '../../lib/src/lib/vscode-peer-link-protocol'; +import { + derivedSocketPath as socketPathFor, fakeContext, fakeSink, fakeWindow, @@ -32,15 +39,14 @@ let dir: string; let realTmp: string | undefined; const opened: LinkModule[] = []; -/** - * The one path every window of an installation contends for, mirroring - * `socketPath()`. Duplicated here on purpose: a derivation that drifted would - * silently give each window its own lease and its own Host. - */ -function derivedSocketPath(): string { - const id = createHash('sha256').update(dir).digest('hex').slice(0, 12); - return join(dir, `dormouse-peer-${id}.sock`); -} +const derivedSocketPath = (): string => socketPathFor(dir); + +/** 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')); @@ -129,6 +135,7 @@ describe('bind-as-lease', () => { 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, [ @@ -149,6 +156,79 @@ describe('bind-as-lease', () => { 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 stat(path)).ino; + + 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 stat(path).catch(() => null); + if (now && now.ino !== 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); @@ -240,7 +320,7 @@ describe('bind-as-lease', () => { expect(broker.isRemotePty('pty-far')).toBe(false); }); - it('stops the stream on unsubscribe', async () => { + it('stops the stream on unsubscribe but keeps the route', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); await attachFar(broker); @@ -252,10 +332,48 @@ describe('bind-as-lease', () => { await tick(); peerSide.emitData('pty-far', 'after unsubscribe'); await tick(100); - expect(sink.data).toEqual([]); - // Unsubscribing also forgets the route, so a later write is not misrouted. - expect(broker.isRemotePty('pty-far')).toBe(false); + + // 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('pty-far')).toBe(true); + + // And a second attach streams again over the route that was never lost. + const again = fakeSink(); + broker.remoteSubscribe('pty-far', 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); + await attachFar(broker); + const first = fakeSink(); + broker.remoteSubscribe('pty-far', 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. + await attachFar(broker); + const second = fakeSink(); + broker.remoteUnsubscribe('pty-far', first); + broker.remoteSubscribe('pty-far', second); + await tick(); + + expect(broker.isRemotePty('pty-far')).toBe(true); + expect(broker.remoteWrite('pty-far', '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 () => { @@ -399,22 +517,228 @@ describe('bind-as-lease', () => { expect(brokerSide.dropped[0]).toBe(brokerSide.forwarded[0].from); }); - it('rejects a client that does not know the token', async () => { + 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 = await openWindow(brokerSide); - await broker.ensurePeerNet(() => {}); + const { broker, peerSide } = await linkedPair(brokerSide); + + expect(brokerSide.joined).toHaveLength(1); + const event = { name: 'status', enrolled: true }; + broker.sendUiEvent(brokerSide.joined[0]!, event); - // The socket path is derived from the storage location, so it is guessable; - // the token in the 0600 file beside it is the only secret. - expect((await readFile(join(dir, 'remote-host.peer-token'), 'utf8')).trim()).toBeTruthy(); + 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)); - socket.write(`${JSON.stringify({ kind: 'hello', token: 'wrong' })}\n`); - // The server drops it rather than answering anything. + // 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', topic: 'directory' })); + 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('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('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/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index 39d095db..86c70123 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -7,12 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createServer, type Server, type Socket } from 'node:net'; -import { createHash } from 'node:crypto'; -import { join } from 'node:path'; +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; -import { FrameDecoder } from '../../lib/src/lib/vscode-peer-link-protocol'; +import { FrameDecoder, encodeFrame } from '../../lib/src/lib/vscode-peer-link-protocol'; +import { ENROLLMENT_KEY } from '../../lib/src/remote/host/store'; import type { ExtensionMessage } from '../src/message-types'; import { + derivedSocketPath as socketPathFor, fakeSink, fakeWindow, freshModule, @@ -33,25 +35,46 @@ let opened: LinkModule | null = null; let squatter: Server | null = null; const squatted: Socket[] = []; -/** Mirrors `socketPath()` — see the note in peer-link.test.ts. */ -function derivedSocketPath(): string { - const id = createHash('sha256').update(dir).digest('hex').slice(0, 12); - return join(dir, `dormouse-peer-${id}.sock`); +const derivedSocketPath = (): string => socketPathFor(dir); + +/** One `secrets.onDidChange` subscriber, as VS Code hands them out. */ +interface SecretWatcher { + (event: { key: string }): void; } /** The slice of `ExtensionContext` the store reads, in memory. */ function fakeContext() { 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 }, + store: { secrets, global, announce, watchers, reads }, context: { globalStorageUri: { fsPath: dir }, subscriptions: [] as unknown[], secrets: { - get: async (key: string) => secrets.get(key), - store: async (key: string, value: string) => void secrets.set(key, value), - delete: async (key: string) => void secrets.delete(key), + 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), @@ -134,6 +157,7 @@ function bridgeLinkToHost( dropForwardedCommands: mod.dropForwardedCommands, deliverCommandResult: mod.deliverCommandResult, deliverUiEvent: mod.deliverUiEvent, + onClientAuthenticated: mod.greetPeerWindow, }); } @@ -147,20 +171,47 @@ async function openFarWindow(side: ReturnType): Promise }> { 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)) frames.push(frame as { kind: 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(derivedSocketPath(), resolve)); + await new Promise((resolve) => server.listen(path, resolve)); squatter = server; return { frames }; } @@ -190,6 +241,7 @@ afterEach(async () => { squatter = null; if (realTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = realTmp; + vi.unstubAllGlobals(); await removeDir(dir); }); @@ -240,6 +292,47 @@ describe('host state store', () => { ]); }); + 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(); @@ -334,6 +427,133 @@ describe('remote host service glue', () => { 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('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', () => { @@ -503,6 +723,30 @@ describe('serving the other windows', () => { expect(sink.data).toEqual(['from the other window']); }); + 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(); From c19d2ca43ed9aba876b00cad90528859c3de1294 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 10:33:16 -0700 Subject: [PATCH 35/56] Centralize VS Code peer stream routing --- docs/specs/vscode.md | 8 +- lib/src/host/remote/service-protocol.ts | 7 + lib/src/host/remote/service.ts | 6 +- lib/src/host/remote/sidecar-entry.ts | 6 +- lib/src/remote/host/peer-surfaces.ts | 2 +- vscode-ext/src/message-router.ts | 20 +- .../src/peer-link-protocol.ts | 68 +++++-- vscode-ext/src/peer-link.ts | 150 +++++++-------- vscode-ext/src/processed-pty-streams.ts | 93 +++++++++ vscode-ext/src/remote-host.ts | 149 +++++--------- vscode-ext/test/helpers.ts | 20 +- .../test/peer-link-protocol.test.ts | 64 ++++++- vscode-ext/test/peer-link.test.ts | 6 +- vscode-ext/test/processed-pty-streams.test.ts | 181 ++++++++++++++++++ vscode-ext/test/remote-host.test.ts | 25 +-- 15 files changed, 569 insertions(+), 236 deletions(-) rename lib/src/lib/vscode-peer-link-protocol.ts => vscode-ext/src/peer-link-protocol.ts (76%) create mode 100644 vscode-ext/src/processed-pty-streams.ts rename lib/src/lib/vscode-peer-link-protocol.test.ts => vscode-ext/test/peer-link-protocol.test.ts (69%) create mode 100644 vscode-ext/test/processed-pty-streams.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 835d6cb8..468a50a7 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -315,11 +315,11 @@ Every webview installs the responder (`installPeerSurfaceResponder` in `lib/src/ **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 `lib/src/lib/vscode-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. +**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. 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 the topic `directory` when its pane state, activity, or focus changes; a membership change (a webview attaching or disposing, a peer window joining or dropping) carries no topic and is always the directory's business. `notifyDirectoryChanged` fans that to the service's watchers, which coalesce a fresh collect rather than retaining the old directory. +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. **Attach-is-the-resize goes through the live xterm.** `attach` and `resize` are the same 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. 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. @@ -327,7 +327,7 @@ Directory answers are snapshots, so the same seam carries invalidation. A webvie **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 listener pair for the whole window**, dispatching by id to the sinks registered for it, rather than one listener per attachment: these run on every chunk of every terminal, so per-attachment listeners would tax every keystroke of every PTY once per attached surface. The pair is installed on the first attachment and removed when the last one goes, so a window with no phone on it pays nothing. +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 @@ -355,7 +355,7 @@ One UI event *is* addressed: when a window completes the handshake the broker se 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, arbitration, and frames in flight; `lib/src/lib/vscode-peer-link-protocol.ts` for the frame shapes, framing, budget, and PTY routing table (tested in `lib/src/lib/vscode-peer-link-protocol.test.ts`); `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`. +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 diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts index 0d30b9d2..a2ed38be 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -44,6 +44,13 @@ export interface RemoteHostCommand { 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; diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 0b290d45..0971e50b 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -30,6 +30,7 @@ import type { HostStateStore } from './host-state-store'; import { REMOTE_HOST_EVENT_EVENT, REMOTE_HOST_RESULT_EVENT, + isRemoteHostCommand, type AdoptParams, type AdoptResult, type ApproveParams, @@ -41,7 +42,6 @@ import { type PairingQueueItem, type PushDevicesResult, type PushParams, - type RemoteHostCommand, type RemoteHostConsoleStatus, } from './service-protocol'; @@ -137,8 +137,8 @@ export class RemoteHostService { } async handleCommand(raw: unknown): Promise { - const command = raw as RemoteHostCommand | null; - if (!command || typeof command.rhId !== 'string' || typeof command.cmd !== 'string') return; + if (!isRemoteHostCommand(raw)) return; + const command = raw; try { const result = await this.#run(command.cmd, command.params); this.#sendToUi(REMOTE_HOST_RESULT_EVENT, { rhId: command.rhId, result }); diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 15c06f5e..4c689ac9 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -22,9 +22,9 @@ import { RemoteHostService } from './service'; import { ASK_BUDGET_MS, REMOTE_HOST_ASK_EVENT, + isRemoteHostCommand, type AnswerParams, type NotifyParams, - type RemoteHostCommand, } from './service-protocol'; /** The slice of `pty-core`'s manager the Host drives. */ @@ -202,8 +202,8 @@ export function createSidecarRemoteHost(options: SidecarRemoteHostOptions): Side return { handleCommand(data) { - const command = data as RemoteHostCommand | null; - if (!command || typeof command.cmd !== 'string') return; + 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); diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index d655d0f9..4f399189 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -47,7 +47,7 @@ export interface PeerSurfaceParams { * 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 `lib/src/lib/vscode-peer-link-protocol.ts`). + * window this PTY lives in (`routedPtyId` in `vscode-ext/src/peer-link-protocol.ts`). */ export interface PeerSurfaceResult { ptyId: string; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 1e6feae2..84aa0271 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -23,6 +23,7 @@ import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runA 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, @@ -63,14 +64,14 @@ interface PendingRequest { timer: ReturnType; } const peerRequests = new Map(); +const processedPtyStreams = createProcessedPtyStreams(onProcessedPtyData, onProcessedPtyExit); // 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, - onProcessedPtyData, - onProcessedPtyExit, + streamPty: processedPtyStreams.streamPty, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), // The Host half: which of these fire depends on which side of the bind this @@ -85,8 +86,7 @@ configurePeerLink({ configureRemoteHost({ brokerRequest, broadcastToWebviews, - onProcessedPtyData, - onProcessedPtyExit, + streamPty: processedPtyStreams.streamPty, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), }); @@ -618,10 +618,10 @@ export function attachRouter( } case 'peer:notify': if (typeof msg.topic !== 'string') break; - // The topic travels: what a webview announced is what the broker's - // watchers filter on, here and at the far end of the link alike. - notifyDirectoryChanged(msg.topic); - remoteNotifyPeerChange(msg.topic); + // Directory is the only peer-query topic today; the transport only + // needs to carry the fact that its snapshot may have changed. + notifyDirectoryChanged(); + remoteNotifyPeerChange(); break; case 'remoteHost:command': handleRemoteHostCommand(msg.payload); @@ -805,7 +805,7 @@ export function attachRouter( // One fewer webview to ask means the directory's answer changed, even if // no surface did. notifyDirectoryChanged(); - remoteNotifyPeerChange(null); + 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; @@ -830,6 +830,6 @@ export function attachRouter( activeRouters.add(router); notifyDirectoryChanged(); - remoteNotifyPeerChange(null); + remoteNotifyPeerChange(); return router; } diff --git a/lib/src/lib/vscode-peer-link-protocol.ts b/vscode-ext/src/peer-link-protocol.ts similarity index 76% rename from lib/src/lib/vscode-peer-link-protocol.ts rename to vscode-ext/src/peer-link-protocol.ts index e090166c..358ba98a 100644 --- a/lib/src/lib/vscode-peer-link-protocol.ts +++ b/vscode-ext/src/peer-link-protocol.ts @@ -6,19 +6,22 @@ * 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, and the table - * that remembers which window a streaming PTY came from. + * 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 — and of Node imports, so the webview side can share - * its types and budgets — meaning the protocol's edge cases (a split frame, a peer - * that vanishes mid-attach) are testable without spawning processes. + * 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 '../host/remote/service-protocol'; +} from '../../lib/src/host/remote/service-protocol'; /** * How long the broker waits for another window to answer before giving up on it. @@ -39,13 +42,17 @@ export const PEER_REPLY_BUDGET_MS = ASK_BUDGET_MS + 2_000; * 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. + * + * Only `request` carries a frame id, because only `request` is awaited. The + * four PTY frames are one-way instructions to the owning window: nothing waits + * on them, and the stream they start is correlated by `ptyId`. */ export type PeerLinkRequest = | { kind: 'request'; id: string; op: string; params: unknown } - | { kind: 'subscribe'; id: string; ptyId: string } - | { kind: 'unsubscribe'; id: string; ptyId: string } - | { kind: 'write'; id: string; ptyId: string; data: string } - | { kind: 'resizePty'; id: string; ptyId: string; cols: number; rows: number } + | { kind: 'subscribe'; 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 @@ -73,8 +80,8 @@ export type PeerLinkResponse = | { kind: 'data'; ptyId: string; data: string } /** Unsolicited: that PTY ended. */ | { kind: 'exit'; ptyId: string; exitCode: number } - /** Unsolicited: future peer-query answers for this topic may differ. */ - | { kind: 'notify'; topic: string | null } + /** 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 @@ -99,9 +106,6 @@ export type PeerLinkFrame = PeerLinkRequest | PeerLinkResponse; * 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`). - * - * The HMAC itself is computed in the socket module, which may import - * `node:crypto`; this one stays Node-free so the webview can share its types. */ export interface PeerLinkChallenge { kind: 'challenge'; @@ -133,6 +137,40 @@ 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`; } diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 85f31264..772cae39 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -38,7 +38,7 @@ */ import { chmod, lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +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'; @@ -56,13 +56,17 @@ import { PEER_SERVER_PROOF_DOMAIN, encodeFrame, forgetPeerRoutes, + freshNonce, + proofMatches, + proveToken, routedPtyId, type PeerLinkChallenge, type PeerLinkHello, type PeerLinkRequest, type PeerLinkResponse, type PeerLinkWelcome, -} from '../../lib/src/lib/vscode-peer-link-protocol'; +} from './peer-link-protocol'; +import type { PtySink } from './processed-pty-streams'; import { log } from './log'; /** @@ -74,14 +78,13 @@ import { log } from './log'; export interface PeerLinkDeps { /** Fan out to this window's own webviews — never to other windows. */ brokerRequest(op: string, params: unknown): Promise; + /** A peer window's answers may have changed, so the directory is stale. */ + invalidateDirectory(): void; /** - * A peer window's answers may have changed, so the directory is stale. - * `topic` is the webview's own word for what changed where there is one; a - * membership change carries none, and is always the directory's business. + * 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. */ - invalidateDirectory(topic?: string | null): void; - onProcessedPtyData(listener: (id: string, data: string) => void): () => void; - onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => void; + streamPty(ptyId: string, sink: PtySink): () => void; writePty(ptyId: string, data: string): void; resizePty(ptyId: string, cols: number, rows: number): void; /** @@ -123,40 +126,6 @@ const RETRY_MS = 1_000; */ const HANDSHAKE_BUDGET_MS = 5_000; -/** - * 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. - */ -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 the shared protocol module must stay Node-free - * for the webview, so the compare is a deliberate second copy.) - */ -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. */ -function freshNonce(): string { - return randomBytes(16).toString('base64url'); -} - let context: vscode.ExtensionContext | null = null; export function initPeerLink(ctx: vscode.ExtensionContext): void { @@ -302,9 +271,10 @@ function send( /** Ask one peer and resolve when it answers, or when the budget expires. */ function ask( client: PeerLinkClient, - // Only the correlated frames can be awaited; `commandResult` and `uiEvent` - // carry no frame id because nothing waits on them here. - frame: Extract, + // `request` is the only frame anything waits on, and so the only one that + // carries an id; everything else is one-way and correlated by `ptyId` or by + // the `rhId` already inside it. + frame: Extract, ): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { @@ -375,7 +345,7 @@ export function remoteSubscribe(ptyId: string, sink: RemotePtySink): void { if (!sinks) { sinks = new Set(); remoteSinks.set(ptyId, sinks); - send(client, { kind: 'subscribe', id: `r${++nextRequestId}`, ptyId }); + send(client, { kind: 'subscribe', ptyId }); } sinks.add(sink); } @@ -393,20 +363,20 @@ export function remoteUnsubscribe(ptyId: string, sink: RemotePtySink): void { remoteSinks.delete(ptyId); const client = routes.get(ptyId); if (!client) return; - send(client, { kind: 'unsubscribe', id: `r${++nextRequestId}`, ptyId }); + send(client, { kind: 'unsubscribe', ptyId }); } export function remoteWrite(ptyId: string, data: string): boolean { const client = routes.get(ptyId); if (!client) return false; - send(client, { kind: 'write', id: `r${++nextRequestId}`, ptyId, data }); + send(client, { kind: 'write', ptyId, data }); return true; } export function remoteResize(ptyId: string, cols: number, rows: number): boolean { const client = routes.get(ptyId); if (!client) return false; - send(client, { kind: 'resizePty', id: `r${++nextRequestId}`, ptyId, cols, rows }); + send(client, { kind: 'resizePty', ptyId, cols, rows }); return true; } @@ -502,7 +472,7 @@ function onServerFrame(client: PeerLinkClient, frame: unknown): void { return; } if (response.kind === 'notify') { - deps?.invalidateDirectory(response.topic); + deps?.invalidateDirectory(); return; } if (response.kind === 'command') { @@ -568,7 +538,8 @@ async function tryBind(path: string, token: string): Promise { // ---------------------------------------------------------------- client side let client: Socket | null = null; -const pendingNotifications = new Set(); +/** 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>(); @@ -576,14 +547,14 @@ function respond(frame: PeerLinkResponse): void { client?.write(encodeFrame(frame)); } -export function remoteNotifyPeerChange(topic: string | null): void { +export function remoteNotifyPeerChange(): void { // The broker is the destination; its own window was notified directly. if (server) return; if (!client || client.destroyed) { - pendingNotifications.add(topic); + pendingNotify = true; return; } - respond({ kind: 'notify', topic }); + respond({ kind: 'notify' }); } /** @@ -613,20 +584,17 @@ async function onClientFrame(frame: unknown): Promise { case 'subscribe': { if (forwarding.has(request.ptyId)) break; if (!deps) break; - const stops: Array<() => void> = []; - const stop = () => { - for (const dispose of stops) dispose(); - }; - stops.push(deps.onProcessedPtyData((id, data) => { - if (id === request.ptyId) respond({ kind: 'data', ptyId: id, data }); - })); - stops.push(deps.onProcessedPtyExit((id, exitCode) => { - if (id !== request.ptyId) return; - respond({ kind: 'exit', ptyId: id, exitCode }); - stop(); - forwarding.delete(request.ptyId); - })); - forwarding.set(request.ptyId, stop); + const { ptyId } = request; + const stop = deps.streamPty(ptyId, { + onData: (data) => respond({ kind: 'data', ptyId, data }), + onExit: (exitCode) => { + respond({ 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); + }, + }); + forwarding.set(ptyId, stop); break; } case 'unsubscribe': @@ -732,8 +700,8 @@ function tryConnect(path: string, token: string): Promise<'connected' | 'refused } // Proved in both directions: from here it is the broker. client = socket; - for (const topic of pendingNotifications) socket.write(encodeFrame({ kind: 'notify', topic })); - pendingNotifications.clear(); + if (pendingNotify) socket.write(encodeFrame({ kind: 'notify' })); + pendingNotify = false; socket.removeAllListeners('error'); socket.on('error', drop); socket.on('close', drop); @@ -775,8 +743,7 @@ let contending = false; let refused = false; let nextAttemptAt = 0; let announceRole: ((broker: boolean) => void) | null = null; -let settledOnce: Promise | null = null; -let markSettled: (() => void) | null = null; +const settleListeners = new Set<() => void>(); const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -796,12 +763,16 @@ export function ensurePeerNet(onRole: (broker: boolean) => void): Promise } // 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 || refused) return Promise.resolve(); - settledOnce ??= new Promise((resolve) => { - markSettled = resolve; + if (!context || disposed) return Promise.resolve(); + if (isPeerLinkSettled()) return Promise.resolve(); + const settled = new Promise((resolve) => { + const stop = onPeerLinkSettled(() => { + stop(); + resolve(); + }); }); void contend(); - return settledOnce; + return settled; } /** Whether this window holds the Host. */ @@ -809,10 +780,33 @@ export function isPeerBroker(): boolean { return server !== null; } +/** + * 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 { + return server !== null || client !== null || 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); - markSettled?.(); - markSettled = null; + for (const listener of [...settleListeners]) listener(); } /** diff --git a/vscode-ext/src/processed-pty-streams.ts b/vscode-ext/src/processed-pty-streams.ts new file mode 100644 index 00000000..36b216b5 --- /dev/null +++ b/vscode-ext/src/processed-pty-streams.ts @@ -0,0 +1,93 @@ +/** + * 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 function createProcessedPtyStreams( + onProcessedPtyData: (listener: (id: string, data: string) => void) => () => void, + onProcessedPtyExit: (listener: (id: string, exitCode: number) => void) => () => void, +): 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(); + + return () => { + // 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(); + }; + }, + }; +} diff --git a/vscode-ext/src/remote-host.ts b/vscode-ext/src/remote-host.ts index bac7567a..527b6733 100644 --- a/vscode-ext/src/remote-host.ts +++ b/vscode-ext/src/remote-host.ts @@ -30,17 +30,20 @@ import { RemoteHostService } from '../../lib/src/host/remote/service'; import { REMOTE_HOST_EVENT_EVENT, REMOTE_HOST_RESULT_EVENT, + isRemoteHostCommand, type RemoteHostCommand, type RemoteHostResult, } from '../../lib/src/host/remote/service-protocol'; -import type { HostSurfaceProvider, PtySink } from '../../lib/src/remote/host/host-surface-provider'; +import type { HostSurfaceProvider } from '../../lib/src/remote/host/host-surface-provider'; import type { WebSocketLike } from '../../lib/src/remote/host/remote-host'; import type { ExtensionMessage } from './message-types'; import { broadcastUiEvent, ensurePeerNet, forwardCommand, + isPeerLinkSettled, isRemotePty, + onPeerLinkSettled, remoteRequest, remoteResize, remoteSubscribe, @@ -50,6 +53,7 @@ import { sendUiEvent, type PeerLinkClient, } from './peer-link'; +import type { PtySink } from './processed-pty-streams'; import { VsCodeHostStateStore } from './remote-host-store'; import { log } from './log'; @@ -64,8 +68,11 @@ export interface RemoteHostDeps { broadcastToWebviews(message: ExtensionMessage): void; writePty(ptyId: string, data: string): void; resizePty(ptyId: string, cols: number, rows: number): void; - onProcessedPtyData(listener: (id: string, data: string) => void): () => void; - onProcessedPtyExit(listener: (id: string, exitCode: number) => void): () => 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; @@ -135,73 +142,20 @@ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProv remoteSubscribe(ptyId, sink); return () => remoteUnsubscribe(ptyId, sink); } - return streamLocalPty(bound, ptyId, sink); + // One of this window's own, through the keyed registry every consumer of + // the processed stream shares (`processed-pty-streams.ts`). + return bound.streamPty(ptyId, sink); }, }); return askProvider.provider; } -/** Sinks on this window's own PTYs, keyed by the id they are watching. */ -const localStreams = new Map>(); -let stopLocalListeners: (() => void) | null = null; - -/** - * Stream a PTY this window owns, through one listener pair for the whole window - * rather than one per attachment: these run on every chunk of every terminal in - * the window, so a listener per attachment would tax every keystroke of every - * PTY once per attached surface. - * - * 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. - */ -function streamLocalPty(bound: RemoteHostDeps, ptyId: string, sink: PtySink): () => void { - let sinks = localStreams.get(ptyId); - if (!sinks) { - sinks = new Set(); - localStreams.set(ptyId, sinks); - } - const subscribed = sinks; - subscribed.add(sink); - - if (!stopLocalListeners) { - const offData = bound.onProcessedPtyData((id, data) => { - const targets = localStreams.get(id); - if (!targets) return; - for (const target of targets) target.onData(data); - }); - const offExit = bound.onProcessedPtyExit((id, exitCode) => { - const targets = localStreams.get(id); - if (!targets) return; - // Iterated live rather than copied: an exit tears its own attachment - // down, which a Set tolerates mid-iteration. - for (const target of targets) target.onExit(exitCode); - }); - stopLocalListeners = () => { - offData(); - offExit(); - }; - } - - return () => { - subscribed.delete(sink); - if (subscribed.size > 0) return; - localStreams.delete(ptyId); - // Nothing attached: back to costing this window's terminals nothing. - if (localStreams.size > 0) return; - stopLocalListeners?.(); - stopLocalListeners = null; - }; -} - /** * Something a future directory answer could depend on changed: a pane, an - * alert, a webview, a peer window. `topic` is a webview's own word for what - * changed; a change with no topic is always the directory's business. + * alert, a webview, a peer window. */ -export function notifyDirectoryChanged(topic?: string | null): void { - askProvider?.notifyDirectoryChanged(topic); +export function notifyDirectoryChanged(): void { + askProvider?.notifyDirectoryChanged(); } /** @@ -249,34 +203,34 @@ function startService(): void { } /** - * Whether a contention is running right now. While it is, this window is - * neither a broker nor a client: {@link handleRemoteHostCommand} holds commands - * instead of refusing them, because a refusal here is indistinguishable to the - * caller from "this machine has no Host at all". + * 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 settling: Promise | null = null; +let contending = false; /** * Join the contention for the Host and start serving if this window wins it. - * Idempotent; resolves once a role is settled. + * Idempotent. */ -function contendForHost(): Promise { - settling ??= ensurePeerNet((broker) => { +function contendForHost(): void { + contending = true; + void ensurePeerNet((broker) => { if (broker) startService(); - }).then( - () => { - settling = null; - drainQueuedCommands(); - }, - (error: unknown) => { - settling = null; - log.error(`[remote-host] contention failed: ${String(error)}`); - drainQueuedCommands(); - }, - ); - return settling; + }); + // A role that was already held (or a link that stood down for good) sends no + // settle notification, so anything queued has to be drained here instead. + if (isPeerLinkSettled()) 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 @@ -352,19 +306,21 @@ function drainQueuedCommands(): void { * 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. * - * A window that is still contending has neither yet, and the contention takes - * as long as a bind and a handshake — so the command is held and drained when a - * role settles rather than refused. Refusing then would tell an enrolled - * machine's webview that it has no Host, seconds before it gets one, and the - * gates that arm on that answer would stay down. + * 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 — nothing contending, no service, - * no broker. + * 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 (!isCommand(payload)) return; + if (!isRemoteHostCommand(payload)) return; if (service) { void service.handleCommand(payload); return; @@ -375,10 +331,10 @@ export function handleRemoteHostCommand(payload: RemoteHostCommand | undefined): // window enrolled first, this window is a client and the command belongs // on the link, which is exactly what the drain does. enqueueCommand(payload); - void contendForHost(); + contendForHost(); return; } - if (settling) { + if (contending && !isPeerLinkSettled()) { enqueueCommand(payload); return; } @@ -397,7 +353,7 @@ export function handleForwardedCommand( payload: RemoteHostCommand | undefined, from: PeerLinkClient, ): void { - if (!isCommand(payload)) return; + 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 @@ -438,10 +394,6 @@ export function greetPeerWindow(client: PeerLinkClient): void { sendUiEvent(client, service.statusEvent()); } -function isCommand(payload: RemoteHostCommand | undefined): payload is RemoteHostCommand { - return !!payload && typeof payload.rhId === 'string' && typeof payload.cmd === 'string'; -} - function refuse(rhId: string): void { deps?.broadcastToWebviews({ type: 'remoteHost:result', payload: { rhId, error: NO_HOST } }); } @@ -459,6 +411,7 @@ export function initRemoteHost(ctx: vscode.ExtensionContext): vscode.Disposable service?.dispose(); service = null; askProvider = null; + contending = false; commandRoutes.clear(); for (const { timer } of queued.splice(0)) clearTimeout(timer); store?.dispose(); @@ -472,7 +425,7 @@ function contendIfEnrolled(ctx: vscode.ExtensionContext): Promise { return hostStateStore(ctx) .loadEnrollment() .then((enrollment) => { - if (enrollment) return contendForHost(); + if (enrollment) contendForHost(); }) .catch((error: unknown) => { log.error(`[remote-host] could not read the enrollment: ${String(error)}`); diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index ff1c880f..468f7810 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -15,6 +15,7 @@ import type { 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-')); @@ -92,6 +93,16 @@ export function fakeWindow( ) { const dataListeners = new Set<(id: string, data: string) => void>(); const exitListeners = new Set<(id: string, exitCode: number) => void>(); + const streams = createProcessedPtyStreams( + (listener) => { + dataListeners.add(listener); + return () => void dataListeners.delete(listener); + }, + (listener) => { + exitListeners.add(listener); + return () => void exitListeners.delete(listener); + }, + ); return { entries: options.entries ?? [], surfaces: options.surfaces ?? {}, @@ -126,14 +137,7 @@ export function fakeWindow( invalidateDirectory: () => { this.invalidations += 1; }, - onProcessedPtyData: (listener) => { - dataListeners.add(listener); - return () => dataListeners.delete(listener); - }, - onProcessedPtyExit: (listener) => { - exitListeners.add(listener); - return () => exitListeners.delete(listener); - }, + 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 }), diff --git a/lib/src/lib/vscode-peer-link-protocol.test.ts b/vscode-ext/test/peer-link-protocol.test.ts similarity index 69% rename from lib/src/lib/vscode-peer-link-protocol.test.ts rename to vscode-ext/test/peer-link-protocol.test.ts index f81e480a..201fd008 100644 --- a/lib/src/lib/vscode-peer-link-protocol.test.ts +++ b/vscode-ext/test/peer-link-protocol.test.ts @@ -1,12 +1,23 @@ +/** + * 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 '../host/remote/service-protocol'; +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 './vscode-peer-link-protocol'; +} from '../src/peer-link-protocol'; describe('reply budgets', () => { it('gives the cross-window wait more room than the fan-out it contains', () => { @@ -65,6 +76,25 @@ describe('FrameDecoder', () => { expect(decoder.push('\n\n')).toEqual([]); }); + it('carries the one-way PTY frames, which have no id of their own', () => { + // Nothing awaits them — the stream they start is correlated by `ptyId` — + // so a frame id would be a field nobody ever reads. + const decoder = new FrameDecoder(); + expect( + decoder.push( + encodeFrame({ kind: 'subscribe', 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', 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. @@ -157,3 +187,33 @@ describe('forgetPeerRoutes', () => { 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 index da7f4837..2e47d50d 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -2,7 +2,7 @@ * 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 - * `lib/src/lib/vscode-peer-link-protocol.test.ts`; this covers the parts that + * `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. */ @@ -18,7 +18,7 @@ import { PEER_CLIENT_PROOF_DOMAIN, PEER_SERVER_PROOF_DOMAIN, encodeFrame, -} from '../../lib/src/lib/vscode-peer-link-protocol'; +} from '../src/peer-link-protocol'; import { derivedSocketPath as socketPathFor, fakeContext, @@ -243,7 +243,7 @@ describe('bind-as-lease', () => { const { brokerSide, peer } = await linkedPair(); const before = brokerSide.invalidations; - peer.remoteNotifyPeerChange('directory'); + peer.remoteNotifyPeerChange(); await waitFor(() => brokerSide.invalidations > before); }); 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..1e8718ea --- /dev/null +++ b/vscode-ext/test/processed-pty-streams.test.ts @@ -0,0 +1,181 @@ +/** + * 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>(); + return { + /** How many listener pairs are installed right now. */ + get installed(): number { + return data.size + exit.size; + }, + emitData(id: string, chunk: string): void { + for (const listener of [...data]) listener(id, chunk); + }, + emitExit(id: string, exitCode: number): void { + for (const listener of [...exit]) listener(id, exitCode); + }, + streams: () => + createProcessedPtyStreams( + (listener) => { + data.add(listener); + return () => void data.delete(listener); + }, + (listener) => { + exit.add(listener); + return () => void exit.delete(listener); + }, + ), + }; +} + +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('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); + + 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 index 86c70123..cee29c2c 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -10,9 +10,10 @@ import { createServer, type Server, type Socket } from 'node:net'; import { mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; -import { FrameDecoder, encodeFrame } from '../../lib/src/lib/vscode-peer-link-protocol'; 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, @@ -93,6 +94,16 @@ function fakeDeps() { 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 streams = createProcessedPtyStreams( + (listener) => { + dataListeners.add(listener); + return () => void dataListeners.delete(listener); + }, + (listener) => { + exitListeners.add(listener); + return () => void exitListeners.delete(listener); + }, + ); return { posted, asked, @@ -112,14 +123,7 @@ function fakeDeps() { broadcastToWebviews: (message) => void posted.push(message), writePty: () => {}, resizePty: () => {}, - onProcessedPtyData: (listener) => { - dataListeners.add(listener); - return () => dataListeners.delete(listener); - }, - onProcessedPtyExit: (listener) => { - exitListeners.add(listener); - return () => exitListeners.delete(listener); - }, + streamPty: streams.streamPty, }; }, }; @@ -149,8 +153,7 @@ function bridgeLinkToHost( link.configurePeerLink({ brokerRequest: local.brokerRequest, invalidateDirectory: mod.notifyDirectoryChanged, - onProcessedPtyData: local.onProcessedPtyData, - onProcessedPtyExit: local.onProcessedPtyExit, + streamPty: local.streamPty, writePty: local.writePty, resizePty: local.resizePty, handleForwardedCommand: mod.handleForwardedCommand, From 3fe2abb4aa27be29065c422a6353c7c19994bf11 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 10:37:42 -0700 Subject: [PATCH 36/56] Serialize VS Code remote host state writes --- docs/specs/vscode.md | 2 +- vscode-ext/src/remote-host-store.ts | 39 +++++++++++++++----- vscode-ext/test/remote-host.test.ts | 56 ++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 468a50a7..2dc633f1 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -258,7 +258,7 @@ A webview is a **surface responder plus UI**: it answers what its own panes are 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. 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. +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. 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. diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index 331b86c0..1dc2847d 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -29,6 +29,13 @@ export class VsCodeHostStateStore implements HostStateStore { readonly #context: vscode.ExtensionContext; #enrollment: Promise | 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. + */ + #tail: Promise = Promise.resolve(); /** * @param onEnrollmentChanged Some window of this extension wrote or cleared @@ -63,14 +70,18 @@ export class VsCodeHostStateStore implements HostStateStore { return this.#enrollment; } - async saveEnrollment(enrollment: HostEnrollment): Promise { - await this.#context.secrets.store(ENROLLMENT_KEY, JSON.stringify(enrollment)); - this.#enrollment = Promise.resolve(enrollment); + saveEnrollment(enrollment: HostEnrollment): Promise { + return this.#mutate(async () => { + await this.#context.secrets.store(ENROLLMENT_KEY, JSON.stringify(enrollment)); + this.#enrollment = Promise.resolve(enrollment); + }); } - async clearEnrollment(): Promise { - await this.#context.secrets.delete(ENROLLMENT_KEY); - this.#enrollment = Promise.resolve(null); + clearEnrollment(): Promise { + return this.#mutate(async () => { + await this.#context.secrets.delete(ENROLLMENT_KEY); + this.#enrollment = Promise.resolve(null); + }); } async #readEnrollment(): Promise { @@ -99,8 +110,20 @@ export class VsCodeHostStateStore implements HostStateStore { return filterAclRecords(hostId, parsed); } - async saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { - await this.#context.globalState.update(aclKey(hostId), JSON.stringify(records)); + saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { + return this.#mutate(() => + this.#context.globalState.update(aclKey(hostId), JSON.stringify(records)), + ); + } + + /** Serialize writes while keeping the queue alive after an individual failure. */ + #mutate(write: () => PromiseLike): Promise { + const result = this.#tail.then(write, write); + this.#tail = result.then( + () => {}, + () => {}, + ); + return result; } } diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index cee29c2c..5dfbcfa4 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -43,8 +43,14 @@ 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() { +function fakeContext(options: { deferGlobalWrites?: PendingGlobalWrite[] } = {}) { const secrets = new Map(); const global = new Map(); const watchers = new Set(); @@ -79,9 +85,25 @@ function fakeContext() { }, globalState: { get: (key: string) => global.get(key), - update: async (key: string, value: unknown) => { - if (value === undefined) global.delete(key); - else global.set(key, value as string); + 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()], }, @@ -349,6 +371,32 @@ describe('host state store', () => { 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', () => { From ee54233f39801d13d507efee5930d425a1bc2959 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 10:41:23 -0700 Subject: [PATCH 37/56] Reject remote host HTTP redirects --- docs/specs/server.md | 6 +++++- lib/src/remote/host/alert-push.test.ts | 9 +++++++++ lib/src/remote/host/enrollment.test.ts | 2 +- lib/src/remote/host/enrollment.ts | 4 ++++ lib/src/remote/host/push-delivery.ts | 4 ++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index d8533781..85d834d6 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -99,7 +99,11 @@ 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. +`*`, 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 diff --git a/lib/src/remote/host/alert-push.test.ts b/lib/src/remote/host/alert-push.test.ts index db2d3755..9dad7f7d 100644 --- a/lib/src/remote/host/alert-push.test.ts +++ b/lib/src/remote/host/alert-push.test.ts @@ -225,6 +225,15 @@ 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. diff --git a/lib/src/remote/host/enrollment.test.ts b/lib/src/remote/host/enrollment.test.ts index ff25050c..7c50e6bc 100644 --- a/lib/src/remote/host/enrollment.test.ts +++ b/lib/src/remote/host/enrollment.test.ts @@ -35,7 +35,7 @@ describe('remote-host enrollment', () => { 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' }); diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index 450a6012..328cc2e1 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -74,6 +74,10 @@ export async function performEnrollment( const base = serverUrl.replace(/\/+$/, ''); const response = await fetch(`${base}${API_ROUTES.hostEnroll}`, { method: 'POST', + // 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 }), }); diff --git a/lib/src/remote/host/push-delivery.ts b/lib/src/remote/host/push-delivery.ts index f2265405..7fd29818 100644 --- a/lib/src/remote/host/push-delivery.ts +++ b/lib/src/remote/host/push-delivery.ts @@ -63,6 +63,10 @@ async function hostFetch( ...(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' }), From 52566d3d940c81815cc8221412720b134916bf8e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 10:43:42 -0700 Subject: [PATCH 38/56] Correct self-host Host and state guidance --- SELF_HOST.md | 56 +++++++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 618c64bf..368e50c5 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -58,23 +58,18 @@ known: 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 - binary pins its webview `connect-src` to the SaaS origin, so a self-host relay - needs a local build: + 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/tauri.mjs` reads that variable and overrides the - checked-in CSP for that build only. - - The Host must be the standalone app. Remote hosting is standalone-only today: - `enableRemoteHost` is passed just by `standalone/src/main.tsx`, so the shared - webview entrypoint `lib/src/main.tsx` — the one the VS Code extension renders - — never loads the relay, enrollment, or pairing modules at all. That, not the - webview CSP in `vscode-ext/src/webview-html.ts`, is why `pnpm dogfood:vscode` - cannot produce a Host for a self-host relay. Do not offer the user a CSP - override as a fix; supporting a VS Code Host is a feature, not a build flag. + `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 @@ -101,6 +96,8 @@ 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 @@ -116,8 +113,9 @@ local Dormouse Host to control. - 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 both `account.json` and `hosts.json` outside the installed release. - Code replacement must never replace state. +- 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 @@ -136,7 +134,7 @@ local Dormouse Host to control. 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`. -- `account.json` and `hosts.json` survive replacement of the running release. +- 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. @@ -235,6 +233,8 @@ and install only into the current user's home directory: state/ account.json hosts.json + push-subscriptions.json + vapid.json ~/Library/LaunchAgents/sh.dormouse.server.plist ~/Library/Logs/Dormouse Server/ @@ -388,11 +388,13 @@ laptop only if the user approves the interruption; otherwise explain that 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 build -whose `DORMOUSE_REMOTE_CONNECT_SRC` includes `https://*.ts.net wss://*.ts.net`. -After `account.json` and `hosts.json` exist: +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 without printing contents. +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. @@ -440,7 +442,7 @@ Do not print the setup password or any credential in the handoff. - Dormouse runtime and state contract: `docs/specs/server.md` - Dormouse trust model: `docs/specs/remote-security-model.md` -- Standalone CSP override: `docs/specs/standalone.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) @@ -467,8 +469,8 @@ Do not print the setup password or any credential in the handoff. - **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:** the standalone Host likely lacks the - `*.ts.net` `connect-src` custom build setting. +- **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. @@ -514,6 +516,8 @@ ephemeral tag:dormouse-ci node --Tailscale SSH--> tag:dormouse-server Droplet /var/lib/dormouse on the Droplet account.json hosts.json + push-subscriptions.json + vapid.json ``` ### Definition of done @@ -997,10 +1001,12 @@ Check specifically that: - 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 build. -After `account.json` and `hosts.json` exist: +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 their ownership and checksums without printing their contents. +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. From 322777c2f107b35993ba17dd98e58ea804ceb911 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 10:49:08 -0700 Subject: [PATCH 39/56] Contain remote surface provider failures --- docs/specs/remote-api.md | 8 ++ lib/src/remote/host/remote-api.test.ts | 92 ++++++++++++++++ lib/src/remote/host/remote-api.ts | 144 +++++++++++++++++++------ 3 files changed, 213 insertions(+), 31 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 1526c7a5..83445277 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -194,6 +194,9 @@ 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 @@ -302,6 +305,11 @@ 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. #### Size authority: last-attach-wins diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 56e222a4..4319195f 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -51,6 +51,9 @@ class FakeProvider implements HostSurfaceProvider { 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; @@ -65,6 +68,7 @@ class FakeProvider implements HostSurfaceProvider { collectDirectory = async (): Promise => { this.collects += 1; await this.collectGate; + if (this.collectError) throw this.collectError; return this.entries; }; @@ -81,6 +85,7 @@ class FakeProvider implements HostSurfaceProvider { 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; }; @@ -142,6 +147,7 @@ class FakeProvider implements HostSurfaceProvider { }, resize: async (cols, rows) => { this.handleResizes.push([surface.ptyId, cols, rows]); + if (this.resizeError) throw this.resizeError; if (surface.cols !== cols || surface.rows !== rows) { surface.cols = cols; surface.rows = rows; @@ -242,6 +248,7 @@ function reply(sent: SentPayload[], requestId: string): RemoteResponse { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); describe('RemoteApiSession hello', () => { @@ -407,6 +414,34 @@ describe('RemoteApiSession directory.watch', () => { 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', () => { @@ -569,6 +604,41 @@ describe('RemoteApiSession surface.attach', () => { 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); @@ -793,6 +863,28 @@ describe('RemoteApiSession terminal input', () => { 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', () => { diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index e9d973e6..a55a26fd 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -32,6 +32,7 @@ import { utf8Decode, utf8Encode, type AttachParams, + type DirectoryEntry, type HelloResult, type RemoteEventMsg, type RemoteRequest, @@ -209,7 +210,23 @@ export class RemoteApiSession { // 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. - const entries = await this.#provider.collectDirectory(); + 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 }); @@ -232,29 +249,52 @@ export class RemoteApiSession { // 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(); - // The client holds a request pending until it is answered, so a - // superseded attach is failed rather than dropped — that also drops its - // event subscription. A disposed session has no transport to answer on. - if (!this.#disposed) { + 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(); + // The client holds a request pending until it is answered, so a + // superseded attach is failed rather than dropped — that also drops its + // event subscription. A disposed session has no transport to answer on. + if (!this.#disposed) { + this.#fail(request, `superseded by a newer attach: ${params.surfaceId}`); + } + 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.#fail(request, `surface attach failed: ${errorMessage(error)}`); + } + }, + (error) => { + if (this.#disposed) return; + if (this.#attachGeneration !== generation) { this.#fail(request, `superseded by a newer attach: ${params.surfaceId}`); + return; } - return; - } - if (!handle) { - this.#fail(request, `no such surface: ${params.surfaceId}`); - return; - } - this.#beginAttach(request, params, handle); - }); + this.#fail(request, `surface attach failed: ${errorMessage(error)}`); + }, + ); } - #beginAttach(request: RemoteRequest, params: AttachParams, handle: SurfaceHandle): void { + #beginAttach( + request: RemoteRequest, + params: AttachParams, + handle: SurfaceHandle, + generation: number, + ): void { // v1: one attachment per session — replace any prior stream. this.#teardownAttachment(); @@ -309,8 +349,41 @@ export class RemoteApiSession { // stream is subscribed first because some PTYs repaint synchronously. // A sibling's owner already applied the size inside the attach 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 !== attachment) { + if (this.#attachment === attachment) this.#teardownAttachment(); + this.#fail( + request, + this.#attachGeneration !== generation + ? `superseded by a newer attach: ${params.surfaceId}` + : `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); + } + }; + if (!sameSize) { - void handle.resize(cols, rows); + // 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 promise rejection in the Host process. + void handle.resize(cols, rows).then(finish, (error) => { + const current = this.#attachment === attachment; + if (current) this.#teardownAttachment(); + if (this.#disposed) return; + this.#fail( + request, + this.#attachGeneration !== generation + ? `superseded by a newer attach: ${params.surfaceId}` + : `surface attach failed: ${errorMessage(error)}`, + ); + }); } else { // Same size: force one repaint with a quick rows bounce on the PTY only, // leaving the already-correct local xterm buffer untouched. Bounce away @@ -329,13 +402,7 @@ export class RemoteApiSession { if (this.#attachment !== attachment) return; this.#provider.resizePty(ptyId, cols, rows); }, FORCE_REPAINT_BOUNCE_MS); - } - - const result: TerminalAttachResult = { cols: handle.cols, rows: handle.rows }; - this.#ok(request, result); - streaming = true; - for (const event of pendingEvents) { - this.#event(subId, event.event, event.data); + finish({ cols: handle.cols, rows: handle.rows }); } } @@ -367,9 +434,20 @@ export class RemoteApiSession { const cols = clampTerminalDimension(params.cols, handle.cols); const rows = clampTerminalDimension(params.rows, handle.rows); - void handle.resize(cols, rows).then((size) => { - this.#ok(request, { cols: size.cols, rows: size.rows } satisfies TerminalAttachResult); - }); + 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 { @@ -385,3 +463,7 @@ export class RemoteApiSession { this.#attachment = null; } } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'internal error'; +} From 915527d524a506e17285306599c195a6d8c35e1f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 10:55:08 -0700 Subject: [PATCH 40/56] Prevent remote Host resurrection after disposal --- docs/specs/server.md | 4 +++- lib/src/host/remote/service.test.ts | 27 +++++++++++++++++++++++++++ lib/src/host/remote/service.ts | 16 +++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 85d834d6..4e380122 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -459,7 +459,9 @@ away. `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: + 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). diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 72469192..0d1a2f6d 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -342,6 +342,33 @@ describe('start', () => { 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(); diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 0971e50b..f1b39c23 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -79,6 +79,8 @@ export class RemoteHostService { * other on the server forever. */ #lifecycle: Promise = Promise.resolve(); + /** 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 by clientId; the approve/deny @@ -113,6 +115,7 @@ export class RemoteHostService { /** 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()); } @@ -133,16 +136,20 @@ export class RemoteHostService { /** 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 (!isRemoteHostCommand(raw)) return; + 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), @@ -304,6 +311,7 @@ export class RemoteHostService { } 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. @@ -313,6 +321,10 @@ export class RemoteHostService { // 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, @@ -348,6 +360,7 @@ export class RemoteHostService { * 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()); } @@ -393,6 +406,7 @@ export class RemoteHostService { } #emitQueue(): void { + if (this.#disposed) return; this.#sendToUi(REMOTE_HOST_EVENT_EVENT, { name: 'pairing-queue', queue: this.#queueSnapshot(), From c411556c6d52284d478ede62ae43d41cdaadf35c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:03:25 -0700 Subject: [PATCH 41/56] Fence peer replies to their broker socket --- docs/specs/vscode.md | 8 +++ vscode-ext/src/peer-link.ts | 42 ++++++++--- vscode-ext/test/peer-link.test.ts | 114 ++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 2dc633f1..edbab329 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -317,6 +317,14 @@ Every webview installs the responder (`installPeerSurfaceResponder` in `lib/src/ **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. +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. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 772cae39..09eac69e 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -544,7 +544,21 @@ let pendingNotify = false; const forwarding = new Map void>(); function respond(frame: PeerLinkResponse): void { - client?.write(encodeFrame(frame)); + 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 { @@ -571,24 +585,34 @@ export function forwardCommand(payload: RemoteHostCommand): boolean { return true; } -async function onClientFrame(frame: unknown): Promise { +async function onClientFrame(socket: Socket, frame: unknown): Promise { const request = frame as PeerLinkRequest; switch (request.kind) { - case 'request': - respond({ + 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: (await deps?.brokerRequest(request.op, request.params)) ?? [], + results, }); break; + } case 'subscribe': { if (forwarding.has(request.ptyId)) break; if (!deps) break; const { ptyId } = request; const stop = deps.streamPty(ptyId, { - onData: (data) => respond({ kind: 'data', ptyId, data }), + onData: (data) => respondTo(socket, { kind: 'data', ptyId, data }), onExit: (exitCode) => { - respond({ kind: 'exit', ptyId, exitCode }); + 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); @@ -662,7 +686,9 @@ function tryConnect(path: string, token: string): Promise<'connected' | 'refused // 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(frame); + 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 diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 2e47d50d..4eadb3f5 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -447,6 +447,120 @@ describe('bind-as-lease', () => { expect(peer.isPeerBroker()).toBe(true); }); + 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(); From 2a46b8ed64f95271c06d38917222044fff77c98c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:06:08 -0700 Subject: [PATCH 42/56] Correct self-host backup state guidance --- AGENTS.md | 2 +- SELF_HOST.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8d2e1fd8..6cb9504c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi - **`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` + `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, 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 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/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 index 368e50c5..998a45dd 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -1045,9 +1045,9 @@ 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. The two JSON files -contain Host bearer credentials even though passkey public keys are not secret, -so protect backup access accordingly. +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: From 8fff4e5ff9a2f4f1693eb5a97e1b8cee752a2ad9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:46:20 -0700 Subject: [PATCH 43/56] Reject malformed peer handshake frames --- docs/specs/vscode.md | 2 +- vscode-ext/src/peer-link.ts | 31 +++++++++++++++++++ vscode-ext/test/peer-link.test.ts | 51 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index edbab329..2cf4a58c 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -285,7 +285,7 @@ The invariants are what make this simpler than the heartbeat lease it replaced: 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. A first frame that is not a valid hello drops the socket. +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. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 09eac69e..1fcf37ae 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -243,6 +243,8 @@ export interface PeerLinkClient { 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. */ @@ -294,6 +296,11 @@ function authenticatedClients(): PeerLinkClient[] { return [...clients].filter((client) => client.authenticated); } +/** 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. * Empty when nothing is connected, and when nobody owned what was asked about. @@ -408,6 +415,8 @@ export function sendUiEvent(client: PeerLinkClient, payload: unknown): void { } 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. @@ -424,6 +433,11 @@ function dropClient(client: PeerLinkClient): void { } 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 @@ -443,6 +457,8 @@ function onServerFrame(client: PeerLinkClient, frame: unknown): void { 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 @@ -509,8 +525,14 @@ async function tryBind(path: string, token: string): Promise { 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); @@ -586,6 +608,10 @@ export function forwardCommand(payload: RemoteHostCommand): boolean { } 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': { @@ -693,6 +719,11 @@ function tryConnect(path: string, token: string): Promise<'connected' | 'refused } // 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) { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 4eadb3f5..d6d5cab9 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -740,6 +740,24 @@ describe('peer handshake', () => { 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(() => {}); @@ -820,6 +838,39 @@ describe('peer handshake', () => { } }); + 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. From d95d675a23a977c977fb30b8c40713b314a39424 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:48:03 -0700 Subject: [PATCH 44/56] Publish remote host state after durable write --- docs/specs/standalone.md | 4 ++- lib/src/host/remote/host-state-store.test.ts | 4 +++ lib/src/host/remote/host-state-store.ts | 26 +++++++++++++++----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 310d4bb9..15601716 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -134,7 +134,9 @@ own panes. Nothing it says can widen access **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. -One file rather than one per value, so a write is one atomic rename and the +The in-memory view advances only after that rename succeeds, so a failed save +cannot be mistaken for durable state by a later adoption. 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 diff --git a/lib/src/host/remote/host-state-store.test.ts b/lib/src/host/remote/host-state-store.test.ts index ab0e102f..ca50696b 100644 --- a/lib/src/host/remote/host-state-store.test.ts +++ b/lib/src/host/remote/host-state-store.test.ts @@ -165,8 +165,12 @@ describe('FileHostStateStore', () => { 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('starts empty and warns on a malformed file', async () => { diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index 60e7601b..d631eb59 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -10,7 +10,7 @@ */ import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { 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'; @@ -114,9 +114,16 @@ export class FileHostStateStore implements HostStateStore { /** Apply one change to the in-memory state and flush it, one at a time. */ #mutate(change: (state: HostStateFile) => Promise): Promise { const run = async (): Promise => { - const state = await this.#read(); - await change(state); - await this.#write(state); + 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 } }; + await change(next); + await this.#write(next); + this.#state = Promise.resolve(next); }; const result = this.#tail.then(run, run); // The chain survives a failed write — one unwritable moment must not stop @@ -156,8 +163,15 @@ export class FileHostStateStore implements HostStateStore { // 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`; - await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); - await rename(tmp, this.#path); + 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(() => {}); + } } } From 57e73a69ba0641dc34db66ed701eeb0f46943fe8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:48:55 -0700 Subject: [PATCH 45/56] Keep remote host state directory private --- lib/src/host/remote/host-state-store.test.ts | 15 ++++++++++++++- lib/src/host/remote/host-state-store.ts | 6 +++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/src/host/remote/host-state-store.test.ts b/lib/src/host/remote/host-state-store.test.ts index ca50696b..a65e6a9b 100644 --- a/lib/src/host/remote/host-state-store.test.ts +++ b/lib/src/host/remote/host-state-store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -120,6 +120,19 @@ describe('FileHostStateStore', () => { 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('leaves no temp file behind, and overwrites in place', async () => { const store = new FileHostStateStore(dir); await store.saveEnrollment(ENROLLMENT); diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index d631eb59..5bb555a9 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -10,7 +10,7 @@ */ import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +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'; @@ -157,6 +157,10 @@ export class FileHostStateStore implements HostStateStore { // 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. + if (process.platform !== 'win32') await chmod(this.#dir, 0o700); // 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 From 7f042382f77c3dd8a68cd2f81373804f1e98b987 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:53:52 -0700 Subject: [PATCH 46/56] Fail closed on malformed Host authorization --- docs/specs/remote-security-model.md | 6 +++ lib/src/remote/host/remote-host.test.ts | 31 ++++++++++++++- lib/src/remote/host/remote-host.ts | 51 ++++++++++++++++++++----- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 233dd525..e9fbb1b2 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -218,6 +218,12 @@ 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. 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/lib/src/remote/host/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts index c69037db..48678e9c 100644 --- a/lib/src/remote/host/remote-host.test.ts +++ b/lib/src/remote/host/remote-host.test.ts @@ -283,6 +283,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); @@ -383,10 +398,24 @@ 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); }); }); diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts index be9b05fa..1be40bac 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, @@ -365,21 +366,42 @@ 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); + 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); + this.#send({ + t: 'decision', + clientId, + allowed: false, + failures: ['passkey-assertion-invalid', 'device-signature-invalid'], + }); + 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 +426,15 @@ 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.session?.dispose(); + state.session = undefined; + return state; + } + #onClientGone(clientId: string): void { this.#clients.get(clientId)?.session?.dispose(); this.#clients.delete(clientId); From c89364d9e6bda0ea2506fe9bb759d322f65ecde7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 11:55:20 -0700 Subject: [PATCH 47/56] Harden scripted Tailscale CLI detection --- SELF_HOST.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 998a45dd..5f17e79f 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -156,7 +156,9 @@ Inspect and report: 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. + 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. @@ -244,7 +246,8 @@ 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. Do not install or reauthenticate Tailscale without the user. + 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 From 567faa21d4d18e8dc6d86466cd426bd33bbc248a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 12:43:03 -0700 Subject: [PATCH 48/56] Finish the quality pass and pin the review's residual edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interrupted simplify list completes: the pairing-queue seed re-arms on every enrolled transition (a window joining mid-pairing shows the modal), VAPID env parsing joins the server's config module with its both-or-neither rule tested, RemoteHost's webview-era option defaults become required (localStorage leaves both Node bundles entirely), the notify topic collapses to a bare ping end to end, one serial-queue helper replaces three lib copies, one FakeSocket serves three test suites, webview directory notifies coalesce on the trailing edge, the adapter's nine listens register in parallel, persistent is a required store fact, and push-devices can clear without dropping its refresher. From the review of the Codex commits: authorization gains a generation guard so an older connect2 evaluation resolving late can neither answer nor re-open the gate a newer attempt closed — remote-security-model.md now states the rule it previously implied; the state-dir chmod no longer fails a durable save on modeless filesystems; a link that can never settle refuses queued commands immediately; a destroyed client socket counts as unsettled; and the exit-during-deferred-resize attach answer is pinned by a test and recorded in remote-api.md. Co-Authored-By: Claude Fable 5 --- docs/specs/alert.md | 2 +- docs/specs/remote-api.md | 6 +- docs/specs/remote-security-model.md | 13 +- docs/specs/server.md | 9 +- docs/specs/standalone.md | 11 +- docs/specs/transport.md | 4 +- docs/specs/vscode.md | 18 +- lib/src/host/remote/ask-surface-provider.ts | 12 +- lib/src/host/remote/host-state-store.test.ts | 23 ++- lib/src/host/remote/host-state-store.ts | 41 +++-- lib/src/host/remote/link-client.test.ts | 17 +- lib/src/host/remote/link-client.ts | 14 +- lib/src/host/remote/serial-queue.ts | 28 ++++ lib/src/host/remote/service-protocol.ts | 5 - lib/src/host/remote/service.test.ts | 45 +---- lib/src/host/remote/service.ts | 20 +-- lib/src/host/remote/sidecar-entry.test.ts | 16 +- lib/src/host/remote/sidecar-entry.ts | 9 +- lib/src/lib/platform/types.ts | 8 +- lib/src/lib/platform/vscode-adapter.test.ts | 4 +- lib/src/lib/platform/vscode-adapter.ts | 2 +- lib/src/lib/push-devices.ts | 16 +- lib/src/remote/client/pocket-client.test.ts | 120 ++++---------- lib/src/remote/host/acl.test.ts | 6 +- lib/src/remote/host/acl.ts | 10 +- lib/src/remote/host/activation.test.ts | 41 ++++- lib/src/remote/host/activation.ts | 28 ++-- lib/src/remote/host/peer-surfaces.test.ts | 31 +++- lib/src/remote/host/peer-surfaces.ts | 18 +- lib/src/remote/host/remote-api.test.ts | 41 +++++ lib/src/remote/host/remote-host.test.ts | 165 +++++++++++++------ lib/src/remote/host/remote-host.ts | 52 ++++-- lib/src/remote/test-fake-socket.ts | 74 +++++++++ server/src/config.ts | 44 ++++- server/src/index.ts | 31 +--- server/test/config.test.mjs | 38 +++++ standalone/src/browser-sidecar-adapter.ts | 2 +- standalone/src/tauri-adapter.test.ts | 5 +- standalone/src/tauri-adapter.ts | 46 ++---- vscode-ext/src/message-router.ts | 5 +- vscode-ext/src/message-types.ts | 2 +- vscode-ext/src/peer-link.ts | 5 +- vscode-ext/src/remote-host-store.ts | 13 +- vscode-ext/src/remote-host.ts | 9 +- vscode-ext/test/peer-link.test.ts | 32 +++- vscode-ext/test/remote-host.test.ts | 21 +++ 46 files changed, 745 insertions(+), 417 deletions(-) create mode 100644 lib/src/host/remote/serial-queue.ts create mode 100644 lib/src/remote/test-fake-socket.ts diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 5f5e2090..01506329 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -182,7 +182,7 @@ Push and speech are independent: both fire when both are on, each on its own del - **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. 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 resets the store to `no-host`, so a request already on the wire cannot resolve afterwards and repopulate the dialog with phones there is no longer anything to push to. (The refresher itself is re-installed after the reset — it stays installed on an un-enrolled machine so the dialog can still ask and be told `no-host`.) +- 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 diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 83445277..62d93186 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -309,7 +309,11 @@ 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. +unhandled Host-process rejections. The stream is subscribed before that resize +settles, so a PTY that exits inside the window tears the attachment down first: +the attach is then 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. #### Size authority: last-attach-wins diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index e9fbb1b2..fd0875a2 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -219,11 +219,14 @@ 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. 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`. +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 4e380122..4122df67 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -55,9 +55,12 @@ 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`. -The `DORMOUSE_VAPID_*` vars stay in `server/src/index.ts` rather than -`readConfig`, because resolving the keypair reads and may write `vapid.json` -and `readConfig` is pure. +`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 diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 15601716..1a227a91 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -134,8 +134,11 @@ own panes. Nothing it says can widen access **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. -The in-memory view advances only after that rename succeeds, so a failed save -cannot be mistaken for durable state by a later adoption. One file rather than +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. 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 @@ -147,7 +150,9 @@ 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. The browser dev harness passes a per-run temp directory +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, diff --git a/docs/specs/transport.md b/docs/specs/transport.md index bcff389b..6812405e 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -111,13 +111,13 @@ 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 for a topic may differ), 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`). +**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 { topic }` | +| 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"). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 2cf4a58c..4a452f56 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -258,7 +258,7 @@ A webview is a **surface responder plus UI**: it answers what its own panes are 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. 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. +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. @@ -297,7 +297,7 @@ Source of truth: `vscode-ext/src/remote-host.ts` (the service glue, provider, an 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 }`. +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. @@ -309,7 +309,7 @@ Two events are pushed rather than answered: `pairing-queue` (the complete queue 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 { topic }`. 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. +`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, and a resize. @@ -327,7 +327,7 @@ 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. +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 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. 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. @@ -369,7 +369,15 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and arbitration; `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`. `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, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies), 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, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, and what a disconnect does to in-flight terminals and forwarded commands. `test/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 and re-reading after a cross-window change), the enroll bootstrap, commands held while the contention settles, 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, asking, and directory invalidation. `test/helpers.ts` holds what both 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. +The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`. Five 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, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies), 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, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, what a disconnect does to in-flight terminals and forwarded commands, 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, 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, asking, and directory invalidation. +- **`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`. diff --git a/lib/src/host/remote/ask-surface-provider.ts b/lib/src/host/remote/ask-surface-provider.ts index 3447ea87..837bbd80 100644 --- a/lib/src/host/remote/ask-surface-provider.ts +++ b/lib/src/host/remote/ask-surface-provider.ts @@ -29,12 +29,11 @@ export interface AskSurfaceProvider { provider: HostSurfaceProvider; /** * Something a future {@link HostSurfaceProvider.collectDirectory} could depend - * on changed. `topic` is the webview's own word for what changed; anything but - * `directory` is somebody else's business, while no topic at all (a membership - * change, a peer joining) is always ours — the cheap direction is to - * re-collect. + * 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(topic?: string | null): void; + notifyDirectoryChanged(): void; } export function createAskSurfaceProvider( @@ -110,8 +109,7 @@ export function createAskSurfaceProvider( return { provider, - notifyDirectoryChanged(topic) { - if (topic !== undefined && topic !== null && topic !== 'directory') return; + 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/host-state-store.test.ts b/lib/src/host/remote/host-state-store.test.ts index a65e6a9b..afef3304 100644 --- a/lib/src/host/remote/host-state-store.test.ts +++ b/lib/src/host/remote/host-state-store.test.ts @@ -8,12 +8,21 @@ import { join } from 'node:path'; * 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 })); +const fsProbe = vi.hoisted(() => ({ + steps: [] as string[], + tmpWriteDelayMs: 0, + /** Stand in for a filesystem with no POSIX modes. */ + chmodFails: false, +})); 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); + }, writeFile: async (path: string, data: never, options: never) => { if (String(path).endsWith('.tmp')) { fsProbe.steps.push('write'); @@ -62,6 +71,7 @@ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dormouse-host-state-')); fsProbe.steps.length = 0; fsProbe.tmpWriteDelayMs = 0; + fsProbe.chmodFails = false; }); afterEach(async () => { @@ -133,6 +143,17 @@ describe('FileHostStateStore', () => { }, ); + 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); diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index 5bb555a9..d5bfc8d6 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -15,6 +15,7 @@ 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. @@ -22,12 +23,13 @@ export type { HostAclRecord }; export interface HostStateStore { /** - * Whether a write survives this process. Absent means yes; only the - * dev-harness store (no state directory) says otherwise, and an adopting - * webview reads it to decide whether it may drop its own copy of the Host - * (`service.ts` → `#adopt`). + * 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; + readonly persistent: boolean; loadEnrollment(): Promise; saveEnrollment(enrollment: HostEnrollment): Promise; clearEnrollment(): Promise; @@ -78,7 +80,7 @@ export class FileHostStateStore implements HostStateStore { * 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. */ - #tail: Promise = Promise.resolve(); + readonly #serialize = createSerialQueue(); constructor(stateDir: string) { this.#dir = stateDir; @@ -90,13 +92,13 @@ export class FileHostStateStore implements HostStateStore { } saveEnrollment(enrollment: HostEnrollment): Promise { - return this.#mutate(async (state) => { + return this.#mutate((state) => { state.enrollment = enrollment; }); } clearEnrollment(): Promise { - return this.#mutate(async (state) => { + return this.#mutate((state) => { state.enrollment = null; }); } @@ -106,14 +108,14 @@ export class FileHostStateStore implements HostStateStore { } saveAcl(hostId: string, records: readonly HostAclRecord[]): Promise { - return this.#mutate(async (state) => { + 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) => Promise): Promise { - const run = async (): Promise => { + #mutate(change: (state: HostStateFile) => void): Promise { + return this.#serialize(async () => { 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 @@ -121,18 +123,10 @@ export class FileHostStateStore implements HostStateStore { // 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 } }; - await change(next); + change(next); await this.#write(next); this.#state = Promise.resolve(next); - }; - const result = this.#tail.then(run, run); - // The chain survives a failed write — one unwritable moment must not stop - // every later save — while the caller still sees the rejection. - this.#tail = result.then( - () => {}, - () => {}, - ); - return result; + }); } #read(): Promise { @@ -160,7 +154,10 @@ export class FileHostStateStore implements HostStateStore { // `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. - if (process.platform !== 'win32') await chmod(this.#dir, 0o700); + // 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 diff --git a/lib/src/host/remote/link-client.test.ts b/lib/src/host/remote/link-client.test.ts index fa370406..d939d0de 100644 --- a/lib/src/host/remote/link-client.test.ts +++ b/lib/src/host/remote/link-client.test.ts @@ -18,16 +18,16 @@ import type { RemoteHostCommand } from './service-protocol'; function fakeTransport() { const sent: RemoteHostCommand[] = []; const answers: Array<{ askId: string; results: unknown[] }> = []; - const notified: string[] = []; + let notified = 0; return { sent, answers, - notified, + notified: () => notified, client(): RemoteHostLinkClient { return createRemoteHostLinkClient({ sendCommand: (command) => void sent.push(command), answerAsk: (askId, results) => void answers.push({ askId, results }), - notify: (topic) => void notified.push(topic), + notify: () => void (notified += 1), }); }, }; @@ -148,8 +148,8 @@ describe('events and notifies', () => { it('sends a notify through the transport, not as a command', () => { const transport = fakeTransport(); - transport.client().link.notify('directory'); - expect(transport.notified).toEqual(['directory']); + transport.client().link.notify(); + expect(transport.notified()).toBe(1); expect(transport.sent).toEqual([]); }); }); @@ -164,9 +164,8 @@ describe('tunnelled envelopes', () => { params: { rhId: 'ask-1', results: [{ ptyId: 'pty-1' }] }, }); expect(answer.rhId).not.toBe('ask-1'); - expect(notifyCommand('directory')).toMatchObject({ - cmd: 'notify', - params: { topic: 'directory' }, - }); + // 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 index 784cceca..d2610665 100644 --- a/lib/src/host/remote/link-client.ts +++ b/lib/src/host/remote/link-client.ts @@ -31,8 +31,8 @@ export interface RemoteHostLinkTransport { 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 for `topic` may differ. */ - notify(topic: string): void; + /** Announce that future answers may differ. */ + notify(): void; } export interface RemoteHostLinkClient { @@ -69,9 +69,9 @@ export function answerAskCommand(askId: string, results: unknown[]): RemoteHostC return { rhId: `rh-tunnel-${++envelopeSeq}`, cmd: 'answer', params: { rhId: askId, results } }; } -/** {@link answerAskCommand}, for a notify. */ -export function notifyCommand(topic: string): RemoteHostCommand { - return { rhId: `rh-tunnel-${++envelopeSeq}`, cmd: 'notify', params: { topic } }; +/** {@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( @@ -105,8 +105,8 @@ export function createRemoteHostLinkClient( responders.set(op, handler); }, - notify(topic) { - transport.notify(topic); + notify() { + transport.notify(); }, on(name, listener) { 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 index a2ed38be..aeb8c14e 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -139,11 +139,6 @@ export interface AnswerParams { results: unknown[]; } -/** Announces that future answers for `topic` may differ. */ -export interface NotifyParams { - topic: string; -} - // --- Command results --- export interface EnrollResult { diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 0d1a2f6d..8cccf4df 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -9,7 +9,7 @@ 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 type { WebSocketLike } from '../../remote/host/remote-host'; +import { FakeSocket } from '../../remote/test-fake-socket'; import { createEphemeralHostStateStore, type HostStateStore } from './host-state-store'; import { RemoteHostService } from './service'; import type { @@ -50,50 +50,19 @@ function aclRecord(devicePublicKey: string, label = 'iPhone Safari'): HostAclRec }; } -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) as Record); - } - - close(): void { - this.readyState = 3; - this.#emit('close', { code: 1000 }); - } - - open(): void { - this.#emit('open', {}); - } - - receive(frame: unknown): void { - this.#emit('message', { data: JSON.stringify(frame) }); - } - - 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); - } -} - 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, diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index f1b39c23..c3084fdf 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -27,6 +27,7 @@ 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, @@ -78,7 +79,7 @@ export class RemoteHostService { * has a reference to and could not be stopped, and the two would displace each * other on the server forever. */ - #lifecycle: Promise = Promise.resolve(); + readonly #serialize = createSerialQueue(); /** Disposal is terminal: no in-flight store read may resurrect the Host. */ #disposed = false; /** @@ -98,21 +99,6 @@ export class RemoteHostService { this.#now = options.now ?? (() => Date.now()); } - /** - * Append `work` to the lifecycle chain and hand back its result. - * - * The chain continues through a failure — a refused enroll must not wedge - * every later command — so the tail swallows what the caller is still given. - */ - #serialize(work: () => Promise): Promise { - const result = this.#lifecycle.then(work, work); - this.#lifecycle = result.then( - () => {}, - () => {}, - ); - return result; - } - /** Start from a persisted enrollment, if there is one this build may reach. */ start(): Promise { if (this.#disposed) return Promise.resolve(); @@ -270,7 +256,7 @@ export class RemoteHostService { // 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 !== false; + const durable = this.#store.persistent; let persisted = existing ? durable : false; if (!existing && isEnrollment(params.enrollment)) { diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index b639b6b4..c551b9ac 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -90,24 +90,16 @@ describe('asking the webview', () => { }); describe('directory invalidation', () => { - it('fires watchers on a directory notify, and stops after unsubscribe', () => { + it('fires watchers on a notify, and stops after unsubscribe', () => { const changes = vi.fn(); const unsubscribe = bridge.provider.watchDirectory(changes); - bridge.onNotify({ topic: 'directory' }); + bridge.onNotify(); expect(changes).toHaveBeenCalledTimes(1); - // An unrelated topic is not this watcher's business. - bridge.onNotify({ topic: 'something-else' }); - expect(changes).toHaveBeenCalledTimes(1); - - // A notify with no topic at all names no other business, so it is ours. - bridge.onNotify(undefined); - expect(changes).toHaveBeenCalledTimes(2); - unsubscribe(); - bridge.onNotify({ topic: 'directory' }); - expect(changes).toHaveBeenCalledTimes(2); + bridge.onNotify(); + expect(changes).toHaveBeenCalledTimes(1); }); }); diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 4c689ac9..8f089214 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -24,7 +24,6 @@ import { REMOTE_HOST_ASK_EVENT, isRemoteHostCommand, type AnswerParams, - type NotifyParams, } from './service-protocol'; /** The slice of `pty-core`'s manager the Host drives. */ @@ -44,7 +43,7 @@ export interface SidecarSurfaceBridge { /** An `answer` command: settles the ask it names. */ onAnswer(params: AnswerParams | undefined): void; /** A `notify` command: something the directory depends on changed. */ - onNotify(params: NotifyParams | undefined): void; + onNotify(): void; /** A `pty-core` event, tapped before it goes to the webview. */ onPtyEvent(event: string, data: unknown): void; dispose(): void; @@ -134,8 +133,8 @@ export function createSidecarSurfaceBridge( asks.get(params.rhId)?.settle(Array.isArray(params.results) ? params.results : []); }, - onNotify(params) { - notifyDirectoryChanged(params?.topic); + onNotify() { + notifyDirectoryChanged(); }, onPtyEvent(event, data) { @@ -207,7 +206,7 @@ export function createSidecarRemoteHost(options: SidecarRemoteHostOptions): Side // 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(command.params as NotifyParams); + if (command.cmd === 'notify') return bridge.onNotify(); void service.handleCommand(command); }, onPtyEvent: bridge.onPtyEvent, diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 7dfb76c2..36153f2b 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -135,8 +135,12 @@ export interface RemoteHostLink { /** 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 for `topic` may differ. */ - notify(topic: string): 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`), diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index b0f41744..4ddd6aab 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -447,8 +447,8 @@ describe('VSCodeAdapter remote host link', () => { it('notifies without waiting for anything', () => { const adapter = new VSCodeAdapter(); - adapter.remoteHost.notify('directory'); - expect(postMessage).toHaveBeenCalledWith({ type: 'peer:notify', topic: 'directory' }); + adapter.remoteHost.notify(); + expect(postMessage).toHaveBeenCalledWith({ type: 'peer:notify' }); }); it('rejects what is still in flight when the webview shuts down', async () => { diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index e7a0976c..17490a3c 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -50,7 +50,7 @@ export class VSCodeAdapter implements PlatformAdapter { // extension host's fan-out settles by `requestId`. answerAsk: (requestId, results) => this.vscode.postMessage({ type: 'peer:answer', requestId, results }), - notify: (topic) => this.vscode.postMessage({ type: 'peer:notify', topic }), + notify: () => this.vscode.postMessage({ type: 'peer:notify' }), }); readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; diff --git a/lib/src/lib/push-devices.ts b/lib/src/lib/push-devices.ts index 26eae726..8d42fdfd 100644 --- a/lib/src/lib/push-devices.ts +++ b/lib/src/lib/push-devices.ts @@ -77,10 +77,18 @@ export function refreshPushDevicesNow(): void { } /** - * Back to `no-host`: a story or test that finished, and the enrolled gate's - * disarm when the Host goes away — the dialog must not keep naming devices - * nothing can reach. It drops the refresher too, so a caller that still wants - * one installed re-installs it afterwards (`lib/src/remote/host/activation.ts`). + * 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 clearPushDevices(): void { + setPushDevices(EMPTY); +} + +/** + * 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 { refresh = null; 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/acl.test.ts b/lib/src/remote/host/acl.test.ts index 0aa81f48..a77874f9 100644 --- a/lib/src/remote/host/acl.test.ts +++ b/lib/src/remote/host/acl.test.ts @@ -36,7 +36,7 @@ describe('remote-host acl persistence', () => { 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'); @@ -48,14 +48,14 @@ describe('remote-host acl persistence', () => { saveAclRecords('host-1', makeRecord('host-1')); // A different host must not inherit host-1's ACL. expect(loadAclRecords('host-2')).toEqual([]); - expect(loadHostAcl('host-2').activeRecords()).toEqual([]); + 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('treats a missing localStorage as an empty ACL', () => { diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts index fe3ce676..eb89e548 100644 --- a/lib/src/remote/host/acl.ts +++ b/lib/src/remote/host/acl.ts @@ -55,12 +55,16 @@ export function clearAclRecords(hostId: string): void { /** * 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 index 52ef11c0..7bbee290 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -48,11 +48,11 @@ vi.mock('./alert-push', () => ({ pushWatch.invalidated += 1; }, })); -const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void>, resets: 0 })); +const pushRefreshers = vi.hoisted(() => ({ current: [] as Array<() => void>, cleared: 0 })); vi.mock('../../lib/push-devices', () => ({ setPushDevicesRefresher: (refresh: () => void) => void pushRefreshers.current.push(refresh), - resetPushDevices: () => { - pushRefreshers.resets += 1; + clearPushDevices: () => { + pushRefreshers.cleared += 1; }, })); const aclState = vi.hoisted(() => ({ @@ -84,7 +84,7 @@ beforeEach(() => { pushWatch.invalidated = 0; pushWatch.loads.length = 0; pushRefreshers.current.length = 0; - pushRefreshers.resets = 0; + pushRefreshers.cleared = 0; aclState.records = []; aclState.cleared.length = 0; enrollmentState.current = { @@ -349,7 +349,7 @@ describe('remote host bridge mode', () => { expect(pairing.getPairingApprovalSnapshot()[0]).toBe(first); }); - it('seeds the mirror once, for a webview that reloaded mid-pairing', async () => { + it('seeds the mirror, for a webview that reloaded mid-pairing', async () => { const link = fakeLink(); link.results.pairingQueue = [{ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }]; const { pairing } = await installBridge(link); @@ -358,6 +358,28 @@ describe('remote host bridge mode', () => { 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', 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); @@ -411,10 +433,11 @@ describe('remote host bridge mode', () => { // 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.resets).toBe(1); - // And the refresher goes back in: the dialog may still open on an - // un-enrolled machine, where asking is one command that answers `no-host`. - expect(pushRefreshers.current.at(-1)).toBeDefined(); + 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(); diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index b6b5a07f..f10bc492 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -28,7 +28,7 @@ import type { } from '../../host/remote/service-protocol'; import { getPlatform } from '../../lib/platform'; import type { RemoteHostLink } from '../../lib/platform/types'; -import { resetPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; +import { clearPushDevices, setPushDevicesRefresher } from '../../lib/push-devices'; import { clearAclRecords, loadAclRecords } from './acl'; import { commitPushDevices, invalidatePushDeviceRefreshes, watchPushRings } from './alert-push'; import { clearEnrollment, getEnrollment } from './enrollment'; @@ -71,14 +71,7 @@ function installBridgeMode(link: RemoteHostLink): void { mirrorPairingQueue(link, (data as PairingQueueEvent).queue); }); - void adoptWebviewHost(link).then(() => { - // Seed once: a webview that reloads mid-pairing has an empty mirror and no - // event coming, since the service only pushes on change. - void link - .command('pairingQueue') - .then((queue) => mirrorPairingQueue(link, (queue ?? []) as PairingQueueItem[])) - .catch(() => {}); - }); + void adoptWebviewHost(link); const refresh = (): void => { void commitPushDevices(async () => { @@ -97,16 +90,23 @@ function installBridgeMode(link: RemoteHostLink): void { 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. + // 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(); - resetPushDevices(); - // `resetPushDevices` also drops the refresher, which stays installed on - // an un-enrolled machine so the dialog can still ask and be told `no-host`. - setPushDevicesRefresher(refresh); + clearPushDevices(); }; }); diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 22715712..6dc0b836 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -22,7 +22,8 @@ interface Responder { /** A platform whose `remoteHost` link stands in for the Host service. */ class ServicePlatform { readonly responders = new Map(); - readonly notified: string[] = []; + /** 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; @@ -31,7 +32,9 @@ class ServicePlatform { respond: (op: string, handler: Responder) => { this.responders.set(op, handler); }, - notify: (topic: string) => void this.notified.push(topic), + notify: () => { + this.notified += 1; + }, on: () => () => {}, }; @@ -138,7 +141,26 @@ describe('surface responder', () => { // entry is only visible to it if this webview says so. await armed(); primeActivity('pty-1', { status: 'ALERT_RINGING' }); - expect(platform.notified).toContain('directory'); + 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('announces nothing until there is a Host to hear it', async () => { @@ -152,7 +174,8 @@ describe('surface responder', () => { await armed(); primeActivity('pty-2', { status: 'ALERT_RINGING' }); - expect(quiet.notified).toEqual([]); + 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 index 4f399189..0fb69c52 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -111,7 +111,22 @@ export function installPeerSurfaceResponder(): void { // change, and focus move — so it is armed only while a Host exists to hear it // (`enrolled-gate.ts`). armWhileEnrolled(link, () => { - const notifyDirectory = () => link.notify('directory'); + 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'; @@ -120,6 +135,7 @@ export function installPeerSurfaceResponder(): void { document.addEventListener('focusout', notifyDirectory); } return () => { + armed = false; unsubscribePaneState(); unsubscribeActivity(); if (!hasDocument) return; diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 4319195f..108b9589 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -59,6 +59,8 @@ class FakeProvider implements HostSurfaceProvider { 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; readonly #sinks = new Map>(); readonly #onChange = new Set<() => void>(); @@ -147,6 +149,9 @@ class FakeProvider implements HostSurfaceProvider { }, 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; @@ -982,6 +987,42 @@ describe('RemoteApiSession teardown', () => { ]); }); + 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(); diff --git a/lib/src/remote/host/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts index 48678e9c..3bad92dd 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', () => { @@ -349,6 +341,7 @@ describe('RemoteHost frame handling', () => { reconnect: false, createWebSocket: () => (socket = new FakeSocket()), loadAcl: () => [], + saveAcl: () => {}, requestApproval: (pending) => pending.approve(), dismissApproval: () => {}, createSession: () => ({ @@ -418,6 +411,86 @@ describe('RemoteHost frame handling', () => { 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 1be40bac..c7a8d6e1 100644 --- a/lib/src/remote/host/remote-host.ts +++ b/lib/src/remote/host/remote-host.ts @@ -42,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 { @@ -62,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`. */ @@ -89,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; @@ -145,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; } @@ -268,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; @@ -376,6 +385,14 @@ export class RemoteHost { // 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( @@ -393,6 +410,7 @@ export class RemoteHost { // 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, @@ -401,6 +419,7 @@ export class RemoteHost { }); return; } + if (superseded()) return; if (decision.allowed) state.established = true; // `failures` is optional on the wire; omit it on an allowed decision. this.#send({ @@ -430,6 +449,7 @@ export class RemoteHost { #resetAuthorization(clientId: string): ClientState { const state = this.#clientState(clientId); state.established = false; + state.authGeneration += 1; state.session?.dispose(); state.session = undefined; return state; 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/server/src/config.ts b/server/src/config.ts index 1db73506..e9960001 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -7,6 +7,8 @@ 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; @@ -21,6 +23,17 @@ export interface ServerConfig { 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. */ @@ -51,5 +64,34 @@ export function readConfig(env: Env = process.env): ServerConfig { const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const pocketDir = env.DORMOUSE_POCKET_DIR ?? join(repoRoot, 'lib', 'dist-pocket'); - return { port, bindHost, setupPassword, origin, stateDir, pocketDir }; + // 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 83342028..82da7a6d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,7 +13,6 @@ import { assertVapidKeyPair, assertVapidSubject, createWebPushSender, - defaultVapidSubject, generateVapidKeys, } from './push.js'; import { VapidStore } from './state.js'; @@ -30,34 +29,12 @@ function loadConfig() { } } -const { port, bindHost, ...appConfig } = loadConfig(); +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); diff --git a/server/test/config.test.mjs b/server/test/config.test.mjs index 9c80a461..cbec6133 100644 --- a/server/test/config.test.mjs +++ b/server/test/config.test.mjs @@ -52,3 +52,41 @@ test('state and pocket dirs are overridable, with a cwd-independent pocket defau 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/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index aff580fe..60ca48eb 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -65,7 +65,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private readonly remoteHostClient = createRemoteHostLinkClient({ sendCommand: (command) => this.sendRemoteHostCommand(command), answerAsk: (askId, results) => this.sendRemoteHostCommand(answerAskCommand(askId, results)), - notify: (topic) => this.sendRemoteHostCommand(notifyCommand(topic)), + notify: () => this.sendRemoteHostCommand(notifyCommand()), }); readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 761e24e0..87f2ea91 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -176,8 +176,9 @@ describe("TauriAdapter remote host link", () => { it("notifies without waiting for anything", async () => { const { adapter, sent } = await bridged(); - adapter.remoteHost.notify("directory"); - expect(sent()[0]).toMatchObject({ cmd: "notify", params: { topic: "directory" } }); + 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 () => { diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 856f77f0..8b243bc1 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -97,7 +97,7 @@ export class TauriAdapter implements PlatformAdapter { private readonly remoteHostClient = createRemoteHostLinkClient({ sendCommand: (command) => this.sendRemoteHostCommand(command), answerAsk: (askId, results) => this.sendRemoteHostCommand(answerAskCommand(askId, results)), - notify: (topic) => this.sendRemoteHostCommand(notifyCommand(topic)), + notify: () => this.sendRemoteHostCommand(notifyCommand()), }); readonly remoteHost: RemoteHostLink = this.remoteHostClient.link; @@ -114,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); @@ -132,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. @@ -164,38 +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(REMOTE_HOST_RESULT_EVENT, (event) => { + listen(REMOTE_HOST_RESULT_EVENT, (event) => { this.remoteHostClient.onResult(event.payload); }), - ); - this.unlistenFns.push( - await listen(REMOTE_HOST_ASK_EVENT, (event) => { + listen(REMOTE_HOST_ASK_EVENT, (event) => { const ask = event.payload; this.remoteHostClient.onAsk(ask.rhId, ask.op, ask.params); }), - ); - this.unlistenFns.push( - await listen<{ name?: string }>(REMOTE_HOST_EVENT_EVENT, (event) => { + listen<{ name?: string }>(REMOTE_HOST_EVENT_EVENT, (event) => { this.remoteHostClient.onEvent(event.payload); }), - ); - this.unlistenFns.push( - await listen("dor:controlRequest", (event) => { + listen("dor:controlRequest", (event) => { const payload = event.payload; const respond = (response: DorControlResult) => { rawInvoke("dor_control_response", { @@ -218,7 +206,7 @@ export class TauriAdapter implements PlatformAdapter { }, })); }), - ); + ]))); await this.hydrateSessionStore(); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 84aa0271..bdb3cd4d 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -617,9 +617,8 @@ export function attachRouter( break; } case 'peer:notify': - if (typeof msg.topic !== 'string') break; - // Directory is the only peer-query topic today; the transport only - // needs to carry the fact that its snapshot may have changed. + // 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; diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index a84a3bec..969f00ae 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -35,7 +35,7 @@ export type WebviewMessage = // `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'; topic: string } + | { type: 'peer:notify' } // One command for the Host service (`lib/src/host/remote/service-protocol.ts`). | { type: 'remoteHost:command'; payload: RemoteHostCommand } | { type: 'dormouse:init' } diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 1fcf37ae..9e8beccf 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -845,7 +845,10 @@ export function isPeerBroker(): boolean { * to reach (`remote-host.ts`). */ export function isPeerLinkSettled(): boolean { - return server !== null || client !== null || refused; + // 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. + return server !== null || (client !== null && !client.destroyed) || refused; } /** diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index 1dc2847d..dc473743 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -16,6 +16,7 @@ import type * as vscode from 'vscode'; import type { HostAclRecord, HostStateStore } from '../../lib/src/host/remote/host-state-store'; +import { createSerialQueue } from '../../lib/src/host/remote/serial-queue'; import { ACL_KEY_PREFIX, filterAclRecords } from '../../lib/src/remote/host/acl'; import { isEnrollment, type HostEnrollment } from '../../lib/src/remote/host/enrollment'; // Imported, not mirrored: a key that drifted between the two sides would strand @@ -35,7 +36,7 @@ export class VsCodeHostStateStore implements HostStateStore { * could allow the older snapshot to finish last and silently de-pair the * newer Client after a restart. */ - #tail: Promise = Promise.resolve(); + readonly #mutate = createSerialQueue(); /** * @param onEnrollmentChanged Some window of this extension wrote or cleared @@ -115,16 +116,6 @@ export class VsCodeHostStateStore implements HostStateStore { this.#context.globalState.update(aclKey(hostId), JSON.stringify(records)), ); } - - /** Serialize writes while keeping the queue alive after an individual failure. */ - #mutate(write: () => PromiseLike): Promise { - const result = this.#tail.then(write, write); - this.#tail = result.then( - () => {}, - () => {}, - ); - return result; - } } /** Keyed per host so a re-enrollment cannot inherit a stale ACL. */ diff --git a/vscode-ext/src/remote-host.ts b/vscode-ext/src/remote-host.ts index 527b6733..fcda2ba5 100644 --- a/vscode-ext/src/remote-host.ts +++ b/vscode-ext/src/remote-host.ts @@ -216,12 +216,13 @@ let contending = false; */ 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(); - }); - // A role that was already held (or a link that stood down for good) sends no - // settle notification, so anything queued has to be drained here instead. - if (isPeerLinkSettled()) drainQueuedCommands(); + }).then(drainQueuedCommands, drainQueuedCommands); } /** diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index d6d5cab9..4fd1ba68 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -7,11 +7,11 @@ * the winner dies, PTY routing, and the token. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +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, type Server } from 'node:net'; +import { createConnection, createServer, Socket, type Server } from 'node:net'; import { dirname, join } from 'node:path'; import { FrameDecoder, @@ -447,6 +447,32 @@ describe('bind-as-lease', () => { 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 }); @@ -711,7 +737,7 @@ describe('peer handshake', () => { expect(JSON.stringify([challenge, welcome])).not.toContain(token); // And the socket is a working peer afterwards. - socket.write(encodeFrame({ kind: 'notify', topic: 'directory' })); + socket.write(encodeFrame({ kind: 'notify' })); socket.destroy(); }); diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index 5dfbcfa4..ca74387d 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -517,6 +517,27 @@ describe('remote host service glue', () => { 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('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 From 75a62104c1bd93f535b5f97c26a82d2099f42b74 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 14:18:27 -0700 Subject: [PATCH 49/56] Fix the final review's findings across store, link, and surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail-closed state reads: only a missing file (or a corrupt one, still loudly) may read as empty — any other read failure now rejects loads and refuses writes, so a transient EACCES at boot can no longer be memoized into an empty snapshot whose next save wipes every pairing. Lifecycle mutations persist before they believe: clearEnrollment deletes from the store before stopping the Host, enroll saves before starting, the enrollment fetch times out at 10s inside the serialized queue, and re-enrolling over a running Host emits the enrolled:false edge the webview gates re-arm on. The peer link survives its edges: a listening server keeps a permanent error handler (an accept-time EMFILE previously killed the extension host), a route is never claimed for a PTY this window owns (colliding cold-restored pane ids could steal the broker's own shell), the broker role is answered only once confirmed (no zombie service inside the reclaim verification window), an unwritable token store stands down once instead of retrying forever, a dropping window settles its in-flight asks, and a result frame is accepted only from the window its request was put to. Late ask answers now trigger a directory re-collect instead of leaving an idle machine's phone blank; never-enrolled windows answer the read-only commands with the canonical idle shapes (parity-tested against a real un-enrolled service); blank PORT is unset and PORT=0 is refused; the extension's scripts build server-lib-common like standalone's do; and an unparseable connect-src override fails the build with the same grammar the runtime enforces. Co-Authored-By: Claude Fable 5 --- docs/specs/alert.md | 4 +- docs/specs/server.md | 27 +++- docs/specs/standalone.md | 16 ++- docs/specs/vscode.md | 23 +-- lib/src/host/remote/connect-src.test.ts | 55 +++++++- lib/src/host/remote/connect-src.ts | 15 +- lib/src/host/remote/host-state-store.test.ts | 38 +++++ lib/src/host/remote/host-state-store.ts | 49 +++++-- lib/src/host/remote/service.test.ts | 69 +++++++++ lib/src/host/remote/service.ts | 29 +++- lib/src/host/remote/sidecar-entry.test.ts | 33 +++++ lib/src/host/remote/sidecar-entry.ts | 22 ++- lib/src/remote/host/acl.test.ts | 29 ++-- lib/src/remote/host/acl.ts | 18 +-- lib/src/remote/host/activation.ts | 26 +++- lib/src/remote/host/enrollment.test.ts | 29 ++++ lib/src/remote/host/enrollment.ts | 9 ++ lib/src/remote/host/peer-surfaces.test.ts | 17 +++ lib/src/remote/host/peer-surfaces.ts | 17 ++- lib/src/remote/host/remote-api.ts | 73 ++++++---- scripts/csp-defaults.mjs | 23 +++ server/src/config.ts | 13 +- server/test/bind-host.test.mjs | 30 ++-- server/test/config.test.mjs | 13 ++ vscode-ext/package.json | 5 +- vscode-ext/src/message-router.ts | 17 ++- vscode-ext/src/peer-link.ts | 132 ++++++++++++++++-- vscode-ext/src/pty-manager.ts | 10 ++ vscode-ext/src/remote-host.ts | 56 +++++++- vscode-ext/test/helpers.ts | 4 + vscode-ext/test/message-router.test.ts | 127 +++++++++++++++++ vscode-ext/test/peer-link.test.ts | 139 +++++++++++++++++++ vscode-ext/test/remote-host.test.ts | 75 +++++++++- 33 files changed, 1123 insertions(+), 119 deletions(-) create mode 100644 vscode-ext/test/message-router.test.ts diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 01506329..3fdf9676 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -267,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/server.md b/docs/specs/server.md index 4122df67..f690ad0d 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -33,7 +33,7 @@ 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. | @@ -94,6 +94,14 @@ 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. The +runtime fails closed on a source it cannot parse, so without this such a build +succeeds and then refuses to enroll against the very server it was built for. +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 @@ -450,6 +458,23 @@ away. 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 diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 1a227a91..cdd0d522 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -138,7 +138,14 @@ 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. One file rather than +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 @@ -179,6 +186,13 @@ seam where a multi-window standalone would instead collect until the budget 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 diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 4a452f56..c4d327c2 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -273,13 +273,15 @@ 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 an inode no client can reach; nothing on the bind path detects that, so `stillOurs` re-stats the path after `RECLAIM_VERIFY_MS` and compares inodes. A window whose inode 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. It **never crosses the socket**. Instead three frames prove mutual knowledge of it: +*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. @@ -311,12 +313,14 @@ The service owns the PTYs but not the *view* of them: a window's terminals are s `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, and a resize. +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, 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; @@ -351,13 +355,15 @@ A client window answers a `request` frame by running its **own in-window** fan-o **Cross-window streams are reference-counted per 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 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 records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. `writePty` / `resizePty` consult that table and fall back to this window's `ptyManager` — the link takes only a PTY it has a route for, and a route is placed only by an attach another window answered, so a local PTY can never be taken out from under the manager that owns it. When a peer disconnects, every PTY 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. +Once an answer names a `ptyId` the broker records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. **Unless this window already has that id**: pane ids are unique within a window and nothing coordinates them across windows — "Duplicate Workspace in New Window" cold-restores identical ids into a second window — so a peer's answer can name one of the broker's own terminals. The route is skipped when `deps.ownsPty` says so (`ptyManager.hasPty` or a webview's claim), and local wins. Recording it would send the phone's keystrokes for the broker's own PTY over the socket and into the other window's shell. `writePty` / `resizePty` consult that table and fall back to this window's `ptyManager` — the link takes only a PTY it has a route for, so a local PTY can never be taken out from under the manager that owns it. When a peer disconnects, every PTY 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. +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. -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 that is neither contending nor connected refuses a command with an error rather than dropping it, so the console hook fails fast instead of hanging for its whole timeout. +**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. @@ -369,11 +375,12 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and arbitration; `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`. Five files, all under `vscode-ext/test/`: +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, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies), 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, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, what a disconnect does to in-flight terminals and forwarded commands, and that a client whose socket died reports *unsettled* before its `close` lands, so it agrees with `forwardCommand`. +- **`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, PTY routing and streaming with two viewers, a colliding PTY id staying local rather than being routed away, 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, 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, asking, and directory invalidation. +- **`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, 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. diff --git a/lib/src/host/remote/connect-src.test.ts b/lib/src/host/remote/connect-src.test.ts index ecf1b888..cc2cf645 100644 --- a/lib/src/host/remote/connect-src.test.ts +++ b/lib/src/host/remote/connect-src.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; // The build scripts read the `.mjs` and the Host service reads the `.ts`; the -// last test here is what keeps them one fact. -import { DEFAULT_REMOTE_CONNECT_SRC as BUILD_DEFAULT } from '../../../../scripts/csp-defaults.mjs'; -import { DEFAULT_REMOTE_CONNECT_SRC, originAllowedByConnectSrc } from './connect-src'; +// 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; @@ -75,3 +83,42 @@ describe('originAllowedByConnectSrc', () => { 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', () => { + // Both silently match 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']) { + expect(() => + resolveRemoteConnectSrc({ DORMOUSE_REMOTE_CONNECT_SRC: bad }, 'test'), + ).toThrow(/DORMOUSE_REMOTE_CONNECT_SRC/); + expect(originAllowedByConnectSrc('https://relay.example.ts.net', bad)).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, + ); + log.mockRestore(); + }); +}); diff --git a/lib/src/host/remote/connect-src.ts b/lib/src/host/remote/connect-src.ts index f8f65572..8033c220 100644 --- a/lib/src/host/remote/connect-src.ts +++ b/lib/src/host/remote/connect-src.ts @@ -59,8 +59,21 @@ interface ParsedSource { 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 = /^([a-z][a-z0-9+.-]*:)\/\/([^/:]+)(?::(\*|\d+))?$/i; + function parseSource(source: string): ParsedSource | null { - const match = /^([a-z][a-z0-9+.-]*:)\/\/([^/:]+)(?::(\*|\d+))?$/i.exec(source); + const match = CONNECT_SRC_SOURCE_PATTERN.exec(source); if (!match) return null; const group = schemeClass(match[1]!.toLowerCase()); if (!group) return null; diff --git a/lib/src/host/remote/host-state-store.test.ts b/lib/src/host/remote/host-state-store.test.ts index afef3304..bbe36cf6 100644 --- a/lib/src/host/remote/host-state-store.test.ts +++ b/lib/src/host/remote/host-state-store.test.ts @@ -13,6 +13,8 @@ const fsProbe = vi.hoisted(() => ({ 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) => { @@ -23,6 +25,10 @@ vi.mock('node:fs/promises', async (importOriginal) => { 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'); @@ -72,6 +78,7 @@ beforeEach(async () => { fsProbe.steps.length = 0; fsProbe.tmpWriteDelayMs = 0; fsProbe.chmodFails = false; + fsProbe.readFileError = null; }); afterEach(async () => { @@ -194,6 +201,10 @@ describe('FileHostStateStore', () => { // 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'); @@ -207,6 +218,33 @@ describe('FileHostStateStore', () => { 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(() => {}); diff --git a/lib/src/host/remote/host-state-store.ts b/lib/src/host/remote/host-state-store.ts index d5bfc8d6..abca9e44 100644 --- a/lib/src/host/remote/host-state-store.ts +++ b/lib/src/host/remote/host-state-store.ts @@ -116,6 +116,10 @@ export class FileHostStateStore implements HostStateStore { /** 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 @@ -132,21 +136,42 @@ export class FileHostStateStore implements HostStateStore { #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 ??= (async () => { - try { - return parseState(await readFile(this.#path, 'utf8')); - } catch (error) { - if ((error as { code?: string } | null)?.code !== 'ENOENT') { - // Fail closed but loudly, like `loadHostAcl`: starting empty 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(); - } - })(); + 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. diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 8cccf4df..b3b94ddf 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -263,6 +263,52 @@ describe('enroll', () => { 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', () => { @@ -349,6 +395,29 @@ describe('start', () => { 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', () => { diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index c3084fdf..97f25f9f 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -184,8 +184,25 @@ export class RemoteHostService { ); } const enrollment = await performEnrollment(params.serverUrl, params.password, params.label); - this.#stopHost(); + // 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 }; } @@ -212,12 +229,18 @@ export class RemoteHostService { } async #clearEnrollment(): Promise> { - this.#stopHost(); - this.#enrollment = null; + // 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 {}; } diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index c551b9ac..1066ccab 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -82,6 +82,23 @@ describe('asking the webview', () => { 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(); @@ -228,6 +245,22 @@ describe('PTYs', () => { expect(one.data).toEqual([]); }); + it('does not let a spent unsubscribe silence the attachment that replaced it', () => { + const first = sink(); + const unsubscribe = bridge.provider.streamPty('pty-1', first); + unsubscribe(); + + // 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); + unsubscribe(); + + 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 index 8f089214..dfb73af2 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -111,10 +111,17 @@ export function createSidecarSurfaceBridge( const subscribed = stream; subscribed.sinks.add(sink); return () => { + // 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. - if (subscribed.sinks.size === 0) streams.delete(ptyId); + streams.delete(ptyId); }; }, }); @@ -130,7 +137,18 @@ export function createSidecarSurfaceBridge( */ onAnswer(params) { if (!params || typeof params.rhId !== 'string') return; - asks.get(params.rhId)?.settle(Array.isArray(params.results) ? params.results : []); + 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() { diff --git a/lib/src/remote/host/acl.test.ts b/lib/src/remote/host/acl.test.ts index a77874f9..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,15 +25,20 @@ 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', loadAclRecords); @@ -44,8 +49,8 @@ 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', loadAclRecords).activeRecords()).toEqual([]); @@ -58,9 +63,17 @@ describe('remote-host acl persistence', () => { 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 eb89e548..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, removeJson, saveJson } from '../../lib/local-json-store'; +import { loadJson, removeJson } from '../../lib/local-json-store'; export const ACL_KEY_PREFIX = 'dormouse.remote-host.acl.'; @@ -40,10 +44,6 @@ export function loadAclRecords(hostId: string): HostAclRecord[] { return filterAclRecords(hostId, loadJson(aclKey(hostId), [], Array.isArray)); } -export function saveAclRecords(hostId: string, records: readonly HostAclRecord[]): void { - saveJson(aclKey(hostId), records); -} - /** * 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): diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index f10bc492..23f79c0c 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -184,17 +184,31 @@ function mirrorPairingQueue(link: RemoteHostLink, queue: readonly PairingQueueIt } } +/** + * 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; + /** * 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 ( - a.accountId === b.accountId && - a.passkeyCredentialId === b.passkeyCredentialId && - a.passkeyPublicKeyHash === b.passkeyPublicKeyHash && - a.devicePublicKey === b.devicePublicKey && - a.requestedLabel === b.requestedLabel + return (Object.keys(PAIRING_REQUEST_FIELDS) as Array).every( + (field) => a[field] === b[field], ); } diff --git a/lib/src/remote/host/enrollment.test.ts b/lib/src/remote/host/enrollment.test.ts index 7c50e6bc..885b2260 100644 --- a/lib/src/remote/host/enrollment.test.ts +++ b/lib/src/remote/host/enrollment.test.ts @@ -52,6 +52,35 @@ describe('remote-host enrollment', () => { 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 }))); diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts index 328cc2e1..f18ecd9c 100644 --- a/lib/src/remote/host/enrollment.ts +++ b/lib/src/remote/host/enrollment.ts @@ -57,6 +57,8 @@ export function clearEnrollment(): void { removeJson(ENROLLMENT_KEY); } +const ENROLL_TIMEOUT_MS = 10_000; + /** * `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 @@ -74,6 +76,13 @@ export async function performEnrollment( 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. diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 6dc0b836..7f1e1e62 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -163,6 +163,23 @@ describe('surface responder', () => { 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. diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index 0fb69c52..7da6b52e 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -21,6 +21,7 @@ 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'; @@ -94,6 +95,19 @@ function driveOwnSurface({ surfaceId, cols, rows }: PeerSurfaceParams): PeerSurf 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 @@ -106,7 +120,8 @@ export function installPeerSurfaceResponder(): void { answerPeers('surfaceOp', driveOwnSurface); const link = getPlatform().remoteHost; - if (!link) return; + 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`). diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index a55a26fd..5e0f1197 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -154,6 +154,33 @@ export class RemoteApiSession { 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 { @@ -256,12 +283,7 @@ export class RemoteApiSession { // the session died or a newer attach superseded this one during that // round trip, unwind it immediately. handle?.release(); - // The client holds a request pending until it is answered, so a - // superseded attach is failed rather than dropped — that also drops its - // event subscription. A disposed session has no transport to answer on. - if (!this.#disposed) { - this.#fail(request, `superseded by a newer attach: ${params.surfaceId}`); - } + this.#failAttach(request, params.surfaceId, generation); return; } if (!handle) { @@ -275,16 +297,21 @@ export class RemoteApiSession { // throw before an attachment is fully installed. if (this.#attachment?.handle === handle) this.#teardownAttachment(); else handle.release(); - this.#fail(request, `surface attach failed: ${errorMessage(error)}`); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface attach failed: ${errorMessage(error)}`, + ); } }, (error) => { - if (this.#disposed) return; - if (this.#attachGeneration !== generation) { - this.#fail(request, `superseded by a newer attach: ${params.surfaceId}`); - return; - } - this.#fail(request, `surface attach failed: ${errorMessage(error)}`); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface attach failed: ${errorMessage(error)}`, + ); }, ); } @@ -353,11 +380,11 @@ export class RemoteApiSession { if (this.#disposed) return; if (this.#attachGeneration !== generation || this.#attachment !== attachment) { if (this.#attachment === attachment) this.#teardownAttachment(); - this.#fail( + this.#failAttach( request, - this.#attachGeneration !== generation - ? `superseded by a newer attach: ${params.surfaceId}` - : `surface closed while attaching: ${params.surfaceId}`, + params.surfaceId, + generation, + `surface closed while attaching: ${params.surfaceId}`, ); return; } @@ -374,14 +401,12 @@ export class RemoteApiSession { // attach until the owner has actually applied it. Rejection is a normal // protocol error, not an unhandled promise rejection in the Host process. void handle.resize(cols, rows).then(finish, (error) => { - const current = this.#attachment === attachment; - if (current) this.#teardownAttachment(); - if (this.#disposed) return; - this.#fail( + if (this.#attachment === attachment) this.#teardownAttachment(); + this.#failAttach( request, - this.#attachGeneration !== generation - ? `superseded by a newer attach: ${params.surfaceId}` - : `surface attach failed: ${errorMessage(error)}`, + params.surfaceId, + generation, + `surface attach failed: ${errorMessage(error)}`, ); }); } else { diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs index 8cec8ad4..317613ee 100644 --- a/scripts/csp-defaults.mjs +++ b/scripts/csp-defaults.mjs @@ -17,14 +17,37 @@ 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 = /^([a-z][a-z0-9+.-]*:)\/\/([^/:]+)(?::(\*|\d+))?$/i; + /** * 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 || CONNECT_SRC_SOURCE_PATTERN.test(source)) continue; + throw new Error( + `[${label}] DORMOUSE_REMOTE_CONNECT_SRC: "${source}" is not a source the remote Host can ` + + 'match. Each entry must be scheme://host with an optional :port or :* — ' + + 'no trailing slash, no path, and the scheme is required ' + + `(e.g. "${DEFAULT_REMOTE_CONNECT_SRC}").`, + ); + } console.error(`[${label}] connect-src remote sources overridden: ${override}`); return override; } diff --git a/server/src/config.ts b/server/src/config.ts index e9960001..bb3b9b7e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -42,9 +42,16 @@ export class ConfigError extends Error {} type Env = Record; export function readConfig(env: Env = process.env): ServerConfig { - const port = Number(env.PORT ?? 3000); - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new ConfigError(`PORT must be an integer between 0 and 65535, got ${env.PORT}`); + // 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; diff --git a/server/test/bind-host.test.mjs b/server/test/bind-host.test.mjs index 03cd72de..b8a466fd 100644 --- a/server/test/bind-host.test.mjs +++ b/server/test/bind-host.test.mjs @@ -54,19 +54,27 @@ async function startServer(extraEnv) { stdio: ['ignore', 'pipe', 'pipe'], }); - 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')) { + 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); - resolve(); - } - }); - child.on('exit', (code) => { - clearTimeout(timer); - reject(new Error(`server exited early with code ${code}`)); + 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() }; } diff --git a/server/test/config.test.mjs b/server/test/config.test.mjs index cbec6133..e437d340 100644 --- a/server/test/config.test.mjs +++ b/server/test/config.test.mjs @@ -46,6 +46,19 @@ test('an unusable PORT is a 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'); diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 999c4e78..395bf8da 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -101,12 +101,13 @@ "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 && 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 stage:dor-cli && node scripts/esbuild.mjs --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", diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index bdb3cd4d..9ec260f5 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -74,6 +74,9 @@ configurePeerLink({ streamPty: processedPtyStreams.streamPty, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), + // Two windows can hold the same pane id — "Duplicate Workspace in New Window" + // cold-restores them — so the link asks before it routes one away. + 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, @@ -610,9 +613,19 @@ export function attachRouter( // 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) break; + 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); - request.pending.delete(router); if (request.pending.size === 0) request.settle(); break; } diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 9e8beccf..7e3d141a 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -78,6 +78,17 @@ import { log } from './log'; 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. + * + * Pane ids are unique within a window and nothing coordinates them across + * windows — "Duplicate Workspace in New Window" cold-restores the *same* ids + * into a second window — so a peer answering an op can name an id this + * window already owns. Routing on that answer would post the broker's own + * keystrokes into the other window's shell, so the local owner wins + * ({@link remoteRequest}). + */ + ownsPty(ptyId: string): boolean; /** A peer window's answers may have changed, so the directory is stale. */ invalidateDirectory(): void; /** @@ -254,12 +265,34 @@ export interface RemotePtySink { } 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(); const routes = new Map(); const remoteSinks = new Map>(); -const pendingRequests = new Map void>(); + +/** + * 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( @@ -283,10 +316,13 @@ function ask( pendingRequests.delete(frame.id); resolve(null); }, PEER_REPLY_BUDGET_MS); - pendingRequests.set(frame.id, (response) => { - clearTimeout(timer); - pendingRequests.delete(frame.id); - resolve(response); + pendingRequests.set(frame.id, { + client, + settle: (response) => { + clearTimeout(timer); + pendingRequests.delete(frame.id); + resolve(response); + }, }); send(client, frame); }); @@ -329,7 +365,16 @@ export async function remoteRequest(op: string, params: unknown): Promise { 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) { @@ -553,6 +625,9 @@ async function tryBind(path: string, token: string): Promise { 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; } @@ -584,8 +659,10 @@ function respondTo(socket: Socket, frame: PeerLinkResponse): void { } export function remoteNotifyPeerChange(): void { - // The broker is the destination; its own window was notified directly. - if (server) return; + // 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; @@ -814,7 +891,11 @@ const delay = (ms: number): Promise => new Promise((resolve) => setTimeout */ export function ensurePeerNet(onRole: (broker: boolean) => void): Promise { announceRole = onRole; - if (server) { + // `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(); } @@ -832,9 +913,9 @@ export function ensurePeerNet(onRole: (broker: boolean) => void): Promise return settled; } -/** Whether this window holds the Host. */ +/** Whether this window holds the Host — verified, not merely bound. */ export function isPeerBroker(): boolean { - return server !== null; + return server !== null && brokerConfirmed; } /** @@ -847,8 +928,9 @@ export function isPeerBroker(): boolean { 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. - return server !== null || (client !== null && !client.destroyed) || refused; + // 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; } /** @@ -889,7 +971,21 @@ async function attempt(): Promise { settle(false); return true; } - const token = await ensureToken(); + 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 @@ -899,6 +995,8 @@ async function attempt(): Promise { 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; } @@ -925,6 +1023,9 @@ async function attempt(): Promise { } 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; } @@ -1004,6 +1105,7 @@ async function contend(): Promise { 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; diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index c7f6bb8b..656b92b8 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -99,6 +99,16 @@ export function getBufferedPtys(): Map; + /** PTY ids this window's own manager holds — what `ownsPty` answers. */ + ownPtyIds?: string[]; } = {}, ) { const dataListeners = new Set<(id: string, data: string) => void>(); @@ -106,6 +108,7 @@ export function fakeWindow( 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, @@ -137,6 +140,7 @@ export function fakeWindow( 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 }), 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.test.ts b/vscode-ext/test/peer-link.test.ts index 4fd1ba68..4d88ab17 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -119,6 +119,145 @@ describe('bind-as-lease', () => { .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('does not route a PTY id this window already owns to the peer that claimed it', 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. + // Routing on that sends the phone's keystrokes into the other window's + // shell instead of the one it attached to. + const brokerSide = fakeWindow({ ownPtyIds: ['pty-far'] }); + const { broker } = await linkedPair(brokerSide, farWindow()); + + expect(await attachFar(broker)).toEqual([{ ptyId: 'pty-far', cols: 80, rows: 24 }]); + // No route, so writes fall back to this window's own manager. + expect(broker.isRemotePty('pty-far')).toBe(false); + expect(broker.remoteWrite('pty-far', 'ls\r')).toBe(false); + await tick(100); + 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 stat(path)).ino; + + const mod = await openWindow(fakeWindow()); + const roles: boolean[] = []; + const settled = mod.ensurePeerNet((held) => roles.push(held)); + + // The instant the path names a new inode this window has bound it — and is + // still deciding whether it may keep it. + const during: boolean[] = []; + let brokerDuring = true; + let settledDuring = true; + await waitFor(async () => { + const now = await stat(path).catch(() => null); + if (!now || now.ino === 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); diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index ca74387d..df2396ab 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -175,6 +175,7 @@ function bridgeLinkToHost( link.configurePeerLink({ brokerRequest: local.brokerRequest, invalidateDirectory: mod.notifyDirectoryChanged, + ownsPty: () => false, streamPty: local.streamPty, writePty: local.writePty, resizePty: local.resizePty, @@ -432,11 +433,21 @@ describe('remote host service glue', () => { mod.initRemoteHost(fakeContext().context); // Nothing has contended yet, so there is no Host here and no socket to - // reach one through. Refusing beats a silent drop: the console hook would - // otherwise hang for its whole timeout. + // 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', error: 'no remote Host is reachable' }, + { + 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 @@ -538,6 +549,64 @@ describe('remote host service glue', () => { 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 From c9073a079598aecef1acda17933899d6c188954e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 16:08:04 -0700 Subject: [PATCH 50/56] Bind pairing approval to the displayed request --- docs/specs/remote-security-model.md | 7 ++ docs/specs/server.md | 24 +++--- lib/src/host/remote/service-protocol.ts | 4 + lib/src/host/remote/service.test.ts | 50 ++++++++++-- lib/src/host/remote/service.ts | 21 +++-- .../remote/host/RemotePairingModalHost.tsx | 10 +-- lib/src/remote/host/activation.test.ts | 76 ++++++++++++++++--- lib/src/remote/host/activation.ts | 21 ++++- lib/src/remote/host/pairing-approval.ts | 10 ++- lib/src/remote/host/remote-host.test.ts | 32 ++++++++ lib/src/remote/host/remote-host.ts | 10 ++- 11 files changed, 217 insertions(+), 48 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index fd0875a2..01790765 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -181,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; diff --git a/docs/specs/server.md b/docs/specs/server.md index f690ad0d..7a2ced77 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -499,18 +499,18 @@ away. `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, request, requestedAt }[]`, pushed - whole on every change) and answer by `clientId`, so the approve/deny closures - never leave the Host's process. **The mirror is compared by content, not by - id.** The service coalesces a re-sent pair under one `clientId` by *replacing* - what it holds, so the same id can come to name a different device — and - Approve authorizes what the service holds. A mirror that skipped an item whose - id it already showed would put the user's consent on a device they were never - shown, so an item whose `requestedAt` or request fields differ replaces the - mirrored one and the modal remounts (it is keyed on `clientId:requestedAt`). - 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; + 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 diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts index aeb8c14e..fe356d33 100644 --- a/lib/src/host/remote/service-protocol.ts +++ b/lib/src/host/remote/service-protocol.ts @@ -68,6 +68,8 @@ export interface RemoteHostAsk { /** 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; } @@ -102,11 +104,13 @@ export interface EnrollParams { 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. */ diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index b3b94ddf..b901cdf1 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -580,6 +580,7 @@ describe('pairing queue', () => { 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. @@ -589,8 +590,9 @@ describe('pairing 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', label: 'Ned iPhone' }); + await command('approve', { clientId: 'c1', pairingId, label: 'Ned iPhone' }); const result = socket.frames('pair-result')[0]!; expect(result).toMatchObject({ clientId: 'c1', approved: true }); @@ -602,8 +604,9 @@ describe('pairing queue', () => { 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' }); + await command('deny', { clientId: 'c1', pairingId }); expect(socket.frames('pair-result')[0]).toMatchObject({ approved: false }); expect(store.acl['host-1']).toBeUndefined(); @@ -622,13 +625,50 @@ describe('pairing queue', () => { expect(queueEvents().at(-1)!.queue).toEqual([]); }); - it('approving something already resolved is a no-op', async () => { + it('rejects approval for something already resolved', async () => { const socket = await running(); socket.receive({ t: 'pair', clientId: 'c1', request: PAIRING }); - await command('approve', { clientId: 'c1' }); - await command('approve', { clientId: 'c1' }); + 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', () => { diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 97f25f9f..c61853d2 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -84,8 +84,9 @@ export class RemoteHostService { #disposed = false; /** * Pairings awaiting local approval, service-side. The webview mirrors a - * serializable projection of this and answers by clientId; the approve/deny - * closures the `RemoteHost` handed us never leave this process. + * 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(); @@ -246,15 +247,24 @@ export class RemoteHostService { } #approve(params: ApproveParams): Record { - this.#pairings.get(params.clientId)?.approve(params.label); + this.#pendingPairing(params.clientId, params.pairingId).approve(params.label); return {}; } #deny(params: DenyParams): Record { - this.#pairings.get(params.clientId)?.deny(); + 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 @@ -407,8 +417,9 @@ export class RemoteHostService { } #queueSnapshot(): PairingQueueItem[] { - return [...this.#pairings.values()].map(({ clientId, request, requestedAt }) => ({ + return [...this.#pairings.values()].map(({ clientId, pairingId, request, requestedAt }) => ({ clientId, + pairingId, request, requestedAt, })); diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx index 5f4110b7..e746b750 100644 --- a/lib/src/remote/host/RemotePairingModalHost.tsx +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -32,11 +32,11 @@ export function RemotePairingModalHost({ return ( head.approve()} onDeny={() => head.deny()} diff --git a/lib/src/remote/host/activation.test.ts b/lib/src/remote/host/activation.test.ts index 7bbee290..af1003e4 100644 --- a/lib/src/remote/host/activation.test.ts +++ b/lib/src/remote/host/activation.test.ts @@ -271,19 +271,27 @@ describe('remote host bridge mode', () => { link.emit('pairing-queue', { name: 'pairing-queue', - queue: [{ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }], + queue: [{ clientId: 'c1', pairingId: 'p1', request: PAIRING_REQUEST, requestedAt: 5 }], }); const head = pairing.getPairingApprovalSnapshot()[0]!; - expect(head).toMatchObject({ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }); + 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', label: 'Ned iPhone' }, + params: { clientId: 'c1', pairingId: 'p1', label: 'Ned iPhone' }, }); head.deny(); - expect(link.commands.at(-1)).toEqual({ cmd: 'deny', params: { clientId: 'c1' } }); + expect(link.commands.at(-1)).toEqual({ + cmd: 'deny', + params: { clientId: 'c1', pairingId: 'p1' }, + }); }); it('replaces the mirror wholesale — the service is authoritative', async () => { @@ -291,7 +299,12 @@ describe('remote host bridge mode', () => { const { pairing } = await installBridge(link); const queue = (ids: string[]) => ({ name: 'pairing-queue', - queue: ids.map((clientId) => ({ clientId, request: PAIRING_REQUEST, requestedAt: 5 })), + queue: ids.map((clientId) => ({ + clientId, + pairingId: `pairing-${clientId}`, + request: PAIRING_REQUEST, + requestedAt: 5, + })), }); link.emit('pairing-queue', queue(['c1', 'c2'])); @@ -321,16 +334,30 @@ describe('remote host bridge mode', () => { link.emit('pairing-queue', { name: 'pairing-queue', - queue: [{ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }], + 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', request: second, requestedAt: 9 }], + queue: [{ clientId: 'c1', pairingId: 'p2', request: second, requestedAt: 9 }], }); const head = pairing.getPairingApprovalSnapshot(); expect(head).toHaveLength(1); - expect(head[0]).toMatchObject({ clientId: 'c1', request: second, requestedAt: 9 }); + 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 () => { @@ -340,18 +367,43 @@ describe('remote host bridge mode', () => { const { pairing } = await installBridge(link); const snapshot = () => ({ name: 'pairing-queue', - queue: [{ clientId: 'c1', request: { ...PAIRING_REQUEST }, requestedAt: 5 }], + 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', request: PAIRING_REQUEST, requestedAt: 5 }]; + 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); @@ -364,7 +416,9 @@ describe('remote host bridge mode', () => { // would stay hidden until the pairing was answered somewhere else. const link = fakeLink(); link.results.status = { enrolled: false }; - link.results.pairingQueue = [{ clientId: 'c1', request: PAIRING_REQUEST, requestedAt: 5 }]; + 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); diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts index 23f79c0c..3b50ee0d 100644 --- a/lib/src/remote/host/activation.ts +++ b/lib/src/remote/host/activation.ts @@ -164,8 +164,14 @@ function mirrorPairingQueue(link: RemoteHostLink, queue: readonly PairingQueueIt 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 approve/deny closures only need the clientId. - if (showing && showing.requestedAt === item.requestedAt && sameRequest(showing.request, item.request)) { + // 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 @@ -176,10 +182,17 @@ function mirrorPairingQueue(link: RemoteHostLink, queue: readonly PairingQueueIt 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, label }).catch(() => {}), - deny: () => void link.command('deny', { clientId: item.clientId }).catch(() => {}), + 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(() => {}), }); } } diff --git a/lib/src/remote/host/pairing-approval.ts b/lib/src/remote/host/pairing-approval.ts index 30b9a92f..bf7028b5 100644 --- a/lib/src/remote/host/pairing-approval.ts +++ b/lib/src/remote/host/pairing-approval.ts @@ -5,15 +5,19 @@ * 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 `clientId` — so the closures - * that can actually write the ACL never leave that process. + * `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/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts index 3bad92dd..ca395e44 100644 --- a/lib/src/remote/host/remote-host.test.ts +++ b/lib/src/remote/host/remote-host.test.ts @@ -212,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); diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts index c7a8d6e1..ea5b15b6 100644 --- a/lib/src/remote/host/remote-host.ts +++ b/lib/src/remote/host/remote-host.ts @@ -329,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), }; @@ -341,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 { @@ -363,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); From ec8d5877814e1f1e99d42a6763c155fdae21834a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 16:14:24 -0700 Subject: [PATCH 51/56] Reject unusable remote connect sources --- docs/specs/server.md | 13 +++++----- lib/src/host/remote/connect-src.test.ts | 33 ++++++++++++++++++++++--- lib/src/host/remote/connect-src.ts | 17 +++++++++++-- scripts/csp-defaults.mjs | 17 ++++++++++--- 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index 7a2ced77..5cc375a8 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -95,12 +95,13 @@ 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. The -runtime fails closed on a source it cannot parse, so without this such a build -succeeds and then refuses to enroll against the very server it was built for. -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. +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 diff --git a/lib/src/host/remote/connect-src.test.ts b/lib/src/host/remote/connect-src.test.ts index cc2cf645..87e4aeae 100644 --- a/lib/src/host/remote/connect-src.test.ts +++ b/lib/src/host/remote/connect-src.test.ts @@ -94,14 +94,32 @@ describe('the build-time check on a self-hoster’s override', () => { }); it('fails the build on an override the Host could never match', () => { - // Both silently match nothing at runtime, so the binary builds green and + // 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']) { + 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', bad)).toBe(false); } + 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( @@ -119,6 +137,15 @@ describe('the build-time check on a self-hoster’s override', () => { 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 index 8033c220..182bf24f 100644 --- a/lib/src/host/remote/connect-src.ts +++ b/lib/src/host/remote/connect-src.ts @@ -70,17 +70,30 @@ interface ParsedSource { * 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 = /^([a-z][a-z0-9+.-]*:)\/\/([^/:]+)(?::(\*|\d+))?$/i; +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: match[3] ?? defaultPort(group), + port, }; } diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs index 317613ee..60aa2b80 100644 --- a/scripts/csp-defaults.mjs +++ b/scripts/csp-defaults.mjs @@ -23,7 +23,16 @@ export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormous * TypeScript, and `lib/src/host/remote/connect-src.test.ts` asserts the two * patterns are the same string. */ -export const CONNECT_SRC_SOURCE_PATTERN = /^([a-z][a-z0-9+.-]*:)\/\/([^/:]+)(?::(\*|\d+))?$/i; +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` @@ -40,11 +49,11 @@ 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 || CONNECT_SRC_SOURCE_PATTERN.test(source)) continue; + 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 be scheme://host with an optional :port or :* — ' + - 'no trailing slash, no path, and the scheme is required ' + + '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}").`, ); } From 1af6e6ae2a0492c2129cd6e5f969259d37977dd9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 16:29:33 -0700 Subject: [PATCH 52/56] Preserve PTY exits during surface attachment --- docs/specs/remote-api.md | 16 ++- docs/specs/standalone.md | 5 +- docs/specs/vscode.md | 2 +- lib/src/host/remote/sidecar-entry.test.ts | 38 ++++++- lib/src/host/remote/sidecar-entry.ts | 41 ++++++- lib/src/remote/host/host-surface-provider.ts | 27 ++++- lib/src/remote/host/remote-api.test.ts | 85 +++++++++++++- lib/src/remote/host/remote-api.ts | 96 ++++++++++++---- standalone/sidecar/pty-core.js | 9 +- standalone/sidecar/pty-core.test.js | 4 + vscode-ext/src/message-router.ts | 6 +- vscode-ext/src/peer-link-protocol.ts | 10 +- vscode-ext/src/peer-link.ts | 104 ++++++++++++++++-- vscode-ext/src/processed-pty-streams.ts | 28 ++++- vscode-ext/src/pty-manager.ts | 11 ++ vscode-ext/src/remote-host.ts | 12 +- vscode-ext/test/helpers.ts | 4 + vscode-ext/test/peer-link-protocol.test.ts | 10 +- vscode-ext/test/peer-link.test.ts | 49 +++++++++ vscode-ext/test/processed-pty-streams.test.ts | 23 ++++ vscode-ext/test/remote-host.test.ts | 29 ++++- 21 files changed, 534 insertions(+), 75 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 62d93186..2e3d5a24 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -310,10 +310,18 @@ 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, so a PTY that exits inside the window tears the attachment down first: -the attach is then 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. +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/standalone.md b/docs/specs/standalone.md index cdd0d522..11ec2e82 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -217,8 +217,9 @@ 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 the tap returns on the first line — the usual -state of a machine with no phone on it. +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 diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index c4d327c2..d4a3442e 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -353,7 +353,7 @@ A client window answers a `request` frame by running its **own in-window** fan-o **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 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 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`). +**Cross-window streams are reference-counted per 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 records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. **Unless this window already has that id**: pane ids are unique within a window and nothing coordinates them across windows — "Duplicate Workspace in New Window" cold-restores identical ids into a second window — so a peer's answer can name one of the broker's own terminals. The route is skipped when `deps.ownsPty` says so (`ptyManager.hasPty` or a webview's claim), and local wins. Recording it would send the phone's keystrokes for the broker's own PTY over the socket and into the other window's shell. `writePty` / `resizePty` consult that table and fall back to this window's `ptyManager` — the link takes only a PTY it has a route for, so a local PTY can never be taken out from under the manager that owns it. When a peer disconnects, every PTY 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. diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index 1066ccab..ce41bebe 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -11,6 +11,7 @@ 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. */ @@ -36,11 +37,13 @@ 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), }, }); }); @@ -237,24 +240,49 @@ describe('PTYs', () => { 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 unsubscribe = bridge.provider.streamPty('pty-1', one); - unsubscribe(); + 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 unsubscribe = bridge.provider.streamPty('pty-1', first); - unsubscribe(); + 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); - unsubscribe(); + subscription.stop(); bridge.onPtyEvent('data', { id: 'pty-1', data: 'still flowing' }); expect(second.data).toEqual(['still flowing']); diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index dfb73af2..523bf25a 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -30,6 +30,8 @@ import { 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 { @@ -97,6 +99,8 @@ export function createSidecarSurfaceBridge( 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), @@ -110,7 +114,7 @@ export function createSidecarSurfaceBridge( } const subscribed = stream; subscribed.sinks.add(sink); - return () => { + 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 @@ -123,6 +127,27 @@ export function createSidecarSurfaceBridge( // 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() }; }, }); @@ -156,11 +181,15 @@ export function createSidecarSurfaceBridge( }, onPtyEvent(event, data) { - // Nothing is attached: this runs on every chunk of every PTY, and the - // usual state of a machine with no phone on it is exactly this. - if (streams.size === 0) return; 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') { @@ -174,8 +203,7 @@ export function createSidecarSurfaceBridge( return; } if (event === 'exit') { - const exitCode = (detail as { exitCode?: unknown }).exitCode; - const code = typeof exitCode === 'number' ? exitCode : 0; + const code = exits.get(detail.id) ?? 0; for (const sink of stream.sinks) sink.onExit(code); } }, @@ -184,6 +212,7 @@ export function createSidecarSurfaceBridge( for (const pending of [...asks.values()]) pending.settle([]); asks.clear(); streams.clear(); + exits.clear(); }, }; } diff --git a/lib/src/remote/host/host-surface-provider.ts b/lib/src/remote/host/host-surface-provider.ts index ee973b89..33a00651 100644 --- a/lib/src/remote/host/host-surface-provider.ts +++ b/lib/src/remote/host/host-surface-provider.ts @@ -45,6 +45,17 @@ export interface PtySink { 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 — @@ -88,9 +99,17 @@ export interface HostSurfaceProvider { resizePty(ptyId: string, cols: number, rows: number): void; /** - * Subscribe to one PTY's output and exit; returns the unsubscribe. 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. + * 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): () => void; + streamPty(ptyId: string, sink: PtySink): PtyStream; } diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts index 108b9589..4ed8ebb2 100644 --- a/lib/src/remote/host/remote-api.test.ts +++ b/lib/src/remote/host/remote-api.test.ts @@ -61,8 +61,11 @@ class FakeProvider implements HostSurfaceProvider { 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 --- @@ -100,7 +103,7 @@ class FakeProvider implements HostSurfaceProvider { this.emitData(ptyId, `pty-resize:${cols}x${rows}`); }; - streamPty = (ptyId: string, sink: PtySink): (() => void) => { + streamPty = (ptyId: string, sink: PtySink) => { this.streamed.push(ptyId); let sinks = this.#sinks.get(ptyId); if (!sinks) { @@ -108,16 +111,23 @@ class FakeProvider implements HostSurfaceProvider { this.#sinks.set(ptyId, sinks); } sinks.add(sink); - return () => { + 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 --- 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; } @@ -128,6 +138,7 @@ class FakeProvider implements HostSurfaceProvider { } emitExit(ptyId: string, exitCode: number): void { + this.#exits.set(ptyId, exitCode); for (const sink of [...(this.#sinks.get(ptyId) ?? [])]) sink.onExit(exitCode); } @@ -935,6 +946,76 @@ describe('RemoteApiSession surface.detach', () => { }); 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); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts index 5e0f1197..70b64ef0 100644 --- a/lib/src/remote/host/remote-api.ts +++ b/lib/src/remote/host/remote-api.ts @@ -332,6 +332,13 @@ export class RemoteApiSession { 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); @@ -339,7 +346,7 @@ export class RemoteApiSession { pendingEvents.push({ event, data }); } }; - const stopStream = this.#provider.streamPty(ptyId, { + 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 @@ -358,28 +365,46 @@ export class RemoteApiSession { // and nulls #attachment so #requireAttached fails and the bounce timer // is cleared. emitOrBuffer(REMOTE_EVENTS.terminalClosed, { exitCode }); - this.#teardownAttachment(); + if (attachment && this.#attachment === attachment) { + this.#teardownAttachment(); + } else { + closedWhileSubscribing = true; + } }, }); - const attachment: Attachment = { + if (closedWhileSubscribing) { + stream.stop(); + handle.release(); + this.#failAttach( + request, + params.surfaceId, + generation, + `surface closed while attaching: ${params.surfaceId}`, + ); + return; + } + attachment = { surfaceId: params.surfaceId, handle, subId, - stopStream, + 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. - // A sibling's owner already applied the size inside the attach round trip, + // 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 !== attachment) { - if (this.#attachment === attachment) this.#teardownAttachment(); + if (this.#attachGeneration !== generation || this.#attachment !== installedAttachment) { + if (this.#attachment === installedAttachment) this.#teardownAttachment(); this.#failAttach( request, params.surfaceId, @@ -396,20 +421,35 @@ export class RemoteApiSession { } }; - 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 promise rejection in the Host process. - void handle.resize(cols, rows).then(finish, (error) => { - if (this.#attachment === attachment) this.#teardownAttachment(); + 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 attach failed: ${errorMessage(error)}`, + `surface closed while attaching: ${params.surfaceId}`, ); - }); - } else { + 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 @@ -422,13 +462,27 @@ export class RemoteApiSession { // 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; + 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 }); - } + }; + + 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 { 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/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 9ec260f5..45dbc30d 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -64,7 +64,11 @@ interface PendingRequest { timer: ReturnType; } const peerRequests = new Map(); -const processedPtyStreams = createProcessedPtyStreams(onProcessedPtyData, onProcessedPtyExit); +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. diff --git a/vscode-ext/src/peer-link-protocol.ts b/vscode-ext/src/peer-link-protocol.ts index 358ba98a..b73d7748 100644 --- a/vscode-ext/src/peer-link-protocol.ts +++ b/vscode-ext/src/peer-link-protocol.ts @@ -43,13 +43,13 @@ export const PEER_REPLY_BUDGET_MS = ASK_BUDGET_MS + 2_000; * and this layer only moves the bytes. Adding an operation touches neither this * file nor the socket code. * - * Only `request` carries a frame id, because only `request` is awaited. The - * four PTY frames are one-way instructions to the owning window: nothing waits - * on them, and the stream they start is correlated by `ptyId`. + * `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'; ptyId: string } + | { kind: 'subscribe'; id: string; ptyId: string } | { kind: 'unsubscribe'; ptyId: string } | { kind: 'write'; ptyId: string; data: string } | { kind: 'resizePty'; ptyId: string; cols: number; rows: number } @@ -76,6 +76,8 @@ export type PeerLinkResponse = * "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. */ diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 7e3d141a..2cee80c1 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -284,6 +284,17 @@ const clients = new Set(); const routes = new Map(); const remoteSinks = new Map>(); +interface PendingRemoteSubscription { + client: PeerLinkClient; + ptyId: string; + promise: Promise; + settle(): void; +} + +/** Subscribe acknowledgement by frame id, plus its one in-flight id per PTY. */ +const pendingRemoteSubscriptions = new Map(); +const pendingRemoteSubscriptionByPty = 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. @@ -306,9 +317,9 @@ function send( /** Ask one peer and resolve when it answers, or when the budget expires. */ function ask( client: PeerLinkClient, - // `request` is the only frame anything waits on, and so the only one that - // carries an id; everything else is one-way and correlated by `ptyId` or by - // the `rhId` already inside it. + // 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) => { @@ -332,6 +343,52 @@ function authenticatedClients(): PeerLinkClient[] { return [...clients].filter((client) => client.authenticated); } +function settleRemoteSubscription(ptyId: string): void { + const id = pendingRemoteSubscriptionByPty.get(ptyId); + 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, ptyId: 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 }); + routes.delete(ptyId); + for (const sink of [...(remoteSinks.get(ptyId) ?? [])]) sink.onExit(0); + remoteSinks.delete(ptyId); + pending.settle(); + }, PEER_REPLY_BUDGET_MS); + (timer as unknown as { unref?: () => void }).unref?.(); + + const settle = () => { + if (pendingRemoteSubscriptions.get(id)?.ptyId !== ptyId) return; + clearTimeout(timer); + pendingRemoteSubscriptions.delete(id); + if (pendingRemoteSubscriptionByPty.get(ptyId) === id) { + pendingRemoteSubscriptionByPty.delete(ptyId); + } + resolveReady(); + }; + pendingRemoteSubscriptions.set(id, { client, ptyId, promise, settle }); + pendingRemoteSubscriptionByPty.set(ptyId, id); + send(client, { kind: 'subscribe', id, ptyId }); + 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); @@ -386,9 +443,13 @@ export function isRemotePty(ptyId: string): boolean { return routes.get(ptyId) !== undefined; } -export function remoteSubscribe(ptyId: string, sink: RemotePtySink): void { +/** 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); - if (!client) return; + if (!client) { + 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 @@ -397,9 +458,14 @@ export function remoteSubscribe(ptyId: string, sink: RemotePtySink): void { if (!sinks) { sinks = new Set(); remoteSinks.set(ptyId, sinks); - send(client, { kind: 'subscribe', ptyId }); + sinks.add(sink); + return beginRemoteSubscription(client, ptyId); } sinks.add(sink); + const pendingId = pendingRemoteSubscriptionByPty.get(ptyId); + return pendingId + ? pendingRemoteSubscriptions.get(pendingId)?.promise ?? Promise.resolve() + : Promise.resolve(); } export function remoteUnsubscribe(ptyId: string, sink: RemotePtySink): void { @@ -414,8 +480,8 @@ export function remoteUnsubscribe(ptyId: string, sink: RemotePtySink): void { // owning window disconnecting (`forgetPeerRoutes`). remoteSinks.delete(ptyId); const client = routes.get(ptyId); - if (!client) return; - send(client, { kind: 'unsubscribe', ptyId }); + if (client) send(client, { kind: 'unsubscribe', ptyId }); + settleRemoteSubscription(ptyId); } export function remoteWrite(ptyId: string, data: string): boolean { @@ -468,6 +534,7 @@ function dropClient(client: PeerLinkClient): void { for (const ptyId of forgetPeerRoutes(routes, client)) { for (const sink of remoteSinks.get(ptyId) ?? []) sink.onExit(0); remoteSinks.delete(ptyId); + settleRemoteSubscription(ptyId); } // 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 @@ -540,6 +607,12 @@ function onServerFrame(client: PeerLinkClient, frame: unknown): void { routes.delete(response.ptyId); for (const sink of [...(remoteSinks.get(response.ptyId) ?? [])]) sink.onExit(response.exitCode); remoteSinks.delete(response.ptyId); + settleRemoteSubscription(response.ptyId); + return; + } + if (response.kind === 'subscribed') { + const pending = pendingRemoteSubscriptions.get(response.id); + if (pending?.client === client) pending.settle(); return; } if (response.kind === 'notify') { @@ -709,19 +782,30 @@ async function onClientFrame(socket: Socket, frame: unknown): Promise { break; } case 'subscribe': { - if (forwarding.has(request.ptyId)) break; + 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); }, }); - forwarding.set(ptyId, stop); + // `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': diff --git a/vscode-ext/src/processed-pty-streams.ts b/vscode-ext/src/processed-pty-streams.ts index 36b216b5..84c124f0 100644 --- a/vscode-ext/src/processed-pty-streams.ts +++ b/vscode-ext/src/processed-pty-streams.ts @@ -28,9 +28,15 @@ export interface ProcessedPtyStreams { 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; @@ -78,7 +84,7 @@ export function createProcessedPtyStreams( subscribed.add(sink); install(); - return () => { + 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. @@ -88,6 +94,26 @@ export function createProcessedPtyStreams( 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 656b92b8..85a759e8 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -99,6 +99,17 @@ export function getBufferedPtys(): Map remoteUnsubscribe(ptyId, sink); + 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 bound.streamPty(ptyId, sink); + return { + stop: bound.streamPty(ptyId, sink), + ready: Promise.resolve(), + }; }, }); return askProvider.provider; diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index 7e81d860..b4f28563 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -95,6 +95,7 @@ export function fakeWindow( ) { 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); @@ -104,6 +105,7 @@ export function fakeWindow( exitListeners.add(listener); return () => void exitListeners.delete(listener); }, + (id) => ptyStatuses.get(id) ?? { alive: true }, ); return { entries: options.entries ?? [], @@ -122,9 +124,11 @@ export function fakeWindow( /** 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 { diff --git a/vscode-ext/test/peer-link-protocol.test.ts b/vscode-ext/test/peer-link-protocol.test.ts index 201fd008..be8685c6 100644 --- a/vscode-ext/test/peer-link-protocol.test.ts +++ b/vscode-ext/test/peer-link-protocol.test.ts @@ -76,19 +76,19 @@ describe('FrameDecoder', () => { expect(decoder.push('\n\n')).toEqual([]); }); - it('carries the one-way PTY frames, which have no id of their own', () => { - // Nothing awaits them — the stream they start is correlated by `ptyId` — - // so a frame id would be a field nobody ever reads. + it('correlates stream readiness while leaving later PTY frames one-way', () => { const decoder = new FrameDecoder(); expect( decoder.push( - encodeFrame({ kind: 'subscribe', ptyId: 'pty-1' }) + + 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', ptyId: 'pty-1' }, + { 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' }, diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 4d88ab17..6636ef73 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -459,6 +459,55 @@ describe('bind-as-lease', () => { expect(broker.isRemotePty('pty-far')).toBe(false); }); + it('fails closed when the owner route disappears before subscription', async () => { + const peerSide = farWindow(); + const { broker, peer } = await linkedPair(fakeWindow(), peerSide); + await attachFar(broker); + + await peer.disposePeerLink(); + await waitFor(() => !broker.isRemotePty('pty-far')); + + const sink = fakeSink(); + await broker.remoteSubscribe('pty-far', 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); + await attachFar(broker); + const first = fakeSink(); + const firstOrder: string[] = []; + const firstReady = broker + .remoteSubscribe('pty-far', { + ...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('pty-far')).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. + await attachFar(broker); + const second = fakeSink(); + await broker.remoteSubscribe('pty-far', 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); diff --git a/vscode-ext/test/processed-pty-streams.test.ts b/vscode-ext/test/processed-pty-streams.test.ts index 1e8718ea..f71b742d 100644 --- a/vscode-ext/test/processed-pty-streams.test.ts +++ b/vscode-ext/test/processed-pty-streams.test.ts @@ -12,17 +12,25 @@ import { createProcessedPtyStreams } from '../src/processed-pty-streams'; 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) => { @@ -33,6 +41,7 @@ function fakeSource() { exit.add(listener); return () => void exit.delete(listener); }, + (id) => statuses.get(id) ?? { alive: true }, ), }; } @@ -142,6 +151,19 @@ describe('processed pty streams', () => { 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. @@ -169,6 +191,7 @@ describe('processed pty streams', () => { 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. diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index df2396ab..8d805d59 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -116,6 +116,7 @@ function fakeDeps() { 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); @@ -125,14 +126,17 @@ function fakeDeps() { 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(), @@ -707,10 +711,11 @@ describe('remote host provider', () => { const provider = mod.createRemoteHostProvider(bound.deps()); const seen: string[] = []; const exits: number[] = []; - const stop = provider.streamPty('pty-1', { + 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'); @@ -720,11 +725,25 @@ describe('remote host provider', () => { expect(seen).toEqual(['hello\x1b]0;title\x07']); expect(exits).toEqual([7]); - stop(); + 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(); @@ -846,8 +865,8 @@ describe('serving the other windows', () => { await waitFor(async () => !!(await provider.resolveSurface('far-1', { cols: 80, rows: 24 }))); const sink = fakeSink(); - const stop = provider.streamPty('pty-far', sink); - await tick(); + const stream = provider.streamPty('pty-far', sink); + await stream.ready; far.emitData('pty-far', 'from the other window'); await waitFor(() => sink.data.length > 0); @@ -857,7 +876,7 @@ describe('serving the other windows', () => { expect(far.writes).toEqual([{ ptyId: 'pty-far', data: 'ls\r' }]); expect(far.resizes).toEqual([{ ptyId: 'pty-far', cols: 120, rows: 40 }]); - stop(); + stream.stop(); await tick(); far.emitData('pty-far', 'after the unsubscribe'); await tick(100); From 99787fbc5fb464fafc2036dde3d27579955e7236 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 16:31:55 -0700 Subject: [PATCH 53/56] Bind surface handles to their selected peer --- docs/specs/remote-api.md | 5 +- docs/specs/vscode.md | 16 +- lib/src/host/remote/ask-surface-provider.ts | 26 ++- lib/src/remote/host/host-surface-provider.ts | 4 + lib/src/remote/host/peer-surfaces.test.ts | 9 + lib/src/remote/host/peer-surfaces.ts | 17 +- vscode-ext/src/message-router.ts | 5 +- vscode-ext/src/peer-link.ts | 182 +++++++++++++------ vscode-ext/src/remote-host.ts | 95 +++++++--- vscode-ext/test/peer-link.test.ts | 124 +++++++------ vscode-ext/test/remote-host.test.ts | 82 ++++++++- 11 files changed, 397 insertions(+), 168 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 2e3d5a24..e3848637 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -72,7 +72,10 @@ protocol concept, so every environment-specific answer sits behind 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. +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 diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index d4a3442e..7fe2d6fd 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -313,7 +313,7 @@ The service owns the PTYs but not the *view* of them: a window's terminals are s `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, 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. +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. @@ -333,9 +333,9 @@ The one field the transport itself reads out of an answer is a reserved `ptyId` 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 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. 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. +**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` — `ptyId`, 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 has one owner, so the first answer is the answer. 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. +**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`.) @@ -347,15 +347,15 @@ The same problem one level out, and it cannot be solved the same way: VS Code ru 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 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, and a surface id is unique across every window. Within the remote tier, all peers are asked at once for the same reason. +**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, and the first surface answer is selected. 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 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`). +**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 records which window it came from, because a PTY id says nothing about where it lives and input and resizes have to reach that window. **Unless this window already has that id**: pane ids are unique within a window and nothing coordinates them across windows — "Duplicate Workspace in New Window" cold-restores identical ids into a second window — so a peer's answer can name one of the broker's own terminals. The route is skipped when `deps.ownsPty` says so (`ptyManager.hasPty` or a webview's claim), and local wins. Recording it would send the phone's keystrokes for the broker's own PTY over the socket and into the other window's shell. `writePty` / `resizePty` consult that table and fall back to this window's `ptyManager` — the link takes only a PTY it has a route for, so a local PTY can never be taken out from under the manager that owns it. When a peer disconnects, every PTY 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. +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. @@ -377,9 +377,9 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and arbitration; 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, PTY routing and streaming with two viewers, a colliding PTY id staying local rather than being routed away, 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.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, asking, and directory invalidation. +- **`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. diff --git a/lib/src/host/remote/ask-surface-provider.ts b/lib/src/host/remote/ask-surface-provider.ts index 837bbd80..43e9348a 100644 --- a/lib/src/host/remote/ask-surface-provider.ts +++ b/lib/src/host/remote/ask-surface-provider.ts @@ -21,9 +21,15 @@ 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. + * 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) => Promise; +export type SurfaceAsk = ( + op: string, + params: unknown, + ownerPtyId?: string, +) => Promise; export interface AskSurfaceProvider { provider: HostSurfaceProvider; @@ -83,12 +89,16 @@ export function createAskSurfaceProvider( // 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, - })) as PeerSurfaceResult[]; + const [settled] = (await ask( + 'surfaceOp', + { + surfaceId, + op: 'resize', + cols: nextCols, + rows: nextRows, + }, + owner.ptyId, + )) as PeerSurfaceResult[]; if (settled) { cols = settled.cols; rows = settled.rows; diff --git a/lib/src/remote/host/host-surface-provider.ts b/lib/src/remote/host/host-surface-provider.ts index 33a00651..da04bfab 100644 --- a/lib/src/remote/host/host-surface-provider.ts +++ b/lib/src/remote/host/host-surface-provider.ts @@ -25,6 +25,10 @@ import type { DirectoryEntry } from 'server-lib-common'; 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; diff --git a/lib/src/remote/host/peer-surfaces.test.ts b/lib/src/remote/host/peer-surfaces.test.ts index 7f1e1e62..3a88cf8d 100644 --- a/lib/src/remote/host/peer-surfaces.test.ts +++ b/lib/src/remote/host/peer-surfaces.test.ts @@ -90,6 +90,15 @@ describe('surface responder', () => { 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'); diff --git a/lib/src/remote/host/peer-surfaces.ts b/lib/src/remote/host/peer-surfaces.ts index 7da6b52e..7952f62a 100644 --- a/lib/src/remote/host/peer-surfaces.ts +++ b/lib/src/remote/host/peer-surfaces.ts @@ -33,7 +33,7 @@ import { armWhileEnrolled } from './enrolled-gate'; * 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 = 'attach' | 'resize'; +export type PeerSurfaceOp = 'resolve' | 'attach' | 'resize'; export interface PeerSurfaceParams { surfaceId: string; @@ -75,21 +75,28 @@ function answerPeers( } /** - * Drive one of this webview's own surfaces on the Host's behalf. + * Resolve or drive one of this webview's own surfaces on the Host's behalf. * - * `attach` and `resize` are the same operation — attach-is-the-resize + * `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, cols, rows }: PeerSurfaceParams): PeerSurfaceResult[] { +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 (term.cols !== nextCols || term.rows !== nextRows) { + if (op !== 'resolve' && (term.cols !== nextCols || term.rows !== nextRows)) { term.resize(nextCols, nextRows); } return [{ ptyId: entry.ptyId, cols: term.cols, rows: term.rows }]; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 45dbc30d..f53750cf 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -78,8 +78,9 @@ configurePeerLink({ streamPty: processedPtyStreams.streamPty, writePty: (ptyId, data) => ptyManager.write(ptyId, data), resizePty: (ptyId, cols, rows) => ptyManager.resize(ptyId, cols, rows), - // Two windows can hold the same pane id — "Duplicate Workspace in New Window" - // cold-restores them — so the link asks before it routes one away. + // 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. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 2cee80c1..13ac5b64 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -81,12 +81,10 @@ export interface PeerLinkDeps { /** * Whether this window's own PTY manager holds that id. * - * Pane ids are unique within a window and nothing coordinates them across - * windows — "Duplicate Workspace in New Window" cold-restores the *same* ids - * into a second window — so a peer answering an op can name an id this - * window already owns. Routing on that answer would post the broker's own - * keystrokes into the other window's shell, so the local owner wins - * ({@link remoteRequest}). + * 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. */ @@ -281,19 +279,26 @@ 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; - ptyId: string; + routeId: string; promise: Promise; settle(): void; } -/** Subscribe acknowledgement by frame id, plus its one in-flight id per PTY. */ +/** Subscribe acknowledgement by frame id, plus its one in-flight id per route. */ const pendingRemoteSubscriptions = new Map(); -const pendingRemoteSubscriptionByPty = new Map(); +const pendingRemoteSubscriptionByRoute = new Map(); /** * One outstanding {@link ask}, and the window it is outstanding against — so a @@ -343,8 +348,44 @@ function authenticatedClients(): PeerLinkClient[] { return [...clients].filter((client) => client.authenticated); } -function settleRemoteSubscription(ptyId: string): void { - const id = pendingRemoteSubscriptionByPty.get(ptyId); +/** + * 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(); } @@ -354,7 +395,11 @@ function settleRemoteSubscription(ptyId: string): void { * 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, ptyId: string): Promise { +function beginRemoteSubscription( + client: PeerLinkClient, + routeId: string, + ownerPtyId: string, +): Promise { const id = `s${++nextRequestId}`; let resolveReady!: () => void; const promise = new Promise((resolve) => { @@ -366,26 +411,27 @@ function beginRemoteSubscription(client: PeerLinkClient, ptyId: string): Promise // 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 }); - routes.delete(ptyId); - for (const sink of [...(remoteSinks.get(ptyId) ?? [])]) sink.onExit(0); - remoteSinks.delete(ptyId); + 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)?.ptyId !== ptyId) return; + if (pendingRemoteSubscriptions.get(id)?.routeId !== routeId) return; clearTimeout(timer); pendingRemoteSubscriptions.delete(id); - if (pendingRemoteSubscriptionByPty.get(ptyId) === id) { - pendingRemoteSubscriptionByPty.delete(ptyId); + if (pendingRemoteSubscriptionByRoute.get(routeId) === id) { + pendingRemoteSubscriptionByRoute.delete(routeId); } resolveReady(); }; - pendingRemoteSubscriptions.set(id, { client, ptyId, promise, settle }); - pendingRemoteSubscriptionByPty.set(ptyId, id); - send(client, { kind: 'subscribe', id, ptyId }); + pendingRemoteSubscriptions.set(id, { client, routeId, promise, settle }); + pendingRemoteSubscriptionByRoute.set(routeId, id); + send(client, { kind: 'subscribe', id, ptyId: ownerPtyId }); return promise; } @@ -395,8 +441,9 @@ function isFrameObject(frame: unknown): frame is Record { } /** - * Put one peer request to every other window and collect what they answer. - * Empty when nothing is connected, and when nobody owned what was asked about. + * 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 @@ -408,8 +455,17 @@ function isFrameObject(frame: unknown): frame is Record { * where that PTY lives, and every later write, resize, and subscribe depends on * knowing. */ -export async function remoteRequest(op: string, params: unknown): Promise { - const peers = authenticatedClients(); +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) => @@ -422,31 +478,38 @@ export async function remoteRequest(op: string, params: unknown): Promise), + ptyId: bindRemotePty(client, ptyId), + }); } } return results; } -/** Whether this PTY is streaming from another window. */ +/** 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); - if (!client) { + const ownerPtyId = routePtyIds.get(ptyId); + if (!client || !ownerPtyId) { sink.onExit(0); return Promise.resolve(); } @@ -459,10 +522,10 @@ export function remoteSubscribe(ptyId: string, sink: RemotePtySink): 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), @@ -126,36 +150,47 @@ async function askBothTiers( * terminal on the machine rather than the broker window's alone. */ export function createRemoteHostProvider(bound: RemoteHostDeps): HostSurfaceProvider { - askProvider = createAskSurfaceProvider((op, params) => askBothTiers(bound, op, params), { - // The link takes only a PTY it has a route for, and a route is placed only - // by an attach another window answered — so a PTY of this window's own can - // never be taken out from under the manager that owns it. - writePty: (ptyId, data) => { - if (!remoteWrite(ptyId, data)) bound.writePty(ptyId, data); - }, - resizePty: (ptyId, cols, rows) => { - if (!remoteResize(ptyId, cols, rows)) bound.resizePty(ptyId, cols, rows); - }, - - streamPty(ptyId, sink) { - if (isRemotePty(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); + 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: () => remoteUnsubscribe(ptyId, sink), - ready, + stop: bound.streamPty(ptyId, sink), + ready: Promise.resolve(), }; - } - // 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; } diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 6636ef73..e28b2589 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -57,8 +57,16 @@ async function openWindow(deps: ReturnType): Promise - broker.remoteRequest('surfaceOp', { surfaceId: 'far-1', op: 'attach', cols: 80, rows: 24 }); +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 = () => @@ -143,20 +151,24 @@ describe('bind-as-lease', () => { } }); - it('does not route a PTY id this window already owns to the peer that claimed it', async () => { + 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. - // Routing on that sends the phone's keystrokes into the other window's - // shell instead of the one it attached to. + // 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 { broker } = await linkedPair(brokerSide, farWindow()); - - expect(await attachFar(broker)).toEqual([{ ptyId: 'pty-far', cols: 80, rows: 24 }]); - // No route, so writes fall back to this window's own manager. - expect(broker.isRemotePty('pty-far')).toBe(false); - expect(broker.remoteWrite('pty-far', 'ls\r')).toBe(false); - await tick(100); + 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([]); }); @@ -400,12 +412,13 @@ describe('bind-as-lease', () => { }); const { broker } = await linkedPair(fakeWindow(), peerSide); - const results = await broker.remoteRequest('surfaceOp', { + const [result] = (await broker.remoteRequest('surfaceOp', { surfaceId: 'far-1', op: 'attach', cols: 100, rows: 30, - }); - expect(results).toEqual([{ ptyId: 'pty-far', 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('pty-far')).toBe(true); + expect(broker.isRemotePty(result!.ptyId)).toBe(true); }); it('reports a surface nobody owns', async () => { @@ -420,10 +433,10 @@ describe('bind-as-lease', () => { it('streams a subscribed PTY from the owning window', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); - await attachFar(broker); + const handle = await attachFar(broker); const sink = fakeSink(); - broker.remoteSubscribe('pty-far', sink); + broker.remoteSubscribe(handle.ptyId, sink); await tick(); peerSide.emitData('pty-far', 'output from the other window'); @@ -434,10 +447,10 @@ describe('bind-as-lease', () => { it('does not stream PTYs it never subscribed to', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); - await attachFar(broker); + const handle = await attachFar(broker); const sink = fakeSink(); - broker.remoteSubscribe('pty-far', sink); + broker.remoteSubscribe(handle.ptyId, sink); await tick(); peerSide.emitData('pty-other', 'not subscribed'); await tick(100); @@ -447,28 +460,28 @@ describe('bind-as-lease', () => { it('forwards a subscribed PTY exit and forgets its route', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); - await attachFar(broker); + const handle = await attachFar(broker); const sink = fakeSink(); - broker.remoteSubscribe('pty-far', sink); + 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('pty-far')).toBe(false); + 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); - await attachFar(broker); + const handle = await attachFar(broker); await peer.disposePeerLink(); - await waitFor(() => !broker.isRemotePty('pty-far')); + await waitFor(() => !broker.isRemotePty(handle.ptyId)); const sink = fakeSink(); - await broker.remoteSubscribe('pty-far', sink); + await broker.remoteSubscribe(handle.ptyId, sink); expect(sink.exits).toEqual([0]); }); @@ -479,11 +492,11 @@ describe('bind-as-lease', () => { // 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); - await attachFar(broker); + const firstHandle = await attachFar(broker); const first = fakeSink(); const firstOrder: string[] = []; const firstReady = broker - .remoteSubscribe('pty-far', { + .remoteSubscribe(firstHandle.ptyId, { ...first, onExit: (code) => { first.exits.push(code); @@ -498,25 +511,25 @@ describe('bind-as-lease', () => { // 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('pty-far')).toBe(false); + 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. - await attachFar(broker); + const secondHandle = await attachFar(broker); const second = fakeSink(); - await broker.remoteSubscribe('pty-far', second); + 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); - await attachFar(broker); + const handle = await attachFar(broker); const sink = fakeSink(); - broker.remoteSubscribe('pty-far', sink); + broker.remoteSubscribe(handle.ptyId, sink); await tick(); - broker.remoteUnsubscribe('pty-far', sink); + broker.remoteUnsubscribe(handle.ptyId, sink); await tick(); peerSide.emitData('pty-far', 'after unsubscribe'); await tick(100); @@ -525,11 +538,11 @@ describe('bind-as-lease', () => { // 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('pty-far')).toBe(true); + 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('pty-far', again); + broker.remoteSubscribe(handle.ptyId, again); await tick(); peerSide.emitData('pty-far', 'flowing again'); await waitFor(() => again.data.length > 0); @@ -542,23 +555,24 @@ describe('bind-as-lease', () => { // that teardown or every later write goes nowhere. const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); - await attachFar(broker); + const firstHandle = await attachFar(broker); const first = fakeSink(); - broker.remoteSubscribe('pty-far', first); + 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. - await attachFar(broker); + const secondHandle = await attachFar(broker); const second = fakeSink(); - broker.remoteUnsubscribe('pty-far', first); - broker.remoteSubscribe('pty-far', second); + broker.remoteUnsubscribe(firstHandle.ptyId, first); + broker.remoteSubscribe(secondHandle.ptyId, second); await tick(); - expect(broker.isRemotePty('pty-far')).toBe(true); - expect(broker.remoteWrite('pty-far', 'ls\r')).toBe(true); + 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']); @@ -567,31 +581,31 @@ describe('bind-as-lease', () => { it('keeps a second viewer streaming when the first detaches', async () => { const peerSide = farWindow(); const { broker } = await linkedPair(fakeWindow(), peerSide); - await attachFar(broker); + const handle = await attachFar(broker); const first = fakeSink(); const second = fakeSink(); - broker.remoteSubscribe('pty-far', first); - broker.remoteSubscribe('pty-far', second); + 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('pty-far', first); + 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('pty-far')).toBe(true); + 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); - await attachFar(broker); + const handle = await attachFar(broker); - expect(broker.remoteWrite('pty-far', 'ls\r')).toBe(true); - expect(broker.remoteResize('pty-far', 120, 40)).toBe(true); + 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' }]); @@ -608,9 +622,9 @@ describe('bind-as-lease', () => { it('reports terminals as exited when their window disconnects', async () => { const peerSide = farWindow(); const { broker, peer } = await linkedPair(fakeWindow(), peerSide); - await attachFar(broker); + const handle = await attachFar(broker); const sink = fakeSink(); - broker.remoteSubscribe('pty-far', sink); + broker.remoteSubscribe(handle.ptyId, sink); await tick(); // The window was closed: its terminals are gone, and a later write must not @@ -619,8 +633,8 @@ describe('bind-as-lease', () => { await waitFor(() => sink.exits.length > 0); expect(sink.exits).toEqual([0]); - expect(broker.isRemotePty('pty-far')).toBe(false); - expect(broker.remoteWrite('pty-far', 'x')).toBe(false); + 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 () => { diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index 8d805d59..c1ae101a 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -862,16 +862,20 @@ describe('serving the other windows', () => { // The attach is what teaches the link where that PTY lives; everything // after it is routed by that. - await waitFor(async () => !!(await provider.resolveSurface('far-1', { cols: 80, rows: 24 }))); + 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('pty-far', sink); + 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('pty-far', 'ls\r'); - provider.resizePty('pty-far', 120, 40); + 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 }]); @@ -883,6 +887,76 @@ describe('serving the other windows', () => { 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 From 2bd97f09889a52779bc72c347c8a30dd2df9c6d4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 16:41:20 -0700 Subject: [PATCH 54/56] Distinguish recycled peer socket inodes --- docs/specs/vscode.md | 2 +- vscode-ext/src/peer-link.ts | 32 +++++++++++++++++++++++++------ vscode-ext/test/peer-link.test.ts | 32 +++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 7fe2d6fd..68ac9e51 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -272,7 +272,7 @@ 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 an inode no client can reach; nothing on the bind path detects that, so `stillOurs` re-stats the path after `RECLAIM_VERIFY_MS` and compares inodes. A window whose inode 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 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. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 13ac5b64..36c67ca5 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -1164,7 +1164,7 @@ async function attempt(): Promise { // // 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 an inode nobody can reach. + // 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 @@ -1210,25 +1210,45 @@ const RECLAIM_VERIFY_MS = 250; const RECLAIM_JITTER_MS = 250; /** - * Whether the socket path still names the inode we just bound. + * 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 an inode no client can + * 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 stat(path).catch(() => null); + const mine = await socketFileIdentity(path); if (!mine) return unstattable; await delay(RECLAIM_VERIFY_MS); - const now = await stat(path).catch(() => null); + const now = await socketFileIdentity(path); if (!now) return unstattable; - return now.ino === mine.ino; + return sameSocketFile(now, mine); } async function contend(): Promise { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index e28b2589..6eea4b2e 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -41,6 +41,21 @@ 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(); @@ -221,20 +236,21 @@ describe('bind-as-lease', () => { await waitForFile(path); corpse.kill('SIGKILL'); await new Promise((resolve) => corpse.on('exit', resolve)); - const dead = (await stat(path)).ino; + 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 inode this window has bound it — and is - // still deciding whether it may keep it. + // 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 stat(path).catch(() => null); - if (!now || now.ino === dead) return false; + 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(); @@ -321,7 +337,7 @@ describe('bind-as-lease', () => { await waitForFile(path); corpse.kill('SIGKILL'); await new Promise((resolve) => corpse.on('exit', resolve)); - const dead = (await stat(path)).ino; + const dead = await socketFileIdentity(path); const mod = await openWindow(fakeWindow()); const settled = mod.ensurePeerNet(() => {}); @@ -329,8 +345,8 @@ describe('bind-as-lease', () => { // window has bound it, inside its own verification window. void (async () => { for (let i = 0; i < 2000; i++) { - const now = await stat(path).catch(() => null); - if (now && now.ino !== dead) { + const now = await socketFileIdentity(path).catch(() => null); + if (now && !sameSocketFile(now, dead)) { await rm(path, { force: true }); return; } From 927b41dce0025479f78d7137523fbbfadcbc2a0c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 17:39:37 -0700 Subject: [PATCH 55/56] fix marketing copy (accidental escaping) Co-authored-by: dormouse-bot --- vscode-ext/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 395bf8da..788179be 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1,7 +1,7 @@ { "name": "dormouse", - "displayName": "Dormouse \u2014 Terminal Multiplexer", - "description": "A persistent multitasking terminal \u2014 tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", + "displayName": "Dormouse — Terminal Multiplexer", + "description": "A persistent multitasking terminal — tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", "version": "1.1.0", "publisher": "diffplug", "license": "FSL-1.1-MIT", From 5ce9dce6924762799e5c527e928c3aa31853fb50 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 19 Aug 2026 19:21:24 -0700 Subject: [PATCH 56/56] Address the full review: forget failed keychain reads, dedup the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VS Code store now applies the same fail-closed rule as the file store: a rejected SecretStorage read is forgotten rather than memoized, because a locked or keyring-less keychain says nothing about what the store holds — and a memoized rejection left an enrolled window silently Host-less for its whole life, since onDidChange only fires on a write and nothing else ever retried. The directory deduplicates by surfaceId, keeping the first answer: duplicated cold-restored windows can hold panes with identical ids, and two identical rows made the phone's picker a lottery over which window an attach reached. First-from-the-concatenation is the same owner the attach path's read-only resolve probe selects, so the row shown is the surface attached. Co-Authored-By: Claude Fable 5 --- docs/specs/vscode.md | 2 +- .../host/remote/ask-surface-provider.test.ts | 40 +++++++++++++++++++ lib/src/host/remote/ask-surface-provider.ts | 15 ++++++- vscode-ext/src/remote-host-store.ts | 12 +++++- vscode-ext/test/remote-host.test.ts | 27 +++++++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 lib/src/host/remote/ask-surface-provider.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 68ac9e51..4668a538 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -347,7 +347,7 @@ The same problem one level out, and it cannot be solved the same way: VS Code ru 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, and the first surface answer is selected. Within the remote tier, all peers are asked at once for the same reason. +**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). 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 index 43e9348a..7a19da92 100644 --- a/lib/src/host/remote/ask-surface-provider.ts +++ b/lib/src/host/remote/ask-surface-provider.ts @@ -51,8 +51,19 @@ export function createAskSurfaceProvider( const provider: HostSurfaceProvider = { async collectDirectory(): Promise { // Each answerer replies with its whole snapshot, so the results *are* the - // entries — no per-webview merging to do on this side. - return (await ask('directory', {})) as DirectoryEntry[]; + // 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) { diff --git a/vscode-ext/src/remote-host-store.ts b/vscode-ext/src/remote-host-store.ts index dc473743..bba43b13 100644 --- a/vscode-ext/src/remote-host-store.ts +++ b/vscode-ext/src/remote-host-store.ts @@ -67,7 +67,17 @@ export class VsCodeHostStateStore implements HostStateStore { // 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. - this.#enrollment ??= this.#readEnrollment(); + // + // 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; } diff --git a/vscode-ext/test/remote-host.test.ts b/vscode-ext/test/remote-host.test.ts index c1ae101a..ff3b3fe4 100644 --- a/vscode-ext/test/remote-host.test.ts +++ b/vscode-ext/test/remote-host.test.ts @@ -322,6 +322,33 @@ describe('host state store', () => { ]); }); + 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