diff --git a/README.md b/README.md index d818a51..232332b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Three running components, all started from the one `wtd` binary: | Component | Runs | Owns | | --- | --- | --- | | Daemon (`wtd daemon`) | One per host, independent of any page or hub | The PTYs and their screen mirrors, git worktree watches, and `~/.local/state/worktree-term/state.json`: each worktree's terminal tabs, splits, active tab and checkbox | -| Hub (`wtd hub`, started by `wtd ui`) | One on the machine you browse from | `~/.config/worktree-term/config.json` (repos, roots, presets, hosts); nothing else | +| Hub (`wtd hub`, started by `wtd ui`) | One on the machine you browse from | `~/.config/worktree-term/config.json` (repos, roots, presets, hosts and their hub ports); nothing else | | Page | The browser | Rendering (xterm.js) and view preferences in `localStorage`: selected repo, selected worktree per repo, "checked only" filter, name search, sidebar width | - **The browser talks only to the hub**: the hub serves the page and one WebSocket per page, on `127.0.0.1` only. @@ -38,7 +38,7 @@ flowchart LR ### Two machines over SSH -The browser and hub run on your laptop; the remote host `devbox` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`, so a hub on `devbox` plays no part. +The browser and hub run on your laptop; for the laptop's page, the remote host `devbox` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`. Picking `devbox` in the Hubs menu shows `devbox`'s own hub instead, with its own `config.json`: the laptop's hub forwards a loopback port of its own for `devbox` to that hub, one `ssh -W` per connection. ```mermaid flowchart LR @@ -52,12 +52,13 @@ flowchart LR subgraph devbox["devbox"] connect["wtd connect
stdio bridge"] remoteDaemon["Daemon
devbox's terminals, git watches
state.json: tabs, splits, checkboxes"] - otherHub["Its own hub and config.json
(not used by the laptop)"] + remoteHub["Its own hub
config.json: devbox's repos"] connect -- "unix socket" --> remoteDaemon + remoteHub -- "unix socket" --> remoteDaemon end hub -- "ssh devbox wtd connect
one control master per host;
a Tailscale check's login link is shown on the page" --> connect - classDef unused stroke-dasharray: 4 4,color:#888; - class otherHub unused; + page -. "Hubs menu: 127.0.0.1:7418,
devbox's port on the laptop" .-> hub + hub -. "ssh -W 127.0.0.1: devbox
per connection, over the same master" .-> remoteHub ``` ## Installation @@ -81,6 +82,8 @@ The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.confi `repos` and `roots` belong to this machine: `repos` are the repo tabs (absolute or `~/…`), `roots` are where "Add repo" looks for repositories, 3 levels deep (default `["~"]`). `hosts` lists remote hosts, each shown after this machine's repos: `name` labels its tabs (`name:repo`), `ssh` is the SSH alias, `repos` must be absolute paths on that host, and its `roots` (`~` meaning the remote home) default to `["~"]`. "Add repo" and "Remove repo" on the page edit `repos` here, keeping everything else as written. +A host's `port` is the loopback port on this machine through which its own hub is shown (see [Remote hubs](#remote-hubs)). The hub writes it the first time the host's hub is opened: one above the highest of `port` and every host's `port`. Once present it is never changed by the hub, and it keeps the host's page on the same origin, so that page's preferences survive. You may set it by hand; it must differ from `port` and from every other host's. + ```json { "port": 7417, @@ -90,7 +93,7 @@ The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.confi { "name": "claude", "command": "claude" }, { "name": "shell", "command": null } ], - "hosts": [{ "name": "devbox", "ssh": "devbox", "repos": ["/home/me/src/api"], "roots": ["~/src"] }] + "hosts": [{ "name": "devbox", "ssh": "devbox", "port": 7418, "repos": ["/home/me/src/api"], "roots": ["~/src"] }] } ``` @@ -107,6 +110,22 @@ or with "Install" on the page while the host is down. The remote gets the same l Each browser session holds one `ssh "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. A host reached over Tailscale SSH whose check has expired shows "needs Tailscale login" with an "Open login link" button; approving the check there connects every open page. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's running daemon speaks an older daemon protocol than its installed `wtd` (whose protocol `wtd --version` prints; it does not ask the running daemon), the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program. +## Remote hubs + +The "Hubs" menu in the page header, shown while `hosts` lists a remote host, lists "This machine" and every remote host. Picking a host shows that host's own hub in the same tab: its own repos, worktrees and terminals, as if you were browsing on it, and "Add repo" and "Remove repo" there edit its `config.json`. Nothing needs configuring beyond the `hosts` entry. Picking "This machine" goes back. `wtd ui ` opens a window that does the same: + +```sh +wtd ui devbox +``` + +To open it, this machine's hub runs `wtd ui --print-code` on the host over its SSH control master. That makes a hub of the host's version serve there and prints one line holding its port and a one-time code, instead of opening a browser: + +```sh +wtd ui --print-code # {"port":7417,"code":"…"} +``` + +This machine's hub then listens on the host's `port` on `127.0.0.1` and relays each connection to the remote hub with `ssh -W`, so sshd on the host must allow it (`AllowTcpForwarding yes`, the default). The header shows "Opening …" meanwhile, an "Open login link" button while a Tailscale check waits, and the error otherwise. A host whose `wtd` is missing or too old offers "Reinstall on ", which installs this machine's `wtd` there without restarting its daemon. Closing the page ends its forwards; the remote terminals keep running. After this machine's hub restarts, pick the host again. + ## Keyboard | Keys | Action | diff --git a/architecture/hub.arc42.md b/architecture/hub.arc42.md index cf0cb1e..fd33fdd 100644 --- a/architecture/hub.arc42.md +++ b/architecture/hub.arc42.md @@ -2,7 +2,7 @@ ## Purpose & context -Local-only gateway. Serves the web bundle and one WebSocket per browser session; per session, holds one link per configured host (local socket or `ssh wtd connect`). Owns `config.json`. +Local-only gateway. Serves the web bundle and one WebSocket per browser session; per session, holds one link per configured host (local socket or `ssh wtd connect`). Owns `config.json`. Forwards a stable loopback port per remote host to that host's hub. ## Building blocks @@ -48,7 +48,7 @@ flowchart TD 1. Reaches every daemon, local or remote, only through a link; never imports daemon code. [enforced: arch_check:model-truth] 2. Only `server` faces the browser; only `links` opens daemon connections or spawns processes. [enforced: arch_check:model-truth] [enforced: test:test/architecture/dependencies.test.ts] -3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token and matching `Origin` and `Host`. [enforced: test:test/process/hub-server-http.test.ts] [enforced: test:test/process/hub-server-websocket.test.ts] +3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token or a valid one-time code, a `Host` of `127.0.0.1:`, and an `Origin` of that same host. [enforced: test:test/process/hub-server-http.test.ts] [enforced: test:test/process/hub-server-websocket.test.ts] 4. Addresses daemon entities as (host, id) and never rewrites daemon ids. [enforced: test:test/process/hub-router-routing.test.ts] 5. Relays terminal bytes without buffering beyond frames in flight; state other than config lives in daemons. [enforced: test:test/process/hub-router-routing.test.ts] diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/.openspec.yaml b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/.openspec.yaml new file mode 100644 index 0000000..5c881ba --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-18 diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/design.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/design.md new file mode 100644 index 0000000..554457d --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/design.md @@ -0,0 +1,60 @@ +## Context + +See proposal.md – Why. The issue proposed a separate Chrome `--app` window per remote hub and an `ssh -L` forward; the interview replaced both (decisions below). DEV-2106 is merged into this branch: its `tailscaleLoginUrl`, `LineScanner`, `StderrTail` and `needs-login` banner are reused. + +Applicable principles: +- **system** 1 (no new model arrows: `main → server/links/router/config` and `router → links/config` already exist), 3 (`node:net` newly allowed in `hub.server`), 5 (the print-code line, both fragments and `hubOpened.url` are schemas parsed where they enter), 6, 7 (every listener on `127.0.0.1`), 8 (unit, process, e2e, opt-in integration). +- **hub** 1, 2 (the tunnel listener faces the browser, so it lives in `server`; every SSH spawn lives in `links`), 3 (edited, approved wording below), 4, 5 (the relay holds only stream buffers). +- **protocol** 2–4 (browser version 9 → 10; frozen shapes for the cross-version print-code line and fragments). +- **web** 1, 2 (fragments decoded only in `client`), 7 (a page talks only to the hub that served it). + +Approved arc42 edits: +- hub.arc42.md principle 3: `3. Binds \`127.0.0.1\` only; rejects any WebSocket upgrade without the token, a \`Host\` of \`127.0.0.1:\`, and an \`Origin\` of that same host. [enforced: test:test/process/hub-server.test.ts]` +- hub.arc42.md Purpose & context, appended: `Forwards a stable loopback port per remote host to that host's hub.` + +## Goals / Non-Goals + +**Goals:** +- See any configured remote host's own hub (repos, worktrees, terminals) in the current tab, with nothing configured beyond the `hosts` entry. +- Get back to the laptop's hub with one pick. +- Each host keeps a stable origin, so its `localStorage` preferences survive. +- Opening never kills remote terminals. + +**Non-Goals:** +- Showing several hubs in one page (use tabs). +- Opening hubs from a remote page directly; it goes through home. +- Reconnecting a remote page after the local hub restarts (reopen from the menu). +- Multi-hop: a remote hub's own `hosts`. + +## Decisions + +- **D1 — Same tab, cross-origin navigation (user's change).** The page cannot embed another hub, because of CSP `connect-src 'self'` and the Origin rule, and proxying was rejected in the issue. So the tab navigates to `http://127.0.0.1:/`. Session storage is per (tab, origin), so the home token survives a round trip in the same tab. +- **D2 — Every opening goes through home.** Only the home page sends `openHub`. A remote page's menu links to `home/` or `home/#hub=`. `wtd ui ` opens `home/#code=…&hub=`. One code path; errors and the login link always render where DEV-2106's UI lives; the home token is always present. +- **D3 — Hub-owned listener plus `ssh -W` per connection** rather than `ssh -L`. The hub sees every connection, so the tunnel's lifetime equals the page's connections, and no `-L`-through-mux semantics are relied on. OpenSSH multiplexes stdio forwarding (`-W`) over the control master. +- **D4 — `port` in `config.json`** (user's choice: one file shared by people and the machine). Assignment is max(hub port, host ports) + 1, wrapping to the lowest free port from 1024, and is done inside the `ConfigEditor` queue, recomputed on every retry. A present `port` is never touched. max+1 avoids reusing a removed host's port, which would otherwise hand a surviving tab's stored token for one remote hub to another. The residual case (removing the highest-port host, then adding a host while an old tab lives) is accepted. +- **D5 — Remote Host/Origin rule: any loopback port, same-origin.** `Host` must be `127.0.0.1:`; `Origin` must equal `http://` + Host. DNS rebinding still fails (IP literal), and other loopback origins still fail (Origin ≠ Host). Rejected: binding a code to a forwarded port (stateful, breaks reloads after the remote hub restarts). +- **D6 — Header status line** instead of state inside the dropdown: approval happens in another tab and must stay visible. +- **D7 — `installHub` installs only.** `reinstallDaemon` would replace a daemon of another version and kill terminals. `HostCoordinator` becomes a FIFO queue of runs with explicit sharing (Codex finding 5): install shares a running or queued install or reinstall; restart shares a running or queued restart or reinstall; reinstall shares a reinstall; everything else waits its turn. +- **D8 — Frozen cross-version shapes.** The print-code line and both fragments pass between `wtd` installations of any versions, so they go in `frozen.golden.json` rather than under a link version. +- **D9 — Browser-only hub error codes** (Codex finding 4). `errorCode` is shared with the daemon link, so `hub-outdated` and `hub-failed` go in a `hubErrorCode` used only by the hub-to-browser `error`; the daemon schema is unchanged. +- **D10 — Element placement (no new arrows).** + - `links`: `HubOpener` runs print-code over SSH, with a live stderr scan, a 30 s budget suspended by a login URL, a 4 KiB stdout cap and kill on cancel. `forward(alias, remotePort, socket)` spawns `ssh -W` and pipes both ways. + - `server`: `TunnelListeners`, one per port. Its mutations are serialised; it binds, repoints, retires and closes. + - `router`: `openHub` / `installHub`. It declares a `HubTunnels` interface (`ensure(host, port, remotePort)`, `probe(port)`) which `main` implements from `server` and `links`. + - `main`: owns the latest-config view the listeners use to authorise each accepted connection. +- **D11 — Fresh config per open** (Codex finding 3). `openHub` and `installHub` resolve the host by its snapshot name against a fresh read of `config.json`; the latest valid read also refreshes the listeners' authorisation map, so a connection to a removed or remapped host is closed without SSH. +- **D12 — One opening per session** (Codex finding 6). A new `openHub` cancels the previous one: its SSH process is killed, and a cancellation token is checked after every await, so an ended opening never binds or replies. This also keeps the 16-code `CodeStore` on the remote safe from piling up. +- **D13 — Probe before navigating.** `GET /` through the new listener must return 200 within 10 s. This catches a refused forward (`AllowTcpForwarding no`, Tailscale ACLs) on the home page, where its stderr line can be shown. +- **D14 — Outdated detection by exit status.** 2 (usage error: no `--print-code`) or 127 (no `wtd`), or exit 0 with output that does not decode, means outdated. ssh's own failures exit 255 and are reported as `hub-failed` with the last stderr line. + +## Risks / Trade-offs + +- [`ssh -W` per connection costs one mux client per HTTP or WS connection] → Chrome keeps a handful per page; each is cheap over the master. +- [The control master is gone and Tailscale's check blocks a later `-W`] → that connection stalls until approval; the page's normal reconnect state shows. Reopening from home surfaces the login link. +- [Fake SSH can't prove real OpenSSH mux behaviour] → the fake learns `-W` for the default tiers, and an opt-in `test/integration` real-SSH test covers an existing and an absent master, a refused forward, loss of the master and child cleanup (Codex finding 7). +- [A hand-edited `port` on a live listener] → the next open rebinds on the new port and retires the old listener and its forwards. +- [A remote hub of another version is replaced by `--print-code`, closing other viewers' sessions there] → the same as `wtd ui` today; accepted. + +## Migration Plan + +Browser protocol 9 → 10 only: an open page reloads once under the stale-bundle rule. The daemon is untouched. `config.json` gains `port` entries lazily. Older remote `wtd` installs are detected and offered "Reinstall on ". diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/proposal.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/proposal.md new file mode 100644 index 0000000..0e1bf1c --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/proposal.md @@ -0,0 +1,35 @@ +## Why + +From the laptop, the page shows remote hosts' repos only as they are listed in the laptop's own `config.json`; there is no way to see a remote host as if sitting at it — its own hub, repos, worktrees and terminals — without copying its repo lists (DEV-2107). A manual `ssh -L` fails the hub's Host check and has no way to deliver a one-time code. + +## What Changes + +- New remote command `wtd ui --print-code`: makes a hub of this version serve and prints `{"port","code"}` instead of opening a browser. Its output shape is frozen. +- `wtd ui ` opens the home page in a new window and switches it to that host's hub. +- A "Hubs" menu in the page header lists "This machine" and every configured remote host; picking one navigates the **current tab** to that hub (agreed change from the issue's separate window). A remote page's menu routes back through the home page. +- The local hub opens a remote hub on request (`openHub`): runs `wtd ui --print-code` over the host's SSH control master, surfaces a Tailscale check login link while waiting, binds a stable loopback port for the host and forwards each connection to the remote hub with `ssh -W`, probes the forward, and returns the URL to navigate to. +- Each `hosts` entry gains an optional `port`, assigned once by the hub and written into `config.json`; it keeps the host's origin, and so its `localStorage` preferences, stable. +- A header status line shows opening progress, the login link, and errors; "Reinstall on " installs a newer `wtd` without restarting the daemon (`installHub`), then reopens. +- The hub accepts `Host` `127.0.0.1:` with a same-origin `Origin`, so a forwarded port reaches it; token and code requirements are unchanged. +- **BREAKING** (browser link only): `BROWSER_PROTOCOL_VERSION` 9 → 10 for `openHub`, `installHub`, `hubLogin`, `hubOpened` and the hub error codes `hub-outdated` and `hub-failed`; the daemon link is unchanged. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `cli`: `wtd ui [] [--print-code]`; usage text; version output names browser protocol 10. +- `hub`: Host/Origin rule; per-host `port` in config and its assignment; opening a remote hub; tunnel listener and forwards; install-only operation. +- `protocol`: browser protocol 10 messages and hub error codes; frozen `--print-code` output and page fragment shapes. +- `web`: Hubs menu, status line, fragment handling, remote page title. + +## Impact + +- Code: `src/protocol` (messages, values, frozen and wire goldens), `src/cli/run.ts`, `src/hub/main` (`openUi`, print-code, wiring), `src/hub/server` (Host/Origin rule, tunnel listener), `src/hub/links` (print-code runner, `ssh -W` forward), `src/hub/router` (openHub, installHub, coordinator queue), `src/hub/config` (`port` schema, assignment edit), `src/web/client` (fragment), `src/web/ui` (menu, status line, title). +- Architecture: hub.arc42 principle 3 and Purpose (approved wording); `node:net` allowed in `hub.server`; no new model arrows. +- Tests: unit, process (fake SSH learns `-W`), e2e, opt-in real-SSH integration test. +- Docs: README. +- Depends on DEV-2106 (merged into this branch). diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/cli/spec.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/cli/spec.md new file mode 100644 index 0000000..eabe7d0 --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/cli/spec.md @@ -0,0 +1,89 @@ +## MODIFIED Requirements + +### Requirement: Version output +`wtd --version` SHALL print `wtd (daemon protocol , browser protocol )` and a newline to stdout and exit 0. + +#### Scenario: Version +- **WHEN** the user runs `wtd --version` +- **THEN** stdout is `wtd (daemon protocol 6, browser protocol 10)` followed by a newline, stderr is empty, and the exit code is 0 + +### Requirement: Help output +`wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui [] [--print-code]`, `hub`, `daemon`, `connect`, `install-local [--systemd]` and `install-remote [--node ]`, and the options `--help` and `--version`. + +#### Scenario: Help +- **WHEN** the user runs `wtd --help` +- **THEN** stdout lists every command and option above, stderr is empty, and the exit code is 0 + +### Requirement: Usage errors +Running `wtd` with no arguments, an unknown command, an option the command does not take, `--node` without a value, `install-remote` without an alias, a second argument to `ui`, or `ui` with both a host and `--print-code` SHALL print a one-line error and the usage to stderr, print nothing to stdout, and exit 2. + +#### Scenario: No arguments +- **WHEN** the user runs `wtd` +- **THEN** stderr contains the usage, stdout is empty, and the exit code is 2 + +#### Scenario: Unknown command +- **WHEN** the user runs `wtd frobnicate` +- **THEN** stderr names `frobnicate` as unknown and contains the usage, and the exit code is 2 + +#### Scenario: Unknown option +- **WHEN** the user runs `wtd --frob` +- **THEN** stderr names `--frob` as unknown and contains the usage, and the exit code is 2 + +#### Scenario: Option of another command +- **WHEN** the user runs `wtd install-remote box --systemd` +- **THEN** stderr names `--systemd` as unknown for `install-remote` and contains the usage, and the exit code is 2 + +#### Scenario: Missing alias +- **WHEN** the user runs `wtd install-remote` +- **THEN** stderr says an alias is required and contains the usage, and the exit code is 2 + +#### Scenario: Missing option value +- **WHEN** the user runs `wtd install-remote box --node` +- **THEN** stderr says `--node` needs a value and contains the usage, and the exit code is 2 + +#### Scenario: Host and print-code together +- **WHEN** the user runs `wtd ui box --print-code` +- **THEN** stderr says a host and `--print-code` cannot be combined and contains the usage, stdout is empty, and the exit code is 2 + +#### Scenario: Two hosts +- **WHEN** the user runs `wtd ui a b` +- **THEN** stderr names `b` as unexpected and contains the usage, and the exit code is 2 + +### Requirement: UI command +`wtd ui [] [--print-code]` SHALL make a hub of its own version serve. If a running hub reports the same version, it SHALL be reused; if it reports another version, it SHALL be asked to shut down and a new hub started once its port is free; otherwise a hub SHALL be started detached with its output appended to `hub.log`, and `wtd ui` SHALL wait up to 5 s for it to serve. It SHALL then obtain a one-time code from the hub. Without options it SHALL start the browser command — `$WTD_BROWSER` if set, else `google-chrome` — with the argument `--app=http://127.0.0.1:/#code=`, detached with its stdio ignored, and exit 0. With `` it SHALL first fail, before touching any hub, with one line `wtd ui: no host named in ` on stderr and exit 1 unless the configuration has a remote host of that name, and SHALL otherwise start the browser command the same way with `--app=http://127.0.0.1:/#code=&hub=`. With `--print-code` it SHALL start no browser, print exactly one print-code line holding the hub's port and the code to stdout, and exit 0. The browser's arguments and the printed line SHALL never contain the token. A hub that cannot be started or reached, or a browser command that cannot be started, SHALL print one line naming the cause to stderr and exit 1. A hub record whose hub does not answer an authenticated identity request SHALL be treated as stale, and its process SHALL NOT be signalled. + +#### Scenario: Hub started and browser opened +- **WHEN** no hub is running and the user runs `wtd ui` +- **THEN** a hub serves on the configured port, the browser command receives a URL with a one-time code and without the token, and the exit code is 0 + +#### Scenario: Running hub reused +- **WHEN** a hub of the same version is running and the user runs `wtd ui` +- **THEN** the same hub process keeps serving and the browser command is started + +#### Scenario: Hub of another version replaced +- **WHEN** a hub reporting another version is running and the user runs `wtd ui` +- **THEN** that hub exits and a hub of the current version serves on the port + +#### Scenario: Stale record never signals a process +- **WHEN** the hub record names a live process that is not a hub and the user runs `wtd ui` +- **THEN** that process is not signalled and a new hub is started + +#### Scenario: Browser missing +- **WHEN** the browser command does not exist +- **THEN** stderr names the browser command and the exit code is 1 + +#### Scenario: Opening a host's hub +- **WHEN** the configuration lists remote host `box` and the user runs `wtd ui box` +- **THEN** the browser command receives `--app=http://127.0.0.1:/#code=&hub=box` and the exit code is 0 + +#### Scenario: Unknown host +- **WHEN** the configuration lists no host named `nope` and the user runs `wtd ui nope` +- **THEN** stderr is `wtd ui: no host named nope in `, no browser is started, and the exit code is 1 + +#### Scenario: Printing a code +- **WHEN** no hub is running and the user runs `wtd ui --print-code` +- **THEN** a hub serves, stdout is one print-code line with the hub's port and a code that the hub accepts once, no browser command is started, and the exit code is 0 + +#### Scenario: Printing a code replaces another version +- **WHEN** a hub reporting another version is running and the user runs `wtd ui --print-code` +- **THEN** that hub exits, a hub of the current version serves, and stdout is its print-code line diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/hub/spec.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/hub/spec.md new file mode 100644 index 0000000..402fb5a --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/hub/spec.md @@ -0,0 +1,249 @@ +## MODIFIED Requirements + +### Requirement: Request checks and headers +Every HTTP request and WebSocket upgrade SHALL carry exactly one `Host` header of the form `127.0.0.1:`, `` a decimal integer from 1 to 65535 without leading zeros; any other request SHALL be refused with status 403. Any port is accepted so that a loopback port forwarded from another host reaches the hub. Every response SHALL carry `Content-Security-Policy: default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. + +#### Scenario: Foreign Host refused +- **WHEN** a request carries `Host: localhost:`, `Host: evil.example`, `Host: 127.0.0.1` or `Host: 127.0.0.1:07417` +- **THEN** the response status is 403 + +#### Scenario: Duplicate Host refused +- **WHEN** a request carries two `Host` headers +- **THEN** the response status is 403 + +#### Scenario: Forwarded port accepted +- **WHEN** a hub listening on port 7417 receives `GET /` with `Host: 127.0.0.1:7420` +- **THEN** it serves `index.html` with status 200 + +#### Scenario: Security headers present +- **WHEN** `index.html` is requested +- **THEN** the response carries the content security policy, `nosniff` and `no-referrer` headers above + +### Requirement: Hub identity and shutdown +`GET /api/identity` with the token SHALL return the hub's package version and `instance`. `POST /api/shutdown` with the token SHALL answer and then shut the hub down as `SIGTERM` does. Both, and `POST /api/code`, SHALL refuse a request without the valid token with status 401, and a request carrying an `Origin` other than `http://` followed by the request's own `Host` value with status 403. + +#### Scenario: Identity reported +- **WHEN** identity is requested with the token +- **THEN** the response names the hub's version and the instance in its hub record + +#### Scenario: Shutdown requires the token +- **WHEN** shutdown is requested without the token +- **THEN** the response status is 401 and the hub keeps running + +#### Scenario: Cross-origin API request refused +- **WHEN** identity is requested with the token, `Host: 127.0.0.1:7420` and `Origin: http://127.0.0.1:7417` +- **THEN** the response status is 403 + +### Requirement: WebSocket authentication +A WebSocket upgrade SHALL be accepted only at `/ws`, with exactly one `Origin` header equal to `http://` followed by the request's own `Host` value, and a `Sec-WebSocket-Protocol` list that, parsed as exact comma-separated tokens, holds `wtd` and exactly one credential: `wtd.token.` with the hub token, or `wtd.code.` with a valid unused code, which the accepted upgrade consumes. The hub SHALL select the subprotocol `wtd` and SHALL never record the offered subprotocols. A refused upgrade SHALL get status 401 for a missing or invalid credential and 403 otherwise. The largest accepted browser message SHALL be `MAX_FRAME` bytes. + +#### Scenario: Missing or wrong Origin refused +- **WHEN** an upgrade with the token carries no `Origin`, `Origin: http://evil.example`, or `Host: 127.0.0.1:7420` with `Origin: http://127.0.0.1:8080` +- **THEN** it is refused with status 403 + +#### Scenario: Forwarded origin accepted +- **WHEN** a hub listening on port 7417 receives an upgrade with the token, `Host: 127.0.0.1:7420` and `Origin: http://127.0.0.1:7420` +- **THEN** it is accepted and the session greets + +#### Scenario: Wrong credential refused +- **WHEN** an upgrade carries a wrong token, a credential with a different prefix or suffix, two credentials, or none +- **THEN** it is refused and no session starts + +#### Scenario: Code used once +- **WHEN** two upgrades present the same valid code +- **THEN** exactly one is accepted + +#### Scenario: Expired code refused +- **WHEN** an upgrade presents a code issued more than 30 s earlier +- **THEN** it is refused with status 401 + +#### Scenario: Oversized browser message closes the session +- **WHEN** a browser sends a message of `MAX_FRAME` + 1 bytes +- **THEN** the session is closed + +### Requirement: Configuration +The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, port, repos, roots}`, `port`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `port` SHALL be an integer from 1024 to 65535, unique among hosts and different from the hub's `port` (its default included); its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}, {name: "claude", command: "claude"}, {name: "codex", command: "codex"}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo or preset edit from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. + +#### Scenario: Defaults without a file +- **WHEN** no `config.json` exists and the hub starts +- **THEN** it listens on port 7417, lists no repos and no remote hosts, and sessions receive the presets `shell` (command null), `claude` (command `claude`) and `codex` (command `codex`), in that order + +#### Scenario: Unknown key refused +- **WHEN** `config.json` holds the key `theme` and the hub starts +- **THEN** the start fails with a line naming the file and `theme` + +#### Scenario: Configured presets replace the default +- **WHEN** `config.json` lists presets `claude` (command `claude`) and `shell` (command null), in that order +- **THEN** sessions receive exactly those two presets in that order + +#### Scenario: Invalid presets refused +- **WHEN** `config.json` holds an empty `presets` list, two presets named `a`, or a preset whose command is "" +- **THEN** the start fails with a line naming the file and `presets` + +#### Scenario: Remote hosts listed in order +- **WHEN** `config.json` lists hosts `b` and `a`, in that order +- **THEN** sessions receive `hosts` with the local host at index 0, `b` at index 1 and `a` at index 2, both remote + +#### Scenario: Option-like alias refused +- **WHEN** a host's `ssh` is `-oProxyCommand=x` or contains a space +- **THEN** the start fails with a line naming the file and `ssh` + +#### Scenario: Home-relative remote repo refused +- **WHEN** a host's `repos` holds `~/app` +- **THEN** the start fails with a line naming the file and `repos` + +#### Scenario: Duplicate host names refused +- **WHEN** two hosts are named `box` +- **THEN** the start fails with a line naming the file and `hosts` + +#### Scenario: Clashing host ports refused +- **WHEN** two hosts have `port` 7420, or a host has `port` 7417 while the top-level `port` is absent +- **THEN** the start fails with a line naming the file and `port` + +#### Scenario: Edit picked up by a new session +- **WHEN** a repo or a preset is added to `config.json` by hand while a session is open, and a new session starts +- **THEN** the new session's `hosts` lists the repo, its `presets` lists the preset, and the open session's do not change + +#### Scenario: Invalid edit keeps the last valid configuration +- **WHEN** `config.json` becomes invalid and a new session starts +- **THEN** that session receives an `internal` error and the repos and presets of the last valid configuration + +### Requirement: Configuration edits +A configuration edit — a repo edit, a preset edit or a host port assignment — SHALL read the current `config.json` (an absent file read as `{}`), change only the edited value — for a repo edit the `repos` of the edited host, the top-level `repos` for the local host, otherwise those of the host entry with the session's host name; for a preset edit the top-level `presets`, starting from the default presets when the file has none; for a port assignment the `port` of the named host entry — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host of a repo edit, the result would hold more than 256 repos for the host, or a preset edit is refused. If the file changed between the read and the replacement, the edit SHALL start over from the changed file, recomputing its result, at most 3 times in all, and then fail with `error{code: internal}`. Edits of every kind SHALL be applied one at a time. After a repo edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; after a preset edit that changed the file, every open session SHALL receive `presets` with the edited presets; nothing else in any session's snapshot SHALL change. + +#### Scenario: Other entries kept as written +- **WHEN** `config.json` lists `~/a` for the local host and a repo is added for it +- **THEN** the file still lists `~/a` unexpanded, followed by the added path, and every other key is unchanged + +#### Scenario: Invalid file not overwritten +- **WHEN** `config.json` is invalid and the browser adds a repo +- **THEN** it receives `error` with code `internal` naming the problem and the file is unchanged + +#### Scenario: Concurrent hand edit kept +- **WHEN** `config.json` is changed by another writer after an edit read it and before the edit replaced it +- **THEN** the edit starts over from the changed file, and the result holds both the other writer's change and the added repo + +#### Scenario: Host gone from the file +- **WHEN** a session's host `box` was removed from `config.json` and that session adds a repo for `box` +- **THEN** it receives `error` with code `internal` and the file is unchanged + +#### Scenario: Repo and preset edits do not lose each other +- **WHEN** a repo is added and a preset removed at the same time +- **THEN** the file holds both changes + +#### Scenario: Port assignment recomputed after a concurrent edit +- **WHEN** a port assignment for host `a` read a file where the highest held port is 7420, and before it replaced the file another writer gave host `b` the `port` 7421 +- **THEN** the assignment starts over and gives `a` the `port` 7422, and `b` keeps 7421 + +### Requirement: Session end and hub shutdown +When a session's WebSocket closes, the hub SHALL close that session's links and end any hub opening that session started, killing its SSH process; terminals SHALL keep running. On `SIGTERM`, `SIGINT` or an authenticated shutdown request the hub SHALL close every session with WebSocket code 1001, close all links, close every hub tunnel listener and end every forward, stop listening, remove its hub record and exit 0. + +#### Scenario: Closing the browser keeps terminals +- **WHEN** a browser with an attached terminal closes its session and a new session watches the repo +- **THEN** the terminal is listed and still running + +#### Scenario: Shutdown closes sessions +- **WHEN** the hub receives `SIGTERM` with a session open +- **THEN** the session is closed with code 1001 and the hub exits 0 + +#### Scenario: Shutdown closes tunnels +- **WHEN** the hub receives `SIGTERM` while a hub tunnel listener is bound and one forward is open +- **THEN** the listener's port accepts no more connections, the forward's SSH process exits, and the hub exits 0 + +## ADDED Requirements + +### Requirement: Host port assignment +Opening a remote host's hub SHALL use that host's `port` from the current `config.json`. A host without `port` SHALL first be assigned one by a configuration edit: one more than the highest of the hub's `port` and every host's `port`; when that exceeds 65535, the lowest port from 1024 upward that is neither the hub's `port` nor any host's `port`; when no such port exists, the opening SHALL fail with `hub-failed` saying there is no free port for the host. A host's `port` that is present SHALL never be changed by the hub. + +#### Scenario: First port above the hub's +- **WHEN** the hub's `port` is 7417, no host has a `port`, and host `box` is opened +- **THEN** `config.json` gives `box` the `port` 7418, keeping every other value as written + +#### Scenario: Next port above the highest held +- **WHEN** hosts hold ports 7418 and 7425 and host `new` without a port is opened +- **THEN** `new` is given 7426 + +#### Scenario: Removed host's port not reused while a higher one is held +- **WHEN** hosts `a` (7418) and `b` (7419) exist, `a` is removed from `config.json`, and host `c` is opened +- **THEN** `c` is given 7420 + +#### Scenario: Wrap-around search +- **WHEN** the hub's `port` is 65535, host `a` holds 1024, and host `b` without a port is opened +- **THEN** `b` is given 1025 + +#### Scenario: Hand-written port kept +- **WHEN** host `box` has `port` 9000 in `config.json` and is opened +- **THEN** the hub forwards port 9000 for it and the file is unchanged + +### Requirement: Opening a remote hub +`openHub{req, host}` SHALL open the hub of the remote host at index `host` of the session's `hosts`, its name, `ssh` alias and `port` read afresh from the current valid `config.json` by the host's name; a local host index, an index outside the snapshot, or a host no longer configured SHALL reply `error{code: unknown-host}`. The hub SHALL run `"$HOME/.local/bin/wtd" ui --print-code` on the host over SSH with the same options as a remote link, reading its standard error as it is written: a Tailscale check login URL, recognised as for a remote link, SHALL be sent once per URL as `hubLogin{req, login}`. Without such a URL the run SHALL be ended after 30 s, replying `error{code: hub-failed}` saying the host did not answer within 30 s; after one, no time limit SHALL apply until SSH ends. Standard output beyond 4096 bytes SHALL end the run with `hub-failed`. An exit status of 127 or 2, or status 0 with output that is not exactly one print-code line, SHALL reply `error{code: hub-outdated}` saying `wtd` on the host is missing or too old to serve its hub; any other failure SHALL reply `error{code: hub-failed}` with SSH's last standard error line or, without one, the exit status. On success the hub SHALL make sure a tunnel listener for the host is bound on `127.0.0.1:` forwarding to the printed remote port, failing with `hub-failed` saying the port is in use when another process holds it; then fetch `GET /` through that listener, failing with `hub-failed` and the forward's last standard error line unless it answers 200 within 10 s; and then reply `hubOpened{req, url}` with `url` = `http://127.0.0.1:/#` followed by the remote page fragment carrying the printed code, the host's name, the hub's own port, and the names of every configured remote host in configuration order. Every error reply SHALL carry the host's index. A session SHALL have at most one opening in progress: a new `openHub` SHALL end the previous one, killing its SSH process, and an ended opening SHALL change no listener and send nothing further. + +#### Scenario: Remote hub opened +- **WHEN** host 1 is `box` with no `port`, the hub listens on 7417, `wtd ui --print-code` on `box` prints port 7417 and a code, and the browser sends `openHub{req: 4, host: 1}` +- **THEN** `config.json` gives `box` the `port` 7418, the browser receives `hubOpened{req: 4}` whose `url` starts `http://127.0.0.1:7418/#code=` and names `box` and home port 7417, and `GET /` and a WebSocket upgrade with that code through `127.0.0.1:7418` reach `box`'s hub + +#### Scenario: Login link while opening +- **WHEN** the SSH process for `openHub` writes `# To authenticate, visit: https://login.tailscale.com/a/abc123` to standard error, and is released 40 s later +- **THEN** the browser receives `hubLogin` with that URL, and then `hubOpened`, with no `hub-failed` in between + +#### Scenario: Too old remote +- **WHEN** `wtd ui --print-code` on the host exits with status 2, or the host has no `wtd` and the shell exits with status 127 +- **THEN** the browser receives `error` with code `hub-outdated` and the host's index, and no listener is bound + +#### Scenario: Unreachable host +- **WHEN** SSH writes `ssh: Could not resolve hostname box: Name or service not known` and exits with status 255 +- **THEN** the browser receives `error` with code `hub-failed` whose message is that line + +#### Scenario: Silent host times out +- **WHEN** the SSH process prints nothing for 30 s +- **THEN** it is killed and the browser receives `error` with code `hub-failed` + +#### Scenario: Oversized output refused +- **WHEN** `wtd ui --print-code` writes 5000 bytes to standard output +- **THEN** the SSH process is killed and the browser receives `error` with code `hub-failed` + +#### Scenario: Port held by another process +- **WHEN** host `box` has `port` 7418 and another process listens on 127.0.0.1:7418 +- **THEN** the browser receives `error` with code `hub-failed` naming port 7418 + +#### Scenario: Forward refused +- **WHEN** the SSH forward for the probe writes `channel 0: open failed: administratively prohibited` and exits +- **THEN** the browser receives `error` with code `hub-failed` whose message is that line + +#### Scenario: Newer open supersedes the previous one +- **WHEN** a session sends `openHub` for host 1, whose SSH process waits, and then `openHub` for host 2 +- **THEN** host 1's SSH process is killed, no listener for host 1 is bound, and the session receives replies only for host 2's request + +#### Scenario: Local host refused +- **WHEN** the browser sends `openHub` for host 0 +- **THEN** it receives `error` with code `unknown-host` + +### Requirement: Hub tunnels +A tunnel listener SHALL listen on `127.0.0.1` only. Each connection it accepts SHALL be relayed byte for byte, both ways, to `127.0.0.1:` on its host through one SSH process (`ssh -W`) started for that connection with the same options as a remote link; when either side ends, both SHALL end, the SSH process being terminated as a remote link's is. The hub SHALL buffer no more than the relay's stream buffers. At most one listener SHALL exist per port; binding, rebinding and closing listeners SHALL happen one at a time. Opening a host whose listener forwards to another remote port SHALL repoint it; a host whose `port` changed SHALL get a new listener on its new port and its old listener SHALL be closed with its forwards. A connection accepted while the listener's host, alias or port no longer matches the configuration the hub last read SHALL be closed at once without starting SSH. Listeners SHALL stay bound until the hub stops or their mapping changes. + +#### Scenario: Leaving the page ends its forwards +- **WHEN** a page served through a tunnel listener is closed +- **THEN** every SSH process started for its connections exits + +#### Scenario: Two sessions share one listener +- **WHEN** two sessions each open host `box` +- **THEN** each runs its own `wtd ui --print-code` and both URLs name the same port, served by one listener + +#### Scenario: Removed host's listener refuses connections +- **WHEN** host `box` was opened, is then removed from `config.json`, and a session starts +- **THEN** a connection to `box`'s port is closed without starting an SSH process + +### Requirement: Installing for a hub +`installHub{req, host}` for a remote host SHALL install the hub's own bundle on that host as `wtd install-remote` does, within 120 s, and reply `done`, never restarting or shutting down a daemon; a failure SHALL reply `error{code: internal}` naming the cause; a local host index SHALL reply `error{code: unknown-host}`. Installs SHALL be coordinated with the host's restarts and reinstalls, from any sessions, so that no two of them run at once: an install SHALL share a running or queued install or reinstall of that host; a restart or reinstall requested while an install runs or is queued SHALL wait until it ends; an install requested while a restart runs SHALL wait until the restart ends. + +#### Scenario: Install leaves the daemon running +- **WHEN** a remote host's daemon of another version runs a terminal and the browser sends `installHub` +- **THEN** the hub's bundle is installed there, the browser receives `done`, and the terminal keeps running in the same daemon instance + +#### Scenario: Install shares a running reinstall +- **WHEN** a `reinstallDaemon` for host 1 is running and another session sends `installHub` for host 1 +- **THEN** only one installation runs and both requests are answered when it ends + +#### Scenario: Restart waits for an install +- **WHEN** an `installHub` for host 1 is running and a `restartDaemon` for host 1 arrives +- **THEN** the restart starts only after the install ended diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/protocol/spec.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/protocol/spec.md new file mode 100644 index 0000000..e51accb --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/protocol/spec.md @@ -0,0 +1,180 @@ +## MODIFIED Requirements + +### Requirement: Protocol version and frozen handshake messages +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 10, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. The print-code line and the page fragments, exchanged between `wtd` installations of any versions, SHALL likewise never change shape. + +#### Scenario: Hello round-trips +- **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded +- **THEN** the decoded value equals the original + +#### Scenario: Hello from another protocol version is still decodable +- **WHEN** a `hello` carrying `protocol` 7 is decoded +- **THEN** decoding succeeds and yields `protocol` 7 + +#### Scenario: Invalid instance rejected +- **WHEN** a `hello` whose `instance` contains a space is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Browser-link change leaves the daemon version +- **WHEN** a message is added to the browser-to-hub catalogue and only `BROWSER_PROTOCOL_VERSION` is raised +- **THEN** the wire golden check passes, and it fails if a daemon-link schema changed as well + +#### Scenario: Shared constant needs both versions +- **WHEN** `MAX_FRAME` changes and only one of the two versions is raised +- **THEN** the wire golden check fails + +#### Scenario: Frozen print-code and fragment shapes +- **WHEN** the print-code line or a page fragment schema accepts or rejects a sample differently from the frozen golden +- **THEN** the frozen golden check fails + +### Requirement: Browser-link message catalogue +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `openHub{req, host}`; `installHub{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. +Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable and `code` a hub error code; `reposDiscovered{req, host, repos}`; `hubLogin{req, login}` with `login` a login URL as in a host entry; `hubOpened{req, url}`. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, login, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, `needs-login`, and `reason`, `daemonVersion`, `instance` and `login` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. `login` SHALL be a string of at most 1024 characters starting with `https://login.tailscale.com/`, non-null exactly when `status` is `needs-login`; decoding SHALL reject any other `login`. A preset SHALL be `{name, command}` with `command` nullable. A `hubOpened` `url` SHALL be `http://127.0.0.1:/#` with `` from 1024 to 65535 without leading zeros, followed by a remote page fragment; decoding SHALL reject any other `url`. + +#### Scenario: Envelope cannot carry a handshake message +- **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Every browser-link message round-trips +- **WHEN** a valid instance of each browser-link message is encoded and decoded +- **THEN** each decoded value equals its original + +#### Scenario: Host entry without instance rejected +- **WHEN** a `hosts` message whose entry lacks `instance` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Host entry without reason rejected +- **WHEN** a `hosts` message whose entry lacks `reason` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Outdated host carries its instance +- **WHEN** a `hosts` entry with `status` "outdated" and a non-null `instance` is decoded +- **THEN** decoding succeeds and yields that instance + +#### Scenario: Token message only from the hub +- **WHEN** a `token` message is decoded as a browser-to-hub message +- **THEN** decoding fails with a protocol error + +#### Scenario: Preset edits only from the browser +- **WHEN** a `removePreset` message is decoded as a hub-to-browser message, or an `addPreset` as a client-to-daemon message +- **THEN** decoding fails with a protocol error + +#### Scenario: Invalid preset edit rejected +- **WHEN** an `addPreset` whose preset has a blank name or an empty command, or a `removePreset` with an empty name, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Preset position bounded +- **WHEN** a `movePreset` with `to` −1, 64 or 0.5 is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Needs-login host carries its login URL +- **WHEN** a `hosts` entry with `status` "needs-login" and `login` "https://login.tailscale.com/a/abc123" is decoded +- **THEN** decoding succeeds and yields that URL + +#### Scenario: Login URL outside Tailscale rejected +- **WHEN** a `hosts` entry with `status` "needs-login" whose `login` is "http://login.tailscale.com/a/x", "https://login.tailscale.com.evil/a/x", "javascript:alert(1)" or 1025 characters long is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Login present exactly while needs-login +- **WHEN** a `hosts` entry with `status` "needs-login" and a null `login`, or with `status` "connecting" and a non-null `login`, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Hub opening messages only in their direction +- **WHEN** an `openHub` is decoded as a hub-to-browser message, or a `hubOpened` as a browser-to-hub message +- **THEN** decoding fails with a protocol error + +#### Scenario: Opened URL must be a loopback page +- **WHEN** a `hubOpened` whose `url` is "http://evil.example:7418/#code=…", "https://127.0.0.1:7418/#…", "http://127.0.0.1:80/#…", "http://127.0.0.1:7418/x#…" or "javascript:alert(1)" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Hub login URL outside Tailscale rejected +- **WHEN** a `hubLogin` whose `login` is "https://login.tailscale.com.evil/a/x" is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Value limits +Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; discovery roots are absolute paths, `~`, or `~/` followed by at least one character, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages and host reasons are at most 1024 characters; daemon error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`; hub error codes, used only by the hub-to-browser `error`, are the daemon error codes and `hub-outdated` and `hub-failed`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. + +#### Scenario: Relative path rejected +- **WHEN** a `watchRepo` whose `repo` is "repo/a" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Home-relative discovery roots accepted +- **WHEN** a `discoverRepos` whose `roots` are "~" and "~/GitHub" is decoded +- **THEN** decoding succeeds and yields both roots unchanged + +#### Scenario: Other relative discovery roots rejected +- **WHEN** a `discoverRepos` whose `roots` hold "GitHub", "~user/x" or "~/" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Home-relative repo rejected +- **WHEN** a `watchRepo` or `addRepo` whose `repo` is "~/x" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Overlong host reason rejected +- **WHEN** a `hosts` entry whose `reason` has 1025 characters is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Collection over limit rejected +- **WHEN** a `worktreesChanged` with 1025 worktrees is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Not-a-repo error code accepted +- **WHEN** an `error` whose `code` is "not-a-repo" is decoded +- **THEN** decoding succeeds + +#### Scenario: Host-unavailable error code accepted +- **WHEN** a hub `error` whose `code` is "host-unavailable" is decoded +- **THEN** decoding succeeds + +#### Scenario: Hub error codes only from the hub +- **WHEN** a hub `error` whose `code` is "hub-outdated" or "hub-failed" is decoded, and a daemon `error` with either code is decoded +- **THEN** the hub error decodes, and the daemon error fails with a protocol error + +#### Scenario: Malformed token rejected +- **WHEN** a `token` message whose `token` has 63 hex digits, or contains an uppercase digit, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Unknown error code rejected +- **WHEN** an `error` whose `code` is "oops" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Blank preset name rejected +- **WHEN** a `presets` message with a preset named " " is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Empty preset command rejected +- **WHEN** a `presets` message with a preset whose `command` is "" is decoded +- **THEN** decoding fails with a protocol error + +## ADDED Requirements + +### Requirement: Print-code line +The protocol SHALL define the print-code line as the JSON object `{port, code}` — `port` an integer from 1024 to 65535, `code` a one-time code — followed by one newline, decoded strictly like a control message from text that SHALL hold nothing else but optional trailing whitespace. + +#### Scenario: Print-code line round-trips +- **WHEN** a print-code line with `port` 7417 and a valid code is encoded and decoded +- **THEN** the decoded value equals the original and the encoded text ends with exactly one newline + +#### Scenario: Extra field or text rejected +- **WHEN** a print-code line carrying an additional field `token`, or preceded by a line `motd`, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Port out of range rejected +- **WHEN** a print-code line with `port` 80 or 7417.5 is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Page fragments +The protocol SHALL define two page fragments, each the text after `#` in a page URL, written as `application/x-www-form-urlencoded` parameters with no parameter repeated and no other parameter. A home fragment SHALL hold `code` (a one-time code), `hub` (a host name of 1–64 characters from `[A-Za-z0-9._-]`), or both. A remote fragment SHALL hold all of `code`, `host` (a host name), `home` (an integer port from 1024 to 65535 without leading zeros) and `hubs` (1 to 63 distinct host names joined by `,`, one of which is `host`). Decoding SHALL reject any other text. + +#### Scenario: Home fragment forms +- **WHEN** `code=<64 hex>`, `hub=box` and `code=<64 hex>&hub=box` are decoded as home fragments +- **THEN** each decodes to the given values + +#### Scenario: Remote fragment decoded +- **WHEN** `code=<64 hex>&host=box&home=7417&hubs=box,gpu` is decoded as a remote fragment +- **THEN** it yields that code, host `box`, home 7417 and hubs `box` and `gpu` in that order + +#### Scenario: Malformed fragments rejected +- **WHEN** a home fragment `hub=a b`, `hub=box&hub=gpu` or `code=x`, or a remote fragment lacking `home`, with `home=0080`, with `hubs=gpu` while `host=box`, or with an extra parameter `token`, is decoded +- **THEN** decoding fails with a protocol error diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/web/spec.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/web/spec.md new file mode 100644 index 0000000..a35dcf0 --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/web/spec.md @@ -0,0 +1,79 @@ +## MODIFIED Requirements + +### Requirement: Authentication +On load, the page SHALL read its URL fragment as a remote fragment, else as a home fragment, else as no fragment, and then remove it from the address without a navigation. A fragment's code SHALL be used once to open the session; the token the hub returns SHALL be kept in session storage and used for every later connection. A remote fragment's `host`, `home` and `hubs` SHALL be kept in session storage, replacing any kept before, and used after reloads; a page with them kept is a remote page, any other page a home page. Without a code or stored token, or when the hub refuses the credential, the page SHALL show only a message telling the user to open worktree-term with `wtd ui`, and SHALL forget a refused stored token. + +#### Scenario: Code exchanged for the token +- **WHEN** the page is opened with a valid one-time code +- **THEN** it connects, the fragment is removed from the address, and a reload connects again without a code + +#### Scenario: No credential +- **WHEN** the page is opened without a code in a fresh browser session +- **THEN** it shows the `wtd ui` message and opens no session + +#### Scenario: Remote fragment kept across a reload +- **WHEN** the page is opened with `#code=&host=box&home=7417&hubs=box,gpu` and then reloaded +- **THEN** after the reload its title is `worktree-term — box` and its Hubs menu lists `box` and `gpu` + +#### Scenario: Malformed fragment ignored +- **WHEN** the page is opened with `#code=x&hub=a b` and a stored token +- **THEN** the fragment is removed, the page connects with the stored token, and no hub is opened + +## ADDED Requirements + +### Requirement: Hubs menu +The page header SHALL show a "Hubs" menu when there is at least one remote host: on a home page the session's remote hosts, on a remote page its kept `hubs`. The menu SHALL list "This machine" followed by each such host name in order, marking the page's own hub — "This machine" on a home page, its `host` on a remote page. Picking the marked entry SHALL do nothing. On a home page, picking a host SHALL close the menu and open that host's hub as the hub opening requirement states. On a remote page, picking "This machine" SHALL navigate the tab to `http://127.0.0.1:/`, and picking another host SHALL navigate it to `http://127.0.0.1:/#hub=`. On a home page, a fragment with `hub` SHALL open that host's hub once the session greets; a name no remote host carries SHALL show the status `no host named `. + +#### Scenario: Menu lists hubs +- **WHEN** a home page's session lists remote hosts `box` and `gpu` +- **THEN** the Hubs menu lists "This machine", `box` and `gpu`, with "This machine" marked + +#### Scenario: No menu without remote hosts +- **WHEN** a home page's session lists only the local host +- **THEN** the header shows no Hubs menu + +#### Scenario: Back home from a remote page +- **WHEN** a remote page with `home` 7417, opened from a home page in the same tab, picks "This machine" +- **THEN** the tab shows the home page on `http://127.0.0.1:7417/`, connected with its stored token + +#### Scenario: Switching between remotes goes through home +- **WHEN** a remote page for `box` with `home` 7417 picks `gpu` +- **THEN** the tab navigates to `http://127.0.0.1:7417/#hub=gpu`, which opens `gpu`'s hub + +#### Scenario: Opening from the address +- **WHEN** a home page is opened with `#code=&hub=box` +- **THEN** it connects and opens `box`'s hub without any click + +#### Scenario: Unknown hub in the address +- **WHEN** a home page is opened with `#hub=nope` and no remote host is named `nope` +- **THEN** the status line shows `no host named nope` and the page stays + +### Requirement: Hub opening status +Opening a host's hub from a home page SHALL send `openHub` for that host and show a status line beside the Hubs menu for the latest opening only, ignoring any reply to an earlier one: `Opening …` while waiting; on `hubLogin`, ` needs Tailscale login` with an "Open login link" button opening the `login` URL in a new tab without giving that tab access to the page; on `hub-outdated`, `: ` with a "Reinstall on " button, which sends `installHub`, shows `Installing on …`, and on `done` opens the hub again; on any other error, `: ` with a × that clears the line; on `hubOpened`, navigation of the tab to its `url`. A remote page SHALL never send `openHub`. + +#### Scenario: Opening navigates the tab +- **WHEN** the user picks `box` in the Hubs menu and the hub replies `hubOpened` with `http://127.0.0.1:7418/#code=&host=box&home=7417&hubs=box` +- **THEN** the same tab shows `box`'s hub on `http://127.0.0.1:7418/`, titled `worktree-term — box`, listing `box`'s own repos + +#### Scenario: Login link while opening +- **WHEN** the hub replies `hubLogin` with `https://login.tailscale.com/a/abc123` and, after approval, `hubOpened` +- **THEN** the status line shows `box needs Tailscale login` with an "Open login link" button linking to that URL in a new tab with no opener, and then the tab navigates to the remote hub + +#### Scenario: Outdated remote reinstalled from the status line +- **WHEN** the hub replies `error` with code `hub-outdated` for `box` and the user clicks "Reinstall on box" +- **THEN** the page sends `installHub` for `box`, shows `Installing on box…`, and after `done` sends `openHub` for `box` again + +#### Scenario: Failure shown with its reason +- **WHEN** the hub replies `error` with code `hub-failed` and message `ssh: Could not resolve hostname box` +- **THEN** the status line shows `box: ssh: Could not resolve hostname box` with a ×, and the tab stays on the home page + +#### Scenario: Later pick wins +- **WHEN** the user picks `box`, then `gpu` before `box`'s reply arrives, and `box`'s `hubOpened` arrives first +- **THEN** the tab does not navigate to `box` and the status line shows `Opening gpu…` + +### Requirement: Page title +A home page's title SHALL be `worktree-term`; a remote page's title SHALL be `worktree-term — `. + +#### Scenario: Remote page titled by host +- **WHEN** a page is opened with a remote fragment whose `host` is `devbox` +- **THEN** its title is `worktree-term — devbox` diff --git a/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md new file mode 100644 index 0000000..e557028 --- /dev/null +++ b/openspec/changes/archive/2026-10-18-dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md @@ -0,0 +1,39 @@ +## 1. Tests — unit + +- [x] 1.1 `src/protocol/messages.test.ts` + `test-samples.ts`: `openHub`, `installHub`, `hubLogin`, `hubOpened` round-trip and are rejected in the wrong direction; `hubOpened.url` rejections (foreign host, `https:`, port 80, a path other than `/`, `javascript:`); `hubLogin` lookalike URL rejected; `hub-outdated` and `hub-failed` accepted by the hub `error` and rejected by the daemon `error`; fails before the schemas exist +- [x] 1.2 `src/protocol`: print-code line round-trip, single trailing newline, extra field, leading `motd` line, port 80 and 7417.5 rejected; home and remote fragment decode and reject cases from the spec; the samples are added to `frozen.golden.json` and the frozen golden test fails on any shape change +- [x] 1.3 `src/protocol/wire.golden.*`: re-blessed for `BROWSER_PROTOCOL_VERSION` 10 with `DAEMON_PROTOCOL_VERSION` 6 and daemon-link goldens unchanged; `constants.test.ts` expects 10 +- [x] 1.4 `src/hub/config`: the `port` schema (range, unique, ≠ hub port incl. default 7417, unknown keys still rejected); the assignment edit: first port 7418, max+1 (7418 and 7425 give 7426), a removed lower port not reused, the wrap-around (hub 65535, held 1024, gives 1025), no free port → error, a present port untouched and the file unchanged, recomputed after a concurrent writer (gives 7422 with 7421 taken), other values kept as written +- [x] 1.5 `src/hub/router/coordinator.test.ts`: the FIFO queue with `install`: install shares a running or queued install or reinstall; restart and reinstall wait for an install; install waits for a restart; all orderings of three requests keep one run at a time; existing restart/reinstall sharing unchanged +- [x] 1.6 `src/cli/run.test.ts`: `ui box` dispatches with the host; `ui --print-code` dispatches print mode; `ui box --print-code` and `ui a b` exit 2 with usage; help lists `ui [] [--print-code]`; `--version` names browser protocol 10 +- [x] 1.7 `src/hub/links`: `HubOpener` against fake processes: login URL reported once, 30 s budget only without a URL, stdout over 4096 bytes kills the process and fails, exit 2/127/undecodable gives outdated, 255 gives failed with the last stderr line, cancel kills and suppresses every callback; `forward` pipes both ways and ends both sides when either ends +- [x] 1.8 `src/web/client`: fragment reading (remote, else home, else none; malformed ignored; remote values kept in and read back from session storage; title derivation); `src/web/ui`: the Hubs menu model (entries, marked entry, hidden without remote hosts, target URLs on a remote page) and the status-line reducer (latest-only, login, outdated → installing → reopen, failed with ×, unknown `#hub` name) + +## 2. Tests — process, e2e, integration + +- [x] 2.1 `test/support/fake-ssh.ts`: support `-W 127.0.0.1:` by relaying stdio to that TCP port on the fake remote; scriptable print-code behaviour (banner then wait, exit 2/127/255, oversized output, refused forward printing `channel 0: open failed: administratively prohibited`); existing behaviour unchanged +- [x] 2.2 `test/process/hub-server.test.ts`: any-port `Host` accepted for `GET /`, the API and upgrades with a matching `Origin`; `127.0.0.1`, `127.0.0.1:07417` and a mismatched `Origin` refused (403); the existing Host and Origin cases still pass +- [x] 2.3 `test/process/hub-cli.test.ts`: `wtd ui --print-code` starts a hub and prints a line whose code the hub accepts once, opens no browser, and replaces a hub of another version; `wtd ui box` passes `#code=…&hub=box` to `$WTD_BROWSER`; `wtd ui nope` exits 1 with the exact message and starts no hub +- [x] 2.4 `test/process/hub-router.test.ts` (fake remote with its own hub): `openHub` writes `port` 7418, replies `hubOpened` with the URL from the spec, and `GET /` and a code upgrade through 127.0.0.1:7418 reach the remote hub; login banner then release after a delay → `hubLogin` then `hubOpened`; exit 2 and 127 → `hub-outdated` with no listener; 255 → `hub-failed` with the line; 30 s silence → `hub-failed` (injected short budget); port held → `hub-failed` naming it; refused forward → `hub-failed` with its line; local index → `unknown-host` +- [x] 2.5 `test/process/hub-router.test.ts`: a second `openHub` kills the first's SSH process and only the second replies; two sessions share one listener; closing a session kills its opening SSH process; closing a tunneled connection ends its forward process; a host removed from config gets its connections closed without SSH; a changed `port` rebinds and retires the old listener; `SIGTERM` closes listeners and forwards +- [x] 2.6 `test/process/hub-router.test.ts`: `installHub` installs while a daemon terminal keeps running in the same instance; it shares a running `reinstallDaemon`; a `restartDaemon` waits for it +- [x] 2.7 `test/e2e` (fake remote hub reachable through fake SSH): Hubs menu lists "This machine" and the remote; picking it lands the tab on the remote origin titled `worktree-term — box` with the remote's repos; "This machine" goes back home and reconnects with the stored token; a remote → other remote pick goes through `home/#hub=`; status line for login (anchor `target=_blank`, `rel` with `noopener noreferrer`), outdated → "Reinstall on box" → installing → navigation, failure with ×; `#hub=nope` shows `no host named nope` +- [x] 2.8 `test/integration` (opt-in, real SSH): `-W` over an existing master and with none, a refused forward reported, master loss ends forwards, no child processes left behind + +## 3. Implementation + +- [x] 3.1 `src/protocol`: new messages, `hubErrorCode`, the `hubOpened` URL schema, the print-code line and fragment schemas with encoders and decoders, version 10, goldens; tests 1.1–1.3 pass +- [x] 3.2 Architecture: apply the two approved hub.arc42.md edits verbatim; add `net` to `hub.server` in `test/architecture/dependencies.ts`; `la-arch-check` passes with no new arrows +- [x] 3.3 `src/hub/server/listener.ts`: Host `127.0.0.1:` for any port, same-origin `Origin` for the API and upgrades; test 2.2 passes +- [x] 3.4 `src/hub/config`: the `port` field and the assignment edit in `ConfigEditor`; test 1.4 passes +- [x] 3.5 `src/hub/router/coordinator.ts`: the FIFO queue with `install`; test 1.5 passes +- [x] 3.6 `src/hub/links`: `HubOpener` and `forward`; test 1.7 passes +- [x] 3.7 `src/hub/server`: `TunnelListeners` (127.0.0.1 only, serialised bind/repoint/retire/close, per-connection authorisation against the latest config map, close on shutdown) +- [x] 3.8 `src/hub/router`: `openHub` (fresh config, port assignment, opener, one per session with cancellation, `HubTunnels.ensure`, probe, URL) and `installHub`; `src/hub/main` wires `HubTunnels` and the config map; tests 2.4–2.6 pass +- [x] 3.9 `src/hub/main` + `src/cli/run.ts`: `ui [] [--print-code]`, optional positional, mutual exclusion, unknown host, the `--print-code` line, usage text; tests 1.6 and 2.3 pass +- [x] 3.10 `src/web`: fragment reading and session storage in `client`, title, Hubs menu, status line, `location.assign` navigation; tests 1.8 and 2.7 pass +- [x] 3.11 Docs: README usage (the Hubs menu, `wtd ui `, `wtd ui --print-code`), configuration (the per-host `port`, written by the hub), remote-host requirements (`AllowTcpForwarding`), and the README architecture section's two-machine diagram gains the hub tunnel (laptop hub's per-host port → `ssh -W` → remote hub); `wtd --help` text matches; every doc example still runs as written + +## 4. Final gates + +- [x] 4.1 `pnpm test` green (incl. Playwright); `pnpm lint` exits 0; `tsc -b` clean; `la-arch-check` passes; `openspec validate dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window --strict` passes diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 9a12a52..5354e4a 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -10,17 +10,17 @@ The `wtd` command line surface: how a user discovers its commands and version, a #### Scenario: Version - **WHEN** the user runs `wtd --version` -- **THEN** stdout is `wtd (daemon protocol 6, browser protocol 8)` followed by a newline, stderr is empty, and the exit code is 0 +- **THEN** stdout is `wtd (daemon protocol 6, browser protocol 10)` followed by a newline, stderr is empty, and the exit code is 0 ### Requirement: Help output -`wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui`, `hub`, `daemon`, `connect`, `install-local [--systemd]` and `install-remote [--node ]`, and the options `--help` and `--version`. +`wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui [] [--print-code]`, `hub`, `daemon`, `connect`, `install-local [--systemd]` and `install-remote [--node ]`, and the options `--help` and `--version`. #### Scenario: Help - **WHEN** the user runs `wtd --help` - **THEN** stdout lists every command and option above, stderr is empty, and the exit code is 0 ### Requirement: Usage errors -Running `wtd` with no arguments, an unknown command, an option the command does not take, `--node` without a value, or `install-remote` without an alias SHALL print a one-line error and the usage to stderr, print nothing to stdout, and exit 2. +Running `wtd` with no arguments, an unknown command, an option the command does not take, `--node` without a value, `install-remote` without an alias, a second argument to `ui`, or `ui` with both a host and `--print-code` SHALL print a one-line error and the usage to stderr, print nothing to stdout, and exit 2. #### Scenario: No arguments - **WHEN** the user runs `wtd` @@ -46,6 +46,14 @@ Running `wtd` with no arguments, an unknown command, an option the command does - **WHEN** the user runs `wtd install-remote box --node` - **THEN** stderr says `--node` needs a value and contains the usage, and the exit code is 2 +#### Scenario: Host and print-code together +- **WHEN** the user runs `wtd ui box --print-code` +- **THEN** stderr says a host and `--print-code` cannot be combined and contains the usage, stdout is empty, and the exit code is 2 + +#### Scenario: Two hosts +- **WHEN** the user runs `wtd ui a b` +- **THEN** stderr names `b` as unexpected and contains the usage, and the exit code is 2 + ### Requirement: Daemon command `wtd daemon` SHALL run this host's daemon in the foreground until it receives `shutdown`, `SIGTERM` or `SIGINT`, then exit 0. It SHALL ignore `SIGHUP`. If a daemon is already serving this host's socket, it SHALL print `wtd daemon: already running` to stderr and exit 1. Any other start failure SHALL print one line naming the cause to stderr, without a stack trace, and exit 1. @@ -88,7 +96,7 @@ Running `wtd` with no arguments, an unknown command, an option the command does - **THEN** stderr is `wtd hub: port is in use` and the exit code is 1 ### Requirement: UI command -`wtd ui` SHALL make a hub of its own version serve and then open the UI. If a running hub reports the same version, it SHALL be reused; if it reports another version, it SHALL be asked to shut down and a new hub started once its port is free; otherwise a hub SHALL be started detached with its output appended to `hub.log`, and `wtd ui` SHALL wait up to 5 s for it to serve. It SHALL then obtain a one-time code from the hub and start the browser command — `$WTD_BROWSER` if set, else `google-chrome` — with the argument `--app=http://127.0.0.1:/#code=`, detached with its stdio ignored, and exit 0. The browser's arguments SHALL never contain the token. A hub that cannot be started or reached, or a browser command that cannot be started, SHALL print one line naming the cause to stderr and exit 1. A hub record whose hub does not answer an authenticated identity request SHALL be treated as stale, and its process SHALL NOT be signalled. +`wtd ui [] [--print-code]` SHALL make a hub of its own version serve. If a running hub reports the same version, it SHALL be reused; if it reports another version, it SHALL be asked to shut down and a new hub started once its port is free; otherwise a hub SHALL be started detached with its output appended to `hub.log`, and `wtd ui` SHALL wait up to 5 s for it to serve. It SHALL then obtain a one-time code from the hub. Without options it SHALL start the browser command — `$WTD_BROWSER` if set, else `google-chrome` — with the argument `--app=http://127.0.0.1:/#code=`, detached with its stdio ignored, and exit 0. With `` it SHALL first fail, before touching any hub, with one line `wtd ui: no host named in ` on stderr and exit 1 unless the configuration has a remote host of that name, and SHALL otherwise start the browser command the same way with `--app=http://127.0.0.1:/#code=&hub=`. With `--print-code` it SHALL start no browser, print exactly one print-code line holding the hub's port and the code to stdout, and exit 0. The browser's arguments and the printed line SHALL never contain the token. A hub that cannot be started or reached, or a browser command that cannot be started, SHALL print one line naming the cause to stderr and exit 1. A hub record whose hub does not answer an authenticated identity request SHALL be treated as stale, and its process SHALL NOT be signalled. #### Scenario: Hub started and browser opened - **WHEN** no hub is running and the user runs `wtd ui` @@ -110,6 +118,22 @@ Running `wtd` with no arguments, an unknown command, an option the command does - **WHEN** the browser command does not exist - **THEN** stderr names the browser command and the exit code is 1 +#### Scenario: Opening a host's hub +- **WHEN** the configuration lists remote host `box` and the user runs `wtd ui box` +- **THEN** the browser command receives `--app=http://127.0.0.1:/#code=&hub=box` and the exit code is 0 + +#### Scenario: Unknown host +- **WHEN** the configuration lists no host named `nope` and the user runs `wtd ui nope` +- **THEN** stderr is `wtd ui: no host named nope in `, no browser is started, and the exit code is 1 + +#### Scenario: Printing a code +- **WHEN** no hub is running and the user runs `wtd ui --print-code` +- **THEN** a hub serves, stdout is one print-code line with the hub's port and a code that the hub accepts once, no browser command is started, and the exit code is 0 + +#### Scenario: Printing a code replaces another version +- **WHEN** a hub reporting another version is running and the user runs `wtd ui --print-code` +- **THEN** that hub exits, a hub of the current version serves, and stdout is its print-code line + ### Requirement: Install commands `wtd install-local [--systemd]` SHALL perform a local installation, with the systemd option when `--systemd` is given. `wtd install-remote [--node ]` SHALL perform a remote installation to ``, with the given Node path when `--node` is given. On success each SHALL print one line to stdout naming the installed version and where it was installed and exit 0; on failure, one line naming the cause to stderr, without a stack trace, and exit 1. diff --git a/openspec/specs/hub/spec.md b/openspec/specs/hub/spec.md index b489bfd..9befa97 100644 --- a/openspec/specs/hub/spec.md +++ b/openspec/specs/hub/spec.md @@ -36,16 +36,20 @@ The hub token SHALL be 32 random bytes as 64 lowercase hex digits in the owner-o - **THEN** the hub starts with a new valid token in the file ### Requirement: Request checks and headers -Every HTTP request and WebSocket upgrade SHALL carry exactly one `Host` header equal to `127.0.0.1:`; any other request SHALL be refused with status 403. Every response SHALL carry `Content-Security-Policy: default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. +Every HTTP request and WebSocket upgrade SHALL carry exactly one `Host` header of the form `127.0.0.1:`, `` a decimal integer from 1 to 65535 without leading zeros; any other request SHALL be refused with status 403. Any port is accepted so that a loopback port forwarded from another host reaches the hub. Every response SHALL carry `Content-Security-Policy: default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. #### Scenario: Foreign Host refused -- **WHEN** a request carries `Host: localhost:` or `Host: evil.example` +- **WHEN** a request carries `Host: localhost:`, `Host: evil.example`, `Host: 127.0.0.1` or `Host: 127.0.0.1:07417` - **THEN** the response status is 403 #### Scenario: Duplicate Host refused - **WHEN** a request carries two `Host` headers - **THEN** the response status is 403 +#### Scenario: Forwarded port accepted +- **WHEN** a hub listening on port 7417 receives `GET /` with `Host: 127.0.0.1:7420` +- **THEN** it serves `index.html` with status 200 + #### Scenario: Security headers present - **WHEN** `index.html` is requested - **THEN** the response carries the content security policy, `nosniff` and `no-referrer` headers above @@ -77,7 +81,7 @@ The hub SHALL serve exactly the files of the web bundle present at its start, `/ - **THEN** the response status is 403 ### Requirement: Hub identity and shutdown -`GET /api/identity` with the token SHALL return the hub's package version and `instance`. `POST /api/shutdown` with the token SHALL answer and then shut the hub down as `SIGTERM` does. Both SHALL refuse a request without the valid token with status 401 and an `Origin` other than the hub's own with status 403. +`GET /api/identity` with the token SHALL return the hub's package version and `instance`. `POST /api/shutdown` with the token SHALL answer and then shut the hub down as `SIGTERM` does. Both, and `POST /api/code`, SHALL refuse a request without the valid token with status 401, and a request carrying an `Origin` other than `http://` followed by the request's own `Host` value with status 403. #### Scenario: Identity reported - **WHEN** identity is requested with the token @@ -87,13 +91,21 @@ The hub SHALL serve exactly the files of the web bundle present at its start, `/ - **WHEN** shutdown is requested without the token - **THEN** the response status is 401 and the hub keeps running +#### Scenario: Cross-origin API request refused +- **WHEN** identity is requested with the token, `Host: 127.0.0.1:7420` and `Origin: http://127.0.0.1:7417` +- **THEN** the response status is 403 + ### Requirement: WebSocket authentication -A WebSocket upgrade SHALL be accepted only at `/ws`, with exactly one `Origin` header equal to `http://127.0.0.1:`, and a `Sec-WebSocket-Protocol` list that, parsed as exact comma-separated tokens, holds `wtd` and exactly one credential: `wtd.token.` with the hub token, or `wtd.code.` with a valid unused code, which the accepted upgrade consumes. The hub SHALL select the subprotocol `wtd` and SHALL never record the offered subprotocols. A refused upgrade SHALL get status 401 for a missing or invalid credential and 403 otherwise. The largest accepted browser message SHALL be `MAX_FRAME` bytes. +A WebSocket upgrade SHALL be accepted only at `/ws`, with exactly one `Origin` header equal to `http://` followed by the request's own `Host` value, and a `Sec-WebSocket-Protocol` list that, parsed as exact comma-separated tokens, holds `wtd` and exactly one credential: `wtd.token.` with the hub token, or `wtd.code.` with a valid unused code, which the accepted upgrade consumes. The hub SHALL select the subprotocol `wtd` and SHALL never record the offered subprotocols. A refused upgrade SHALL get status 401 for a missing or invalid credential and 403 otherwise. The largest accepted browser message SHALL be `MAX_FRAME` bytes. #### Scenario: Missing or wrong Origin refused -- **WHEN** an upgrade with the token carries no `Origin`, or `Origin: http://evil.example` +- **WHEN** an upgrade with the token carries no `Origin`, `Origin: http://evil.example`, or `Host: 127.0.0.1:7420` with `Origin: http://127.0.0.1:8080` - **THEN** it is refused with status 403 +#### Scenario: Forwarded origin accepted +- **WHEN** a hub listening on port 7417 receives an upgrade with the token, `Host: 127.0.0.1:7420` and `Origin: http://127.0.0.1:7420` +- **THEN** it is accepted and the session greets + #### Scenario: Wrong credential refused - **WHEN** an upgrade carries a wrong token, a credential with a different prefix or suffix, two credentials, or none - **THEN** it is refused and no session starts @@ -141,7 +153,7 @@ After the handshake, a message that fails to decode, a second `hello`, or an out - **THEN** that session is closed and the other keeps working ### Requirement: Configuration -The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, repos, roots}`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}, {name: "claude", command: "claude"}, {name: "codex", command: "codex"}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo or preset edit from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. +The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, port, repos, roots}`, `port`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `port` SHALL be an integer from 1024 to 65535, unique among hosts and different from the hub's `port` (its default included); its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}, {name: "claude", command: "claude"}, {name: "codex", command: "codex"}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo or preset edit from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. #### Scenario: Defaults without a file - **WHEN** no `config.json` exists and the hub starts @@ -175,6 +187,10 @@ The configuration SHALL be `config.json` holding a JSON object with optional `po - **WHEN** two hosts are named `box` - **THEN** the start fails with a line naming the file and `hosts` +#### Scenario: Clashing host ports refused +- **WHEN** two hosts have `port` 7420, or a host has `port` 7417 while the top-level `port` is absent +- **THEN** the start fails with a line naming the file and `port` + #### Scenario: Edit picked up by a new session - **WHEN** a repo or a preset is added to `config.json` by hand while a session is open, and a new session starts - **THEN** the new session's `hosts` lists the repo, its `presets` lists the preset, and the open session's do not change @@ -280,7 +296,7 @@ While a session's unsent WebSocket data is at least 1 MiB, the hub SHALL stop re - **THEN** exactly one new daemon instance serves and both receive `done` when the reinstall ends ### Requirement: Session end and hub shutdown -When a session's WebSocket closes, the hub SHALL close that session's links; terminals SHALL keep running. On `SIGTERM`, `SIGINT` or an authenticated shutdown request the hub SHALL close every session with WebSocket code 1001, close all links, stop listening, remove its hub record and exit 0. +When a session's WebSocket closes, the hub SHALL close that session's links and end any hub opening that session started, killing its SSH process; terminals SHALL keep running. On `SIGTERM`, `SIGINT` or an authenticated shutdown request the hub SHALL close every session with WebSocket code 1001, close all links, close every hub tunnel listener and end every forward, stop listening, remove its hub record and exit 0. #### Scenario: Closing the browser keeps terminals - **WHEN** a browser with an attached terminal closes its session and a new session watches the repo @@ -290,6 +306,10 @@ When a session's WebSocket closes, the hub SHALL close that session's links; ter - **WHEN** the hub receives `SIGTERM` with a session open - **THEN** the session is closed with code 1001 and the hub exits 0 +#### Scenario: Shutdown closes tunnels +- **WHEN** the hub receives `SIGTERM` while a hub tunnel listener is bound and one forward is open +- **THEN** the listener's port accepts no more connections, the forward's SSH process exits, and the hub exits 0 + ### Requirement: Daemon reinstall `reinstallDaemon{req, host}` for a remote host in any status SHALL install the hub's own bundle on that host as `wtd install-remote` does, within 120 s; then dial the host until a daemon answers whose `hello` carries the hub's package version and `protocol`, sending the frozen `hello` and `shutdown` to any daemon that answers with another version or protocol, within 10 s; and then reply `done`. A failure SHALL reply `error{code: internal}` with a message naming the failed step (`install` or `restart`) and its cause. For the local host it SHALL reply `error{code: internal}` saying the local daemon is restarted, not reinstalled. It SHALL be coordinated with restarts as the daemon restart requirement states. @@ -351,7 +371,7 @@ When a session's WebSocket closes, the hub SHALL close that session's links; ter - **THEN** it is removed from `config.json` and the browser receives `done` ### Requirement: Configuration edits -A configuration edit — a repo edit or a preset edit — SHALL read the current `config.json` (an absent file read as `{}`), change only the edited list — for a repo edit the `repos` of the edited host, the top-level `repos` for the local host, otherwise those of the host entry with the session's host name; for a preset edit the top-level `presets`, starting from the default presets when the file has none — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host of a repo edit, the result would hold more than 256 repos for the host, or a preset edit is refused. If the file changed between the read and the replacement, the edit SHALL start over, at most 3 times in all, and then fail with `error{code: internal}`. Edits of either kind SHALL be applied one at a time. After a repo edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; after a preset edit that changed the file, every open session SHALL receive `presets` with the edited presets; nothing else in any session's snapshot SHALL change. +A configuration edit — a repo edit, a preset edit or a host port assignment — SHALL read the current `config.json` (an absent file read as `{}`), change only the edited value — for a repo edit the `repos` of the edited host, the top-level `repos` for the local host, otherwise those of the host entry with the session's host name; for a preset edit the top-level `presets`, starting from the default presets when the file has none; for a port assignment the `port` of the named host entry — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host of a repo edit, the result would hold more than 256 repos for the host, or a preset edit is refused. If the file changed between the read and the replacement, the edit SHALL start over from the changed file, recomputing its result, at most 3 times in all, and then fail with `error{code: internal}`. Edits of every kind SHALL be applied one at a time. After a repo edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; after a preset edit that changed the file, every open session SHALL receive `presets` with the edited presets; nothing else in any session's snapshot SHALL change. #### Scenario: Other entries kept as written - **WHEN** `config.json` lists `~/a` for the local host and a repo is added for it @@ -373,6 +393,10 @@ A configuration edit — a repo edit or a preset edit — SHALL read the current - **WHEN** a repo is added and a preset removed at the same time - **THEN** the file holds both changes +#### Scenario: Port assignment recomputed after a concurrent edit +- **WHEN** a port assignment for host `a` read a file where the highest held port is 7420, and before it replaced the file another writer gave host `b` the `port` 7421 +- **THEN** the assignment starts over and gives `a` the `port` 7422, and `b` keeps 7421 + ### Requirement: Editing presets `addPreset{req, preset}` SHALL append the preset to the presets, `removePreset{req, name}` SHALL remove the preset of exactly that name and `movePreset{req, name, to}` SHALL move the preset of exactly that name to position `to`, or to the last position when `to` is beyond it, each as a configuration edit. The requester SHALL receive `done`, and only then every open session `presets`. `addPreset` SHALL be refused with `error{req, host: null, code: internal}` naming the reason when a preset of that name exists or there would be more than 64 presets; `removePreset` SHALL be refused that way when it would remove the last preset. A `removePreset` or `movePreset` naming no preset, and a `movePreset` to the preset's own position, SHALL answer `done`, leave the file unchanged and send no `presets`. @@ -434,3 +458,99 @@ While a remote link has received no frame from its daemon, the hub SHALL read it #### Scenario: One approval connects every session - **WHEN** sessions A and B each hold a `needs-login` link to `box` over their own SSH processes, a session C is `connected` to `box`, and only A's SSH process proceeds and greets - **THEN** B's original SSH process is terminated, B receives `box` as `connecting` and then `connected` over a new SSH process, and C receives no change to `box` + +### Requirement: Host port assignment +Opening a remote host's hub SHALL use that host's `port` from the current `config.json`. A host without `port` SHALL first be assigned one by a configuration edit: one more than the highest of the hub's `port` and every host's `port`; when that exceeds 65535, the lowest port from 1024 upward that is neither the hub's `port` nor any host's `port`; when no such port exists, the opening SHALL fail with `hub-failed` saying there is no free port for the host. A host's `port` that is present SHALL never be changed by the hub. + +#### Scenario: First port above the hub's +- **WHEN** the hub's `port` is 7417, no host has a `port`, and host `box` is opened +- **THEN** `config.json` gives `box` the `port` 7418, keeping every other value as written + +#### Scenario: Next port above the highest held +- **WHEN** hosts hold ports 7418 and 7425 and host `new` without a port is opened +- **THEN** `new` is given 7426 + +#### Scenario: Removed host's port not reused while a higher one is held +- **WHEN** hosts `a` (7418) and `b` (7419) exist, `a` is removed from `config.json`, and host `c` is opened +- **THEN** `c` is given 7420 + +#### Scenario: Wrap-around search +- **WHEN** the hub's `port` is 65535, host `a` holds 1024, and host `b` without a port is opened +- **THEN** `b` is given 1025 + +#### Scenario: Hand-written port kept +- **WHEN** host `box` has `port` 9000 in `config.json` and is opened +- **THEN** the hub forwards port 9000 for it and the file is unchanged + +### Requirement: Opening a remote hub +`openHub{req, host}` SHALL open the hub of the remote host at index `host` of the session's `hosts`, its name, `ssh` alias and `port` read afresh from the current valid `config.json` by the host's name; a local host index, an index outside the snapshot, or a host no longer configured SHALL reply `error{code: unknown-host}`. The hub SHALL run `"$HOME/.local/bin/wtd" ui --print-code` on the host over SSH with the same options as a remote link, reading its standard error as it is written: a Tailscale check login URL, recognised as for a remote link, SHALL be sent once per URL as `hubLogin{req, login}`. Without such a URL the run SHALL be ended after 30 s, replying `error{code: hub-failed}` saying the host did not answer within 30 s; after one, no time limit SHALL apply until SSH ends. Standard output beyond 4096 bytes SHALL end the run with `hub-failed`. An exit status of 127 or 2, or status 0 with output that is not exactly one print-code line, SHALL reply `error{code: hub-outdated}` saying `wtd` on the host is missing or too old to serve its hub; any other failure SHALL reply `error{code: hub-failed}` with SSH's last standard error line or, without one, the exit status. On success the hub SHALL make sure a tunnel listener for the host is bound on `127.0.0.1:` forwarding to the printed remote port, failing with `hub-failed` saying the port is in use when another process holds it; then fetch `GET /` through that listener, failing with `hub-failed` and the forward's last standard error line unless it answers 200 within 10 s; and then reply `hubOpened{req, url}` with `url` = `http://127.0.0.1:/#` followed by the remote page fragment carrying the printed code, the host's name, the hub's own port, and the names of every configured remote host in configuration order. Every error reply SHALL carry the host's index. A session SHALL have at most one opening in progress: a new `openHub` SHALL end the previous one, killing its SSH process, and an ended opening SHALL change no listener and send nothing further. + +#### Scenario: Remote hub opened +- **WHEN** host 1 is `box` with no `port`, the hub listens on 7417, `wtd ui --print-code` on `box` prints port 7417 and a code, and the browser sends `openHub{req: 4, host: 1}` +- **THEN** `config.json` gives `box` the `port` 7418, the browser receives `hubOpened{req: 4}` whose `url` starts `http://127.0.0.1:7418/#code=` and names `box` and home port 7417, and `GET /` and a WebSocket upgrade with that code through `127.0.0.1:7418` reach `box`'s hub + +#### Scenario: Login link while opening +- **WHEN** the SSH process for `openHub` writes `# To authenticate, visit: https://login.tailscale.com/a/abc123` to standard error, and is released 40 s later +- **THEN** the browser receives `hubLogin` with that URL, and then `hubOpened`, with no `hub-failed` in between + +#### Scenario: Too old remote +- **WHEN** `wtd ui --print-code` on the host exits with status 2, or the host has no `wtd` and the shell exits with status 127 +- **THEN** the browser receives `error` with code `hub-outdated` and the host's index, and no listener is bound + +#### Scenario: Unreachable host +- **WHEN** SSH writes `ssh: Could not resolve hostname box: Name or service not known` and exits with status 255 +- **THEN** the browser receives `error` with code `hub-failed` whose message is that line + +#### Scenario: Silent host times out +- **WHEN** the SSH process prints nothing for 30 s +- **THEN** it is killed and the browser receives `error` with code `hub-failed` + +#### Scenario: Oversized output refused +- **WHEN** `wtd ui --print-code` writes 5000 bytes to standard output +- **THEN** the SSH process is killed and the browser receives `error` with code `hub-failed` + +#### Scenario: Port held by another process +- **WHEN** host `box` has `port` 7418 and another process listens on 127.0.0.1:7418 +- **THEN** the browser receives `error` with code `hub-failed` naming port 7418 + +#### Scenario: Forward refused +- **WHEN** the SSH forward for the probe writes `channel 0: open failed: administratively prohibited` and exits +- **THEN** the browser receives `error` with code `hub-failed` whose message is that line + +#### Scenario: Newer open supersedes the previous one +- **WHEN** a session sends `openHub` for host 1, whose SSH process waits, and then `openHub` for host 2 +- **THEN** host 1's SSH process is killed, no listener for host 1 is bound, and the session receives replies only for host 2's request + +#### Scenario: Local host refused +- **WHEN** the browser sends `openHub` for host 0 +- **THEN** it receives `error` with code `unknown-host` + +### Requirement: Hub tunnels +A tunnel listener SHALL listen on `127.0.0.1` only. Each connection it accepts SHALL be relayed byte for byte, both ways, to `127.0.0.1:` on its host through one SSH process (`ssh -W`) started for that connection with the same options as a remote link; when either side ends, both SHALL end, the SSH process being terminated as a remote link's is. The hub SHALL buffer no more than the relay's stream buffers. At most one listener SHALL exist per port; binding, rebinding and closing listeners SHALL happen one at a time. Opening a host whose listener forwards to another remote port SHALL repoint it; a host whose `port` changed SHALL get a new listener on its new port and its old listener SHALL be closed with its forwards. A connection accepted while the listener's host, alias or port no longer matches the configuration the hub last read SHALL be closed at once without starting SSH. Listeners SHALL stay bound until the hub stops or their mapping changes. + +#### Scenario: Leaving the page ends its forwards +- **WHEN** a page served through a tunnel listener is closed +- **THEN** every SSH process started for its connections exits + +#### Scenario: Two sessions share one listener +- **WHEN** two sessions each open host `box` +- **THEN** each runs its own `wtd ui --print-code` and both URLs name the same port, served by one listener + +#### Scenario: Removed host's listener refuses connections +- **WHEN** host `box` was opened, is then removed from `config.json`, and a session starts +- **THEN** a connection to `box`'s port is closed without starting an SSH process + +### Requirement: Installing for a hub +`installHub{req, host}` for a remote host SHALL install the hub's own bundle on that host as `wtd install-remote` does, within 120 s, and reply `done`, never restarting or shutting down a daemon; a failure SHALL reply `error{code: internal}` naming the cause; a local host index SHALL reply `error{code: unknown-host}`. Installs SHALL be coordinated with the host's restarts and reinstalls, from any sessions, so that no two of them run at once: an install SHALL share a running or queued install or reinstall of that host; a restart or reinstall requested while an install runs or is queued SHALL wait until it ends; an install requested while a restart runs SHALL wait until the restart ends. + +#### Scenario: Install leaves the daemon running +- **WHEN** a remote host's daemon of another version runs a terminal and the browser sends `installHub` +- **THEN** the hub's bundle is installed there, the browser receives `done`, and the terminal keeps running in the same daemon instance + +#### Scenario: Install shares a running reinstall +- **WHEN** a `reinstallDaemon` for host 1 is running and another session sends `installHub` for host 1 +- **THEN** only one installation runs and both requests are answered when it ends + +#### Scenario: Restart waits for an install +- **WHEN** an `installHub` for host 1 is running and a `restartDaemon` for host 1 arrives +- **THEN** the restart starts only after the install ended diff --git a/openspec/specs/protocol/spec.md b/openspec/specs/protocol/spec.md index 83c1d45..e4259d4 100644 --- a/openspec/specs/protocol/spec.md +++ b/openspec/specs/protocol/spec.md @@ -6,7 +6,7 @@ The wire contract between the browser UI, the hub and every daemon: which messag ## Requirements ### Requirement: Protocol version and frozen handshake messages -The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 9, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 10, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. The print-code line and the page fragments, exchanged between `wtd` installations of any versions, SHALL likewise never change shape. #### Scenario: Hello round-trips - **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded @@ -28,6 +28,10 @@ The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal - **WHEN** `MAX_FRAME` changes and only one of the two versions is raised - **THEN** the wire golden check fails +#### Scenario: Frozen print-code and fragment shapes +- **WHEN** the print-code line or a page fragment schema accepts or rejects a sample differently from the frozen golden +- **THEN** the frozen golden check fails + ### Requirement: Strict control-message decoding A control message SHALL be one UTF-8 JSON object whose `t` names a message of the decoding direction and whose fields are exactly those of that message. Decoding SHALL reject, with a protocol error and without coercion or repair: invalid UTF-8, invalid JSON, a non-object, an unknown `t`, a message of the opposite direction, a missing field, an extra field, a value of the wrong type, and a value outside its bounds. Encoding SHALL reject any value that would not decode. @@ -89,9 +93,9 @@ A worktree SHALL be `{path, head, branch, detached, locked, prunable, bare, main - **THEN** decoding fails with a protocol error ### Requirement: Browser-link message catalogue -Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. -Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. -A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, login, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, `needs-login`, and `reason`, `daemonVersion`, `instance` and `login` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. `login` SHALL be a string of at most 1024 characters starting with `https://login.tailscale.com/`, non-null exactly when `status` is `needs-login`; decoding SHALL reject any other `login`. A preset SHALL be `{name, command}` with `command` nullable. +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `openHub{req, host}`; `installHub{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. +Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable and `code` a hub error code; `reposDiscovered{req, host, repos}`; `hubLogin{req, login}` with `login` a login URL as in a host entry; `hubOpened{req, url}`. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, login, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, `needs-login`, and `reason`, `daemonVersion`, `instance` and `login` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. `login` SHALL be a string of at most 1024 characters starting with `https://login.tailscale.com/`, non-null exactly when `status` is `needs-login`; decoding SHALL reject any other `login`. A preset SHALL be `{name, command}` with `command` nullable. A `hubOpened` `url` SHALL be `http://127.0.0.1:/#` with `` from 1024 to 65535 without leading zeros, followed by a remote page fragment; decoding SHALL reject any other `url`. #### Scenario: Envelope cannot carry a handshake message - **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded @@ -141,6 +145,18 @@ A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instan - **WHEN** a `hosts` entry with `status` "needs-login" and a null `login`, or with `status` "connecting" and a non-null `login`, is decoded - **THEN** decoding fails with a protocol error +#### Scenario: Hub opening messages only in their direction +- **WHEN** an `openHub` is decoded as a hub-to-browser message, or a `hubOpened` as a browser-to-hub message +- **THEN** decoding fails with a protocol error + +#### Scenario: Opened URL must be a loopback page +- **WHEN** a `hubOpened` whose `url` is "http://evil.example:7418/#code=…", "https://127.0.0.1:7418/#…", "http://127.0.0.1:80/#…", "http://127.0.0.1:7418/x#…" or "javascript:alert(1)" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Hub login URL outside Tailscale rejected +- **WHEN** a `hubLogin` whose `login` is "https://login.tailscale.com.evil/a/x" is decoded +- **THEN** decoding fails with a protocol error + ### Requirement: Request correlation Every message that asks a peer to act and can fail SHALL carry `req`, an integer from 1 to 2^32−1, and its outcome SHALL be correlated by that `req`: `termCreated` for `createTerm`, `reposDiscovered` for `discoverRepos`, `worktreeAtRisk` or `done` for `removeWorktree`, `done` for every other request, `error` for any failed request. A `termCreated` or `error` with `req` null SHALL correlate with no request. `resize`, `ack`, `setVisible`, input frames, `hello` and `shutdown` SHALL carry no `req`. @@ -161,7 +177,7 @@ Every message that asks a peer to act and can fail SHALL carry `req`, an integer - **THEN** decoding fails with a protocol error ### Requirement: Value limits -Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; discovery roots are absolute paths, `~`, or `~/` followed by at least one character, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages and host reasons are at most 1024 characters; error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. +Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; discovery roots are absolute paths, `~`, or `~/` followed by at least one character, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages and host reasons are at most 1024 characters; daemon error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`; hub error codes, used only by the hub-to-browser `error`, are the daemon error codes and `hub-outdated` and `hub-failed`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. #### Scenario: Relative path rejected - **WHEN** a `watchRepo` whose `repo` is "repo/a" is decoded @@ -195,6 +211,10 @@ Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters - **WHEN** a hub `error` whose `code` is "host-unavailable" is decoded - **THEN** decoding succeeds +#### Scenario: Hub error codes only from the hub +- **WHEN** a hub `error` whose `code` is "hub-outdated" or "hub-failed" is decoded, and a daemon `error` with either code is decoded +- **THEN** the hub error decodes, and the daemon error fails with a protocol error + #### Scenario: Malformed token rejected - **WHEN** a `token` message whose `token` has 63 hex digits, or contains an uppercase digit, is decoded - **THEN** decoding fails with a protocol error @@ -277,7 +297,7 @@ On a stream, an output or snapshot payload SHALL be a 4-byte terminal id, an 8-b - **THEN** decoding fails with a protocol error ### Requirement: Shared constants -The protocol SHALL export: `DAEMON_PROTOCOL_VERSION` = 6; `BROWSER_PROTOCOL_VERSION` = 8; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. +The protocol SHALL export: `DAEMON_PROTOCOL_VERSION` = 6; `BROWSER_PROTOCOL_VERSION` = 10; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. #### Scenario: Constants hold their values - **WHEN** the exported constants are read @@ -293,3 +313,33 @@ The protocol SHALL define the hub's one-time code response as the JSON object `{ #### Scenario: Extra field rejected - **WHEN** a code response carrying an additional field `token` is decoded - **THEN** decoding fails with a protocol error + +### Requirement: Print-code line +The protocol SHALL define the print-code line as the JSON object `{port, code}` — `port` an integer from 1024 to 65535, `code` a one-time code — followed by one newline, decoded strictly like a control message from text that SHALL hold nothing else but optional trailing whitespace. + +#### Scenario: Print-code line round-trips +- **WHEN** a print-code line with `port` 7417 and a valid code is encoded and decoded +- **THEN** the decoded value equals the original and the encoded text ends with exactly one newline + +#### Scenario: Extra field or text rejected +- **WHEN** a print-code line carrying an additional field `token`, or preceded by a line `motd`, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Port out of range rejected +- **WHEN** a print-code line with `port` 80 or 7417.5 is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Page fragments +The protocol SHALL define two page fragments, each the text after `#` in a page URL, written as `application/x-www-form-urlencoded` parameters with no parameter repeated and no other parameter. A home fragment SHALL hold `code` (a one-time code), `hub` (a host name of 1–64 characters from `[A-Za-z0-9._-]`), or both. A remote fragment SHALL hold all of `code`, `host` (a host name), `home` (an integer port from 1024 to 65535 without leading zeros) and `hubs` (1 to 63 distinct host names joined by `,`, one of which is `host`). Decoding SHALL reject any other text. + +#### Scenario: Home fragment forms +- **WHEN** `code=<64 hex>`, `hub=box` and `code=<64 hex>&hub=box` are decoded as home fragments +- **THEN** each decodes to the given values + +#### Scenario: Remote fragment decoded +- **WHEN** `code=<64 hex>&host=box&home=7417&hubs=box,gpu` is decoded as a remote fragment +- **THEN** it yields that code, host `box`, home 7417 and hubs `box` and `gpu` in that order + +#### Scenario: Malformed fragments rejected +- **WHEN** a home fragment `hub=a b`, `hub=box&hub=gpu` or `code=x`, or a remote fragment lacking `home`, with `home=0080`, with `hubs=gpu` while `host=box`, or with an extra parameter `token`, is decoded +- **THEN** decoding fails with a protocol error diff --git a/openspec/specs/web/spec.md b/openspec/specs/web/spec.md index 9cafeda..f6b0e40 100644 --- a/openspec/specs/web/spec.md +++ b/openspec/specs/web/spec.md @@ -6,7 +6,7 @@ The browser UI served by the hub: one tab per repo, a live worktree sidebar, and ## Requirements ### Requirement: Authentication -On load, a URL fragment `#code=` SHALL be used once to open the session, then removed from the address without a navigation; the token the hub returns SHALL be kept in session storage and used for every later connection. Without a code or stored token, or when the hub refuses the credential, the page SHALL show only a message telling the user to open worktree-term with `wtd ui`, and SHALL forget a refused stored token. +On load, the page SHALL read its URL fragment as a remote fragment, else as a home fragment, else as no fragment, and then remove it from the address without a navigation. A fragment's code SHALL be used once to open the session; the token the hub returns SHALL be kept in session storage and used for every later connection. A remote fragment's `host`, `home` and `hubs` SHALL be kept in session storage, replacing any kept before, and used after reloads; a page with them kept is a remote page, any other page a home page. Without a code or stored token, or when the hub refuses the credential, the page SHALL show only a message telling the user to open worktree-term with `wtd ui`, and SHALL forget a refused stored token. #### Scenario: Code exchanged for the token - **WHEN** the page is opened with a valid one-time code @@ -16,6 +16,14 @@ On load, a URL fragment `#code=` SHALL be used once to open the session, t - **WHEN** the page is opened without a code in a fresh browser session - **THEN** it shows the `wtd ui` message and opens no session +#### Scenario: Remote fragment kept across a reload +- **WHEN** the page is opened with `#code=&host=box&home=7417&hubs=box,gpu` and then reloaded +- **THEN** after the reload its title is `worktree-term — box` and its Hubs menu lists `box` and `gpu` + +#### Scenario: Malformed fragment ignored +- **WHEN** the page is opened with `#code=x&hub=a b` and a stored token +- **THEN** the fragment is removed, the page connects with the stored token, and no hub is opened + ### Requirement: Handshake and stale bundle The page SHALL send `hello` with its own browser protocol version and the package version it was built from, and SHALL compare both with the hub's `hello`. On a difference it SHALL reload once for that hub `instance`; if the difference remains after that reload it SHALL show a message saying the UI is outdated and to run `wtd ui`, without reloading again. @@ -537,3 +545,60 @@ The sidebar header SHALL hold, between its title and the checked filter's toggle #### Scenario: Refused storage write - **WHEN** the browser refuses to store the search and the user types text - **THEN** the list is narrowed by the text and the page shows that saving its settings failed + +### Requirement: Hubs menu +The page header SHALL show a "Hubs" menu when there is at least one remote host: on a home page the session's remote hosts, on a remote page its kept `hubs`. The menu SHALL list "This machine" followed by each such host name in order, marking the page's own hub — "This machine" on a home page, its `host` on a remote page. Picking the marked entry SHALL do nothing. On a home page, picking a host SHALL close the menu and open that host's hub as the hub opening requirement states. On a remote page, picking "This machine" SHALL navigate the tab to `http://127.0.0.1:/`, and picking another host SHALL navigate it to `http://127.0.0.1:/#hub=`. On a home page, a fragment with `hub` SHALL open that host's hub once the session greets; a name no remote host carries SHALL show the status `no host named `. + +#### Scenario: Menu lists hubs +- **WHEN** a home page's session lists remote hosts `box` and `gpu` +- **THEN** the Hubs menu lists "This machine", `box` and `gpu`, with "This machine" marked + +#### Scenario: No menu without remote hosts +- **WHEN** a home page's session lists only the local host +- **THEN** the header shows no Hubs menu + +#### Scenario: Back home from a remote page +- **WHEN** a remote page with `home` 7417, opened from a home page in the same tab, picks "This machine" +- **THEN** the tab shows the home page on `http://127.0.0.1:7417/`, connected with its stored token + +#### Scenario: Switching between remotes goes through home +- **WHEN** a remote page for `box` with `home` 7417 picks `gpu` +- **THEN** the tab navigates to `http://127.0.0.1:7417/#hub=gpu`, which opens `gpu`'s hub + +#### Scenario: Opening from the address +- **WHEN** a home page is opened with `#code=&hub=box` +- **THEN** it connects and opens `box`'s hub without any click + +#### Scenario: Unknown hub in the address +- **WHEN** a home page is opened with `#hub=nope` and no remote host is named `nope` +- **THEN** the status line shows `no host named nope` and the page stays + +### Requirement: Hub opening status +Opening a host's hub from a home page SHALL send `openHub` for that host and show a status line beside the Hubs menu for the latest opening only, ignoring any reply to an earlier one: `Opening …` while waiting; on `hubLogin`, ` needs Tailscale login` with an "Open login link" button opening the `login` URL in a new tab without giving that tab access to the page; on `hub-outdated`, `: ` with a "Reinstall on " button, which sends `installHub`, shows `Installing on …`, and on `done` opens the hub again; on any other error, `: ` with a × that clears the line; on `hubOpened`, navigation of the tab to its `url`. A remote page SHALL never send `openHub`. + +#### Scenario: Opening navigates the tab +- **WHEN** the user picks `box` in the Hubs menu and the hub replies `hubOpened` with `http://127.0.0.1:7418/#code=&host=box&home=7417&hubs=box` +- **THEN** the same tab shows `box`'s hub on `http://127.0.0.1:7418/`, titled `worktree-term — box`, listing `box`'s own repos + +#### Scenario: Login link while opening +- **WHEN** the hub replies `hubLogin` with `https://login.tailscale.com/a/abc123` and, after approval, `hubOpened` +- **THEN** the status line shows `box needs Tailscale login` with an "Open login link" button linking to that URL in a new tab with no opener, and then the tab navigates to the remote hub + +#### Scenario: Outdated remote reinstalled from the status line +- **WHEN** the hub replies `error` with code `hub-outdated` for `box` and the user clicks "Reinstall on box" +- **THEN** the page sends `installHub` for `box`, shows `Installing on box…`, and after `done` sends `openHub` for `box` again + +#### Scenario: Failure shown with its reason +- **WHEN** the hub replies `error` with code `hub-failed` and message `ssh: Could not resolve hostname box` +- **THEN** the status line shows `box: ssh: Could not resolve hostname box` with a ×, and the tab stays on the home page + +#### Scenario: Later pick wins +- **WHEN** the user picks `box`, then `gpu` before `box`'s reply arrives, and `box`'s `hubOpened` arrives first +- **THEN** the tab does not navigate to `box` and the status line shows `Opening gpu…` + +### Requirement: Page title +A home page's title SHALL be `worktree-term`; a remote page's title SHALL be `worktree-term — `. + +#### Scenario: Remote page titled by host +- **WHEN** a page is opened with a remote fragment whose `host` is `devbox` +- **THEN** its title is `worktree-term — devbox` diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index 229d70d..979cd72 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -1,7 +1,23 @@ import { readFileSync } from 'node:fs'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as hubMain from '../hub/main/index.js'; import { runCli } from './run.js'; +const PRINTED = '{"port":7417,"code":"' + 'a'.repeat(64) + '"}\n'; + +const ui = vi.hoisted(() => ({ openUi: vi.fn(), printCode: vi.fn() })); + +vi.mock('../hub/main/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + openUi: ui.openUi, + printCode: ui.printCode, +})); + +beforeEach(() => { + ui.openUi.mockReset().mockResolvedValue(undefined); + ui.printCode.mockReset().mockResolvedValue(PRINTED); +}); + interface Result { code: number; stdout: string; @@ -48,7 +64,7 @@ describe('wtd --version', () => { it('prints the package and both protocol versions', async () => { expect(await wtd('--version')).toEqual({ code: 0, - stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`, + stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`, stderr: '', }); }); @@ -64,6 +80,39 @@ describe('wtd --help', () => { } expect(stdout).toContain('install-local [--systemd]'); expect(stdout).toContain('install-remote [--node ]'); + expect(stdout).toContain('ui [] [--print-code]'); + }); +}); + +describe('wtd ui', () => { + it('opens the UI for this machine without a host', async () => { + expect(await wtd('ui')).toEqual({ code: 0, stdout: '', stderr: '' }); + expect(ui.openUi).toHaveBeenCalledOnce(); + expect(ui.openUi.mock.calls[0]?.[0]).toMatchObject({ host: null }); + expect(ui.printCode).not.toHaveBeenCalled(); + }); + + it('opens the UI switched to the named host', async () => { + expect(await wtd('ui', 'box')).toEqual({ code: 0, stdout: '', stderr: '' }); + expect(ui.openUi).toHaveBeenCalledOnce(); + expect(ui.openUi.mock.calls[0]?.[0]).toMatchObject({ host: 'box' }); + }); + + it('prints the code line with --print-code, opening no browser', async () => { + expect(await wtd('ui', '--print-code')).toEqual({ code: 0, stdout: PRINTED, stderr: '' }); + expect(ui.printCode).toHaveBeenCalledOnce(); + expect(ui.printCode.mock.calls[0]?.[0]).not.toHaveProperty('browser'); + expect(ui.openUi).not.toHaveBeenCalled(); + }); + + it('reports an unknown host on one line and exits 1', async () => { + ui.openUi.mockRejectedValue(new Error('no host named nope in /c/config.json')); + expect(await wtd('ui', 'nope')).toEqual({ code: 1, stdout: '', stderr: 'wtd ui: no host named nope in /c/config.json\n' }); + }); + + it('reports a failed print on one line and exits 1', async () => { + ui.printCode.mockRejectedValue(new Error('the hub did not start; see /s/hub.log')); + expect(await wtd('ui', '--print-code')).toEqual({ code: 1, stdout: '', stderr: 'wtd ui: the hub did not start; see /s/hub.log\n' }); }); }); @@ -84,8 +133,36 @@ describe('usage errors', () => { await expectUsageError(await wtd('--frob'), /--frob/); }); - it.each(['ui', 'hub'])('rejects an argument to %s', async (verb) => { - await expectUsageError(await wtd(verb, 'extra'), /extra/); + it('rejects an argument to hub', async () => { + await expectUsageError(await wtd('hub', 'extra'), /extra/); + }); + + it('rejects a second host for ui, naming it', async () => { + await expectUsageError(await wtd('ui', 'a', 'b'), /'b'/); + expect(ui.openUi).not.toHaveBeenCalled(); + }); + + it.each([ + ['ui', 'box', '--print-code'], + ['ui', '--print-code', 'box'], + ])('refuses a host combined with --print-code: %s %s %s', async (...argv) => { + await expectUsageError(await wtd(...argv), /cannot be combined/); + const result = await wtd(...argv); + expect(result.stderr).toContain('--print-code'); + expect(ui.openUi).not.toHaveBeenCalled(); + expect(ui.printCode).not.toHaveBeenCalled(); + }); + + it.each([['hub'], ['daemon'], ['connect'], ['install-local'], ['install-remote', 'box']])( + 'names --print-code as unknown for %s', + async (...argv) => { + await expectUsageError(await wtd(...argv, '--print-code'), /--print-code/); + }, + ); + + it('refuses --print-code with a value', async () => { + await expectUsageError(await wtd('ui', '--print-code=x'), /--print-code/); + expect(ui.printCode).not.toHaveBeenCalled(); }); it('requires an alias for install-remote', async () => { diff --git a/src/cli/run.ts b/src/cli/run.ts index dfcf609..9c32c5d 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -4,7 +4,7 @@ import type { Duplex } from 'node:stream'; import { parseArgs } from 'node:util'; import pkg from '../../package.json' with { type: 'json' }; import { AlreadyRunningError, runDaemon } from '../daemon/main/index.js'; -import { HubAlreadyRunningError, openUi, runHub } from '../hub/main/index.js'; +import { HubAlreadyRunningError, openUi, printCode, runHub } from '../hub/main/index.js'; import { dial } from '../platform/dialer/index.js'; import { currentHostPaths } from '../platform/files/index.js'; import { installLocal, installRemote } from '../platform/install/index.js'; @@ -18,7 +18,10 @@ export interface CliIo { const USAGE = `Usage: wtd [options] Commands: - ui Start the hub and open the UI in Chrome + ui [] [--print-code] + Start the hub and open the UI in Chrome; with , switched to + that remote host's hub; --print-code prints the hub's port and a + one-time code instead of opening a browser hub Run the hub in the foreground daemon Run this host's daemon in the foreground connect Bridge stdio to this host's daemon, starting it if needed @@ -38,23 +41,31 @@ const OPTIONS = { version: { type: 'boolean' }, systemd: { type: 'boolean' }, node: { type: 'string' }, + 'print-code': { type: 'boolean' }, } as const; /** Options a command was given. */ interface CommandOptions { systemd: boolean; node: string | null; + printCode: boolean; } interface Command { /** Positional arguments after the verb. */ args: readonly string[]; + /** Optional positional arguments after those. */ + optional?: readonly string[]; /** Options the command takes besides `--help` and `--version`. */ options: readonly (keyof CommandOptions)[]; /** Runs the command and resolves with its exit code. */ run: (io: CliIo, args: readonly string[], options: CommandOptions) => Promise; } +class UsageError extends Error { + override readonly name = 'UsageError'; +} + const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).replace(/\s+/g, (run) => (run.includes('\n') ? ' ' : run)); @@ -68,7 +79,14 @@ const wtdCommand = (): string[] => { const hub = async (io: CliIo): Promise => { try { const wtd = wtdCommand(); - await runHub({ version: pkg.version, paths: currentHostPaths(), wtd, webDir: join(dirname(wtd[1] ?? ''), 'web'), home: homedir() }); + await runHub({ + version: pkg.version, + paths: currentHostPaths(), + wtd, + webDir: join(dirname(wtd[1] ?? ''), 'web'), + home: homedir(), + env: process.env, + }); return 0; } catch (error) { io.stderr(`wtd hub: ${error instanceof HubAlreadyRunningError ? 'already running' : message(error)}\n`); @@ -76,10 +94,17 @@ const hub = async (io: CliIo): Promise => { } }; -const ui = async (io: CliIo): Promise => { +const ui = async (io: CliIo, [named]: readonly string[], { printCode: print }: CommandOptions): Promise => { + const host = named ?? null; + if (host !== null && print) throw new UsageError('ui: a host and --print-code cannot be combined'); try { + const run = { version: pkg.version, paths: currentHostPaths(), wtd: wtdCommand(), home: homedir() }; + if (print) { + io.stdout(await printCode(run)); + return 0; + } const browser = process.env['WTD_BROWSER'] ?? 'google-chrome'; - await openUi({ version: pkg.version, paths: currentHostPaths(), wtd: wtdCommand(), home: homedir(), browser }); + await openUi({ ...run, browser, host }); return 0; } catch (error) { io.stderr(`wtd ui: ${message(error)}\n`); @@ -166,7 +191,7 @@ const installRemoteCommand = async (io: CliIo, [alias = '']: readonly string[], }; const COMMANDS: ReadonlyMap = new Map([ - ['ui', { args: [], options: [], run: ui }], + ['ui', { args: [], optional: ['host'], options: ['printCode'], run: ui }], ['hub', { args: [], options: [], run: hub }], ['daemon', { args: [], options: [], run: daemon }], ['connect', { args: [], options: [], run: connect }], @@ -174,10 +199,6 @@ const COMMANDS: ReadonlyMap = new Map([ ['install-remote', { args: ['alias'], options: ['node'], run: installRemoteCommand }], ]); -class UsageError extends Error { - override readonly name = 'UsageError'; -} - interface Invocation { help: boolean; version: boolean; @@ -187,7 +208,8 @@ interface Invocation { given: { name: keyof CommandOptions; rawName: string }[]; } -const isCommandOption = (name: string): name is keyof CommandOptions => name === 'systemd' || name === 'node'; +/** The command option an option name sets, if it is one. */ +const COMMAND_OPTIONS: Readonly> = { systemd: 'systemd', node: 'node', 'print-code': 'printCode' }; /** Records one option in `invocation`; throws UsageError for a misused or unknown one. */ const applyOption = ( @@ -198,16 +220,23 @@ const applyOption = ( if (value === undefined) throw new UsageError(`option ${rawName} needs a value`); invocation.options.node = value; } else if (value !== undefined) throw new UsageError(`option ${rawName} takes no value`); + const option = Object.hasOwn(COMMAND_OPTIONS, name) ? COMMAND_OPTIONS[name] : undefined; if (name === 'help') invocation.help = true; else if (name === 'version') invocation.version = true; - else if (name === 'systemd') invocation.options.systemd = true; - else if (!isCommandOption(name)) throw new UsageError(`unknown option ${rawName}`); - if (isCommandOption(name)) invocation.given.push({ name, rawName }); + else if (option === 'systemd' || option === 'printCode') invocation.options[option] = true; + else if (option === undefined) throw new UsageError(`unknown option ${rawName}`); + if (option !== undefined) invocation.given.push({ name: option, rawName }); }; const parse = (argv: readonly string[]): Invocation => { const { tokens, positionals } = parseArgs({ args: [...argv], options: OPTIONS, allowPositionals: true, strict: false, tokens: true }); - const invocation: Invocation = { help: false, version: false, positionals, options: { systemd: false, node: null }, given: [] }; + const invocation: Invocation = { + help: false, + version: false, + positionals, + options: { systemd: false, node: null, printCode: false }, + given: [], + }; for (const token of tokens) if (token.kind === 'option') applyOption(invocation, token); return invocation; }; @@ -232,7 +261,7 @@ const dispatch = async (argv: readonly string[], io: CliIo): Promise => if (foreign !== undefined) throw new UsageError(`${verb} takes no option ${foreign.rawName}`); const missing = command.args[args.length]; if (missing !== undefined) throw new UsageError(`${verb}: ${missing} is required`); - const extra = args[command.args.length]; + const extra = args[command.args.length + (command.optional?.length ?? 0)]; if (extra !== undefined) throw new UsageError(`${verb}: unexpected argument '${extra}'`); return command.run(io, args, options); }; diff --git a/src/daemon/server/bind.test.ts b/src/daemon/server/bind.test.ts index 41972c3..ae27e30 100644 --- a/src/daemon/server/bind.test.ts +++ b/src/daemon/server/bind.test.ts @@ -14,9 +14,7 @@ interface Hooks { removals: { path: string; locked: boolean }[]; } -const hooks = vi.hoisted( - (): Hooks => ({ failRename: false, replaceAfterRename: false, lockHeld: false, lockPath: '', removals: [] }), -); +const hooks = vi.hoisted((): Hooks => ({ failRename: false, replaceAfterRename: false, lockHeld: false, lockPath: '', removals: [] })); vi.mock('../../platform/files/index.js', async (importOriginal) => { const real = await importOriginal(); diff --git a/src/hub/config/config.test.ts b/src/hub/config/config.test.ts index dae6785..80976e2 100644 --- a/src/hub/config/config.test.ts +++ b/src/hub/config/config.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ConfigError, ConfigSource, DEFAULT_PORT, loadConfig, parseConfig } from './index.js'; +import { ConfigError, ConfigSource, DEFAULT_PORT, loadConfig, nextHostPort, parseConfig } from './index.js'; const HOME = '/home/u'; @@ -197,8 +197,8 @@ describe('parseConfig', () => { ], }); expect(config.hosts).toEqual([ - { name: 'b', ssh: 'b.example.org', repos: ['/srv/app'], roots: ['~/src'] }, - { name: 'a.1_x-y', ssh: 'user@a', repos: [], roots: ['~'] }, + { name: 'b', ssh: 'b.example.org', port: null, repos: ['/srv/app'], roots: ['~/src'] }, + { name: 'a.1_x-y', ssh: 'user@a', port: null, repos: [], roots: ['~'] }, ]); }); @@ -267,6 +267,50 @@ describe('parseConfig', () => { expect(problem(JSON.stringify({ hosts: [host] }))).toContain(key); }); + describe('host ports', () => { + const withPorts = (...ports: unknown[]): unknown => ({ + hosts: ports.map((port, i) => ({ name: `h${String(i)}`, ssh: `h${String(i)}`, port })), + }); + + it.each([1024, 7418, 65535])('accepts port %i', (port) => { + expect(parse(withPorts(port)).hosts[0]?.port).toBe(port); + }); + + it('gives a host without a port the port null', () => { + expect(parse({ hosts: [{ name: 'box', ssh: 'box' }] }).hosts[0]?.port).toBeNull(); + }); + + it.each([ + ['1023', 1023], + ['65536', 65536], + ['a fraction', 7418.5], + ['a string', '7418'], + ['null', null], + ])('refuses %s, naming port', (_name, port) => { + expect(problem(JSON.stringify(withPorts(port)))).toContain('port'); + }); + + it('refuses two hosts with the same port, naming port', () => { + expect(problem(JSON.stringify(withPorts(7420, 7420)))).toContain('port'); + }); + + it('refuses a host port equal to the hub port, naming port', () => { + expect(problem(JSON.stringify({ port: 9000, hosts: [{ name: 'box', ssh: 'box', port: 9000 }] }))).toContain('port'); + }); + + it('refuses a host port equal to the default hub port when the hub port is absent, naming port', () => { + expect(problem(JSON.stringify(withPorts(DEFAULT_PORT)))).toContain('port'); + }); + + it('accepts a host port of 7417 when the hub listens elsewhere', () => { + expect(parse({ port: 9000, hosts: [{ name: 'box', ssh: 'box', port: 7417 }] }).hosts[0]?.port).toBe(7417); + }); + + it('still refuses an unknown key in a host with a port, naming it', () => { + expect(problem(JSON.stringify({ hosts: [{ name: 'box', ssh: 'box', port: 7418, user: 'u' }] }))).toContain('user'); + }); + }); + it('refuses hosts that are not a list', () => { expect(problem(JSON.stringify({ hosts: { name: 'box', ssh: 'box' } }))).toContain('hosts'); }); @@ -276,6 +320,24 @@ describe('parseConfig', () => { }); }); +describe('nextHostPort', () => { + it.each([ + ['one above the hub port without host ports', 7417, [], 7418], + ['one above the highest held port', 7417, [7418, 7425], 7426], + ['above a higher port held while a lower one was freed', 7417, [7419], 7420], + ['above the hub port when it is the highest', 9000, [7418], 9001], + ['the lowest free port from 1024 when above 65535', 65535, [1024], 1025], + ['the lowest free port, skipping the hub port', 1024, [65535], 1025], + ])('gives %s', (_name, hub, held, port) => { + expect(nextHostPort(hub, held)).toBe(port); + }); + + it('gives null when every port is taken', () => { + const held = Array.from({ length: 65535 - 1024 }, (_, i) => 1024 + i); + expect(nextHostPort(65535, held)).toBeNull(); + }); +}); + describe('loadConfig', () => { it('uses the defaults for a missing file without creating it or its directory', async () => { const path = join(dir, 'worktree-term', 'config.json'); @@ -422,6 +484,15 @@ describe('README', () => { expect(readme).toContain(text); }); + it('gives a configuration example with a host port', () => { + const configs = jsonBlocks(section('Configuration')).map((block) => parseConfig(block, HOME)); + expect(configs.some((config) => config.hosts.some((host) => host.port !== null))).toBe(true); + }); + + it.each(['wtd ui ', 'wtd ui --print-code', 'AllowTcpForwarding'])('documents %s', (text) => { + expect(readme).toContain(text); + }); + it('tells Claude Code to ring the bell', () => { const [settings] = jsonBlocks(section('Attention marks')); expect(JSON.parse(settings ?? '')).toEqual({ preferredNotifChannel: 'terminal_bell' }); diff --git a/src/hub/config/config.ts b/src/hub/config/config.ts index 0264488..eb22958 100644 --- a/src/hub/config/config.ts +++ b/src/hub/config/config.ts @@ -22,6 +22,8 @@ export interface RemoteHostConfig { name: string; /** The SSH alias. */ ssh: string; + /** The local port its hub is forwarded to; null until the hub assigns one. */ + port: number | null; repos: string[]; roots: string[]; } @@ -57,29 +59,43 @@ const roots = z.array(discoveryRootSchema).min(1).max(MAX_ROOTS); // eslint-disable-next-line no-control-regex -- control characters are what aliases must not contain const SSH_ALIAS = /^[^-\s\u0000-\u0008\u000e-\u001f\u007f][^\s\u0000-\u0008\u000e-\u001f\u007f]*$/; +const port = z.int().min(1024).max(65535); + const remoteHost = z.strictObject({ name: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, 'must be 1–64 characters from A–Z, a–z, 0–9, ., _ and -'), ssh: z.string().max(255).regex(SSH_ALIAS, 'must not be empty, start with - or contain whitespace or control characters'), + port: port.optional(), repos: z.array(absolutePath).max(MAX_REPOS).optional(), roots: roots.optional(), }); -const configSchema = z.strictObject({ - port: z.int().min(1024).max(65535).optional(), - repos: z.array(repoPath).max(MAX_REPOS).optional(), - roots: roots.optional(), - hosts: z - .array(remoteHost) - .max(MAX_REMOTE_HOSTS) - .refine((list) => new Set(list.map((h) => h.name)).size === list.length, 'host names must be unique') - .optional(), - presets: z - .array(presetSchema) - .min(1) - .max(MAX_PRESETS) - .refine((list) => new Set(list.map((p) => p.name)).size === list.length, 'preset names must be unique') - .optional(), -}); +const configSchema = z + .strictObject({ + port: port.optional(), + repos: z.array(repoPath).max(MAX_REPOS).optional(), + roots: roots.optional(), + hosts: z + .array(remoteHost) + .max(MAX_REMOTE_HOSTS) + .refine((list) => new Set(list.map((h) => h.name)).size === list.length, 'host names must be unique') + .optional(), + presets: z + .array(presetSchema) + .min(1) + .max(MAX_PRESETS) + .refine((list) => new Set(list.map((p) => p.name)).size === list.length, 'preset names must be unique') + .optional(), + }) + .superRefine((config, ctx) => { + const taken = new Set([config.port ?? DEFAULT_PORT]); + for (const [i, host] of (config.hosts ?? []).entries()) { + if (host.port === undefined) continue; + if (taken.has(host.port)) { + ctx.addIssue({ code: 'custom', path: ['hosts', i, 'port'], message: 'must differ from the hub port and every other host port' }); + } + taken.add(host.port); + } + }); const oneLine = (text: string): string => text.replace(/\s+/g, (run) => (run.includes('\n') ? ' ' : run)); @@ -115,10 +131,28 @@ export const parseConfig = (text: string | null, home: string): HubConfig => { repos: (result.data.repos ?? []).map((repo) => expand(repo, home)), roots: result.data.roots ?? [...DEFAULT_ROOTS], presets: result.data.presets ?? [...DEFAULT_PRESETS], - hosts: (result.data.hosts ?? []).map((h) => ({ name: h.name, ssh: h.ssh, repos: h.repos ?? [], roots: h.roots ?? [...DEFAULT_ROOTS] })), + hosts: (result.data.hosts ?? []).map((h) => ({ + name: h.name, + ssh: h.ssh, + port: h.port ?? null, + repos: h.repos ?? [], + roots: h.roots ?? [...DEFAULT_ROOTS], + })), }; }; +/** + * The port for a host without one: above the hub's and every held port, else the lowest free one from 1024; + * null when none is free. + */ +export const nextHostPort = (hubPort: number, held: readonly number[]): number | null => { + const taken = new Set([hubPort, ...held]); + const above = Math.max(...taken) + 1; + if (above <= 65535) return above; + for (let candidate = 1024; candidate <= 65535; candidate++) if (!taken.has(candidate)) return candidate; + return null; +}; + /** Reads and parses the configuration at `path`; throws ConfigError naming the file and the problem. */ export const loadConfig = async (path: string, home: string): Promise => { try { diff --git a/src/hub/config/editor.test.ts b/src/hub/config/editor.test.ts index a6b2ee0..2bf3d65 100644 --- a/src/hub/config/editor.test.ts +++ b/src/hub/config/editor.test.ts @@ -300,3 +300,141 @@ describe('ConfigEditor lists', () => { await expect(edits.lists('box', '/r/a')).rejects.toThrow('box'); }); }); + +describe('ConfigEditor assigning host ports', () => { + const HOSTS = { + roots: ['~/src'], + presets: [{ name: 'claude', command: 'claude' }], + hosts: [ + { name: 'box', ssh: 'box.example.org', repos: ['/srv/a'] }, + { name: 'gpu', ssh: 'gpu' }, + ], + }; + + it('gives the first host port one above the default hub port, keeping every other value as written', async () => { + write(HOSTS); + expect(await editor().assignPort('box')).toEqual({ changed: true, port: 7418 }); + const expected = { ...HOSTS, hosts: [{ ...HOSTS.hosts[0], port: 7418 }, HOSTS.hosts[1]] }; + expect(read()).toEqual(expected); + expect(text().trimEnd()).toBe(JSON.stringify(expected, null, 2)); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('gives one above the highest of the hub port and the held host ports', async () => { + write({ + port: 7417, + hosts: [ + { name: 'a', ssh: 'a', port: 7418 }, + { name: 'b', ssh: 'b', port: 7425 }, + { name: 'new', ssh: 'new' }, + ], + }); + expect(await editor().assignPort('new')).toEqual({ changed: true, port: 7426 }); + }); + + it('does not reuse a removed host’s lower port while a higher one is held', async () => { + write({ + hosts: [ + { name: 'b', ssh: 'b', port: 7419 }, + { name: 'c', ssh: 'c' }, + ], + }); + expect(await editor().assignPort('c')).toEqual({ changed: true, port: 7420 }); + }); + + it('wraps around to the lowest free port from 1024', async () => { + write({ + port: 65535, + hosts: [ + { name: 'a', ssh: 'a', port: 1024 }, + { name: 'b', ssh: 'b' }, + ], + }); + expect(await editor().assignPort('b')).toEqual({ changed: true, port: 1025 }); + }); + + it('leaves a present port and the file untouched', async () => { + write({ hosts: [{ name: 'box', ssh: 'box', port: 9000 }] }); + const before = text(); + expect(await editor().assignPort('box')).toEqual({ changed: false, port: 9000 }); + expect(text()).toBe(before); + }); + + it('refuses a host the file does not list, naming it, leaving the file unchanged', async () => { + write(HOSTS); + const before = text(); + const edits = editor(); + await expect(edits.assignPort('nope')).rejects.toThrow(ConfigError); + await expect(edits.assignPort('nope')).rejects.toThrow('nope'); + expect(text()).toBe(before); + }); + + it('refuses an absent file, which lists no host', async () => { + const edits = editor(); + await expect(edits.assignPort('box')).rejects.toThrow(ConfigError); + expect(existsSync(path)).toBe(false); + }); + + it('refuses an invalid file, leaving it unchanged', async () => { + write({ theme: 'dark', hosts: [{ name: 'box', ssh: 'box' }] }); + const before = text(); + const edits = editor(); + await expect(edits.assignPort('box')).rejects.toThrow('theme'); + expect(text()).toBe(before); + }); + + it('starts over when another writer gave a host a port meanwhile, recomputing the result', async () => { + write({ + hosts: [ + { name: 'x', ssh: 'x', port: 7420 }, + { name: 'a', ssh: 'a' }, + { name: 'b', ssh: 'b' }, + ], + }); + const attempts: number[] = []; + const result = await editor({ + beforeReplace: (attempt) => { + attempts.push(attempt); + if (attempt === 1) { + writeFileSync( + path, + JSON.stringify({ + hosts: [ + { name: 'x', ssh: 'x', port: 7420 }, + { name: 'a', ssh: 'a' }, + { name: 'b', ssh: 'b', port: 7421 }, + ], + }), + ); + } + }, + }).assignPort('a'); + expect(attempts).toEqual([1, 2]); + expect(result).toEqual({ changed: true, port: 7422 }); + expect(read()).toEqual({ + hosts: [ + { name: 'x', ssh: 'x', port: 7420 }, + { name: 'a', ssh: 'a', port: 7422 }, + { name: 'b', ssh: 'b', port: 7421 }, + ], + }); + }); + + it('applies concurrent assignments and repo edits one at a time, losing none', async () => { + write({ + hosts: [ + { name: 'a', ssh: 'a' }, + { name: 'b', ssh: 'b' }, + ], + }); + const shared = editor(); + const [a, , b] = await Promise.all([shared.assignPort('a'), shared.addRepo('a', '/srv/x'), shared.assignPort('b')]); + expect([a.port, b.port]).toEqual([7418, 7419]); + expect(read()).toEqual({ + hosts: [ + { name: 'a', ssh: 'a', port: 7418, repos: ['/srv/x'] }, + { name: 'b', ssh: 'b', port: 7419 }, + ], + }); + }); +}); diff --git a/src/hub/config/editor.ts b/src/hub/config/editor.ts index f55b292..abbff63 100644 --- a/src/hub/config/editor.ts +++ b/src/hub/config/editor.ts @@ -1,7 +1,7 @@ import { dirname } from 'node:path'; import { fileIdentity, makePrivateDirs, readFileIfExists, writeFileAtomic } from '../../platform/files/index.js'; import type { Preset } from '../../protocol/index.js'; -import { ConfigError, expandHome, MAX_PRESETS, MAX_REPOS, parseConfig, type HubConfig } from './config.js'; +import { ConfigError, expandHome, MAX_PRESETS, MAX_REPOS, nextHostPort, parseConfig, type HubConfig } from './config.js'; const ATTEMPTS = 3; @@ -24,22 +24,28 @@ export interface EditorHooks { type JsonObject = Record; -/** A change to one array in the file. */ +/** The outcome of a port assignment: whether the file changed, and the host's port afterwards. */ +export interface PortAssignment { + changed: boolean; + port: number; +} + +/** A change to one value in the file. */ interface FieldEdit { - /** The object holding the array, and its key there. */ + /** The object holding the value, and its key there. */ owner: (raw: JsonObject) => JsonObject; key: string; - /** The array's current value, from the parsed configuration or the object as written. */ - current: (config: HubConfig, owner: JsonObject) => T[]; + /** The current value, from the parsed configuration or the object as written. */ + current: (config: HubConfig, owner: JsonObject) => T; /** The new value, null to leave the file alone; throws ConfigError to refuse the edit. */ - change: (current: T[]) => T[] | null; + change: (current: T, config: HubConfig) => T | null; } const isObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); const stringsOf = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []); -/** Edits the repos of one host, or the presets, in `config.json`, keeping everything else as written; one edit at a time. */ +/** Edits the repos or port of one host, or the presets, in `config.json`, keeping everything else as written; one edit at a time. */ export class ConfigEditor { private queue: Promise = Promise.resolve(); @@ -106,6 +112,26 @@ export class ConfigEditor { }); } + /** Gives the named remote host a port unless it has one; refuses when no port is free. */ + async assignPort(host: string): Promise { + const { changed, value } = await this.edit({ + owner: (raw) => this.ownerOf(raw, host), + key: 'port', + current: (config) => config.hosts.find((h) => h.name === host)?.port ?? null, + change: (current, config) => { + if (current !== null) return null; + const port = nextHostPort( + config.port, + config.hosts.flatMap((h) => (h.port === null ? [] : [h.port])), + ); + if (port === null) throw new ConfigError(`there is no free port for host ${host}`); + return port; + }, + }); + if (value === null) throw new ConfigError(`host ${host} has no port`); + return { changed, port: value }; + } + private names(host: string | null, entry: string, repo: string): boolean { return entry === repo || (host === null && expandHome(entry, this.home) === repo); } @@ -125,13 +151,13 @@ export class ConfigEditor { return { changed, presets: value }; } - private edit(field: FieldEdit): Promise<{ changed: boolean; value: T[] }> { + private edit(field: FieldEdit): Promise<{ changed: boolean; value: T }> { const run = this.queue.then(() => this.attempt(field, 1)); this.queue = run.catch(() => undefined); return run; } - private async attempt(field: FieldEdit, attempt: number): Promise<{ changed: boolean; value: T[] }> { + private async attempt(field: FieldEdit, attempt: number): Promise<{ changed: boolean; value: T }> { const identity = await fileIdentity(this.path); const text = await readFileIfExists(this.path); const config = parseConfig(text, this.home); @@ -139,7 +165,7 @@ export class ConfigEditor { if (!isObject(raw)) throw new ConfigError('the configuration is not an object'); const owner = field.owner(raw); const current = field.current(config, owner); - const next = field.change(current); + const next = field.change(current, config); if (next === null) return { changed: false, value: current }; owner[field.key] = next; await this.hooks.beforeReplace?.(attempt); diff --git a/src/hub/config/index.ts b/src/hub/config/index.ts index 9d3b375..ec920e5 100644 --- a/src/hub/config/index.ts +++ b/src/hub/config/index.ts @@ -4,9 +4,10 @@ export { DEFAULT_PORT, expandHome, loadConfig, + nextHostPort, parseConfig, type ConfigSnapshot, type HubConfig, type RemoteHostConfig, } from './config.js'; -export { ConfigEditor, type EditorHooks, type PresetEdit, type RepoEdit } from './editor.js'; +export { ConfigEditor, type EditorHooks, type PortAssignment, type PresetEdit, type RepoEdit } from './editor.js'; diff --git a/src/hub/links/hub.test.ts b/src/hub/links/hub.test.ts new file mode 100644 index 0000000..5d072ca --- /dev/null +++ b/src/hub/links/hub.test.ts @@ -0,0 +1,288 @@ +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, connect, type Server, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hostPaths } from '../../platform/files/index.js'; +import { forwardToHub, HubOpener, PRINT_CODE_COMMAND, type HubOpenResult } from './index.js'; + +const CODE = '0123456789abcdef'.repeat(4); +const LINE = `{"port":7417,"code":"${CODE}"}`; +const LOGIN = 'https://login.tailscale.com/a/abc123'; +const BANNER = `# To authenticate, visit: ${LOGIN}`; + +let dir: string; +let paths: ReturnType; +let servers: Server[]; +let sockets: Socket[]; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-hub-link-'))); + paths = hostPaths({ env: { XDG_STATE_HOME: join(dir, 's') }, home: '/nonexistent', host: 'local' }); + servers = []; + sockets = []; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const socket of sockets) socket.destroy(); + for (const server of servers) server.close(); + for (const pid of pids()) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone. + } + } + rmSync(dir, { recursive: true, force: true }); +}); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Installs `body` as the SSH program; `$D` is the test directory, `$@` the SSH arguments. */ +const fakeSsh = (body: string): void => { + const program = join(dir, 'ssh'); + writeFileSync(program, `#!/bin/sh\nD='${dir}'\nfor a in "$@"; do printf '%s\\0' "$a"; done > "$D/args"\necho $$ >> "$D/pids"\n${body}\n`); + chmodSync(program, 0o755); + vi.stubEnv('WTD_SSH', program); +}; + +const fileText = (name: string): string => (existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : ''); + +const args = (): string[] => fileText('args').split('\0').slice(0, -1); + +const pids = (): number[] => fileText('pids').split('\n').filter(Boolean).map(Number); + +const alive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const waitFor = async (probe: () => boolean, what: string, timeout = 5000): Promise => { + const deadline = Date.now() + timeout; + while (!probe()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(20); + } +}; + +const SSH_OPTIONS = (): string[] => [ + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + '-o', + 'ControlMaster=auto', + '-o', + 'ControlPersist=10m', + '-o', + `ControlPath=${paths.runDir}/ssh-%C`, + '-o', + 'ServerAliveInterval=15', +]; + +interface Opening { + logins: string[]; + results: HubOpenResult[]; + done: Promise; + cancel: () => void; +} + +const open = (options?: { timeoutMs?: number }, alias = 'box'): Opening => { + const logins: string[] = []; + const results: HubOpenResult[] = []; + let resolveDone: (result: HubOpenResult) => void = () => undefined; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const opening = new HubOpener(paths, alias, options).open({ + login: (url) => logins.push(url), + done: (result) => { + results.push(result); + resolveDone(result); + }, + }); + return { logins, results, done, cancel: opening.cancel }; +}; + +describe('HubOpener', () => { + it('runs the print-code command over SSH with the remote link options', async () => { + fakeSsh(`printf '%s\\n' '${LINE}'`); + await open().done; + expect(args()).toEqual([...SSH_OPTIONS(), '--', 'box', PRINT_CODE_COMMAND]); + expect(PRINT_CODE_COMMAND).toBe('"$HOME/.local/bin/wtd" ui --print-code'); + }); + + it('opens with the printed port and code', async () => { + fakeSsh(`printf '%s\\n' '${LINE}'`); + expect(await open().done).toEqual({ kind: 'opened', port: 7417, code: CODE }); + }); + + it('reports each login URL once, without a time limit after the first', async () => { + fakeSsh( + `printf '%s\\n' '${BANNER}' '${BANNER}' >&2; sleep 0.6; printf '%s\\n' '# To authenticate, visit: https://login.tailscale.com/a/other' >&2; printf '%s\\n' '${LINE}'`, + ); + const opening = open({ timeoutMs: 300 }); + expect(await opening.done).toEqual({ kind: 'opened', port: 7417, code: CODE }); + expect(opening.logins).toEqual([LOGIN, 'https://login.tailscale.com/a/other']); + }); + + it('reports the login URL while SSH still waits', async () => { + fakeSsh(`printf '%s\\n' '${BANNER}' >&2; exec sleep 600`); + const opening = open(); + await waitFor(() => opening.logins.length === 1, 'the login URL'); + expect(opening.results).toEqual([]); + opening.cancel(); + }); + + it('fails a silent run after its time limit, killing it', async () => { + fakeSsh('exec sleep 600'); + const started = Date.now(); + const result = await open({ timeoutMs: 300 }).done; + expect(result.kind).toBe('failed'); + expect(result.kind === 'failed' ? result.message : '').toMatch(/did not answer within/); + expect(Date.now() - started).toBeGreaterThanOrEqual(300); + await waitFor(() => pids().every((pid) => !alive(pid)), 'the SSH process to be killed'); + }); + + it('kills a silent run that ignores SIGTERM', async () => { + fakeSsh("trap '' TERM; exec sleep 600"); + const result = await open({ timeoutMs: 300 }).done; + expect(result.kind).toBe('failed'); + await waitFor(() => pids().every((pid) => !alive(pid)), 'the SSH process to be killed', 10_000); + }); + + it('fails and kills a run writing more than 4096 bytes to standard output', async () => { + fakeSsh('head -c 5000 /dev/zero | tr "\\000" x; exec sleep 600'); + const result = await open().done; + expect(result.kind).toBe('failed'); + expect(result.kind === 'failed' ? result.message : '').toMatch(/4096 bytes/); + await waitFor(() => pids().every((pid) => !alive(pid)), 'the SSH process to be killed'); + }); + + it.each([ + ['exits with status 2', "echo 'wtd: unknown option --print-code' >&2; exit 2"], + ['exits with status 127', "echo 'sh: 1: /home/u/.local/bin/wtd: not found' >&2; exit 127"], + ['prints something else than a print-code line', "echo 'Usage: wtd '"], + ['prints a second line', `printf '%s\\n' '${LINE}' '${LINE}'`], + ['prints nothing', 'exit 0'], + ])('reports wtd missing or outdated when it %s', async (_name, body) => { + fakeSsh(body); + const result = await open().done; + expect(result.kind).toBe('outdated'); + expect(result.kind === 'outdated' ? result.message : '').toMatch(/missing or too old/); + }); + + it('fails with SSH’s last standard error line', async () => { + fakeSsh("echo 'debug noise' >&2; echo 'ssh: Could not resolve hostname box: Name or service not known' >&2; exit 255"); + expect(await open().done).toEqual({ kind: 'failed', message: 'ssh: Could not resolve hostname box: Name or service not known' }); + }); + + it('fails naming the exit status without standard error', async () => { + fakeSsh('exit 1'); + const result = await open().done; + expect(result.kind).toBe('failed'); + expect(result.kind === 'failed' ? result.message : '').toMatch(/status 1/); + }); + + it('kills the run on cancel and reports nothing afterwards', async () => { + fakeSsh(`sleep 0.3; printf '%s\\n' '${BANNER}' >&2; printf '%s\\n' '${LINE}'; exec sleep 600`); + const opening = open(); + await waitFor(() => pids().length === 1, 'the SSH process to start'); + opening.cancel(); + await waitFor(() => pids().every((pid) => !alive(pid)), 'the SSH process to be killed'); + await sleep(600); + expect(opening.logins).toEqual([]); + expect(opening.results).toEqual([]); + }); +}); + +/** A connected socket pair over a local TCP server: the hub's side and the browser's side. */ +const socketPair = async (): Promise<{ hub: Socket; browser: Socket }> => { + let accepted: (socket: Socket) => void = () => undefined; + const hubSide = new Promise((resolve) => { + accepted = resolve; + }); + const server = createServer((socket) => { + accepted(socket); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('no port'); + const browser = connect(address.port, '127.0.0.1'); + const hub = await hubSide; + sockets.push(hub, browser); + return { hub, browser }; +}; + +const received = (socket: Socket): (() => string) => { + let text = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + text += chunk; + }); + return () => text; +}; + +const closed = (socket: Socket): Promise => + new Promise((resolve) => { + if (socket.destroyed) resolve(); + else + socket.once('close', () => { + resolve(); + }); + }); + +describe('forwardToHub', () => { + it('runs ssh -W to the remote hub port over the shared master', async () => { + fakeSsh('exec cat'); + const { hub } = await socketPair(); + forwardToHub(paths, 'box', 7420, hub); + await waitFor(() => fileText('args') !== '', 'the SSH program to run'); + expect(args()).toEqual([...SSH_OPTIONS(), '-W', '127.0.0.1:7420', '--', 'box']); + }); + + it('relays bytes both ways', async () => { + fakeSsh('exec cat'); + const { hub, browser } = await socketPair(); + const text = received(browser); + forwardToHub(paths, 'box', 7420, hub); + browser.write('GET / HTTP/1.1\r\n\r\n'); + await waitFor(() => text() === 'GET / HTTP/1.1\r\n\r\n', 'the echoed request'); + expect(text()).toBe('GET / HTTP/1.1\r\n\r\n'); + }); + + it('ends the SSH process when the browser side ends', async () => { + fakeSsh("trap 'exit 0' TERM; cat; exit 0"); + const { hub, browser } = await socketPair(); + const forward = forwardToHub(paths, 'box', 7420, hub); + await waitFor(() => pids().length === 1, 'the SSH process to start'); + browser.end(); + await waitFor(() => pids().every((pid) => !alive(pid)), 'the SSH process to exit'); + await expect(forward.ended).resolves.toBeNull(); + }); + + it('kills an SSH process that ignores the end of its input', async () => { + fakeSsh("trap '' TERM; exec sleep 600 < /dev/null"); + const { hub, browser } = await socketPair(); + const forward = forwardToHub(paths, 'box', 7420, hub); + await waitFor(() => pids().length === 1, 'the SSH process to start'); + browser.destroy(); + await waitFor(() => pids().every((pid) => !alive(pid)), 'the SSH process to be killed', 10_000); + await expect(forward.ended).resolves.toBeNull(); + }); + + it('ends the browser side when SSH exits, with its last standard error line', async () => { + fakeSsh("echo 'channel 0: open failed: administratively prohibited' >&2; exit 255"); + const { hub, browser } = await socketPair(); + const forward = forwardToHub(paths, 'box', 7420, hub); + await expect(forward.ended).resolves.toBe('channel 0: open failed: administratively prohibited'); + await closed(browser); + expect(browser.destroyed || browser.readableEnded).toBe(true); + }); +}); diff --git a/src/hub/links/hub.ts b/src/hub/links/hub.ts new file mode 100644 index 0000000..6c73ca4 --- /dev/null +++ b/src/hub/links/hub.ts @@ -0,0 +1,219 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import type { Socket } from 'node:net'; +import type { Readable, Writable } from 'node:stream'; +import { LineScanner, sshCommand, sshForwardCommand, StderrTail, stopProcess, tailscaleLoginUrl } from '../../platform/dialer/index.js'; +import { decodePrintCode, ProtocolError } from '../../protocol/index.js'; + +/** The remote command that makes the host's hub serve and prints its port and a one-time code. */ +export const PRINT_CODE_COMMAND = '"$HOME/.local/bin/wtd" ui --print-code'; + +const OPEN_TIMEOUT_MS = 30_000; +const MAX_OUTPUT = 4096; +const KILL_GRACE_MS = 1000; +/** Exit statuses of a `wtd` without `--print-code` (usage error) or without `wtd` (command not found). */ +const OUTDATED_STATUSES: ReadonlySet = new Set([2, 127]); + +export type HubOpenResult = + { kind: 'opened'; port: number; code: string } | { kind: 'outdated'; message: string } | { kind: 'failed'; message: string }; + +export interface HubOpenEvents { + /** SSH waits for its Tailscale check to be approved at `url`. Once per URL. */ + login: (url: string) => void; + /** Called once, unless cancelled. */ + done: (result: HubOpenResult) => void; +} + +export interface HubOpening { + /** Kills the SSH process; no event follows. */ + cancel: () => void; +} + +type SshProcess = ChildProcessByStdio; + +const seconds = (ms: number): string => String(ms / 1000); + +/** Runs `wtd ui --print-code` on a remote host over SSH. */ +export class HubOpener { + private readonly timeoutMs: number; + + constructor( + private readonly paths: Parameters[0], + readonly alias: string, + options: { timeoutMs?: number } = {}, + ) { + this.timeoutMs = options.timeoutMs ?? OPEN_TIMEOUT_MS; + } + + open(events: HubOpenEvents): HubOpening { + let ended = false; + const end = (result: HubOpenResult): void => { + if (ended) return; + ended = true; + clearTimeout(timer); + if (child !== null) stopProcess(child, KILL_GRACE_MS); + events.done(result); + }; + let child: SshProcess | null = null; + let timer: ReturnType | undefined = setTimeout(() => { + end({ kind: 'failed', message: `${this.alias} did not answer within ${seconds(this.timeoutMs)} s` }); + }, this.timeoutMs); + try { + const [program = 'ssh', ...args] = sshCommand(this.paths, this.alias, PRINT_CODE_COMMAND); + child = spawn(program, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + this.follow(child, program, events, end, () => { + clearTimeout(timer); + timer = undefined; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setImmediate(() => { + end({ kind: 'failed', message }); + }); + } + return { + cancel: () => { + if (ended) return; + ended = true; + clearTimeout(timer); + if (child !== null) stopProcess(child, KILL_GRACE_MS); + }, + }; + } + + private follow( + child: SshProcess, + program: string, + events: HubOpenEvents, + end: (result: HubOpenResult) => void, + loggingIn: () => void, + ): void { + const tail = new StderrTail(); + const lines = new LineScanner(); + const logins = new Set(); + const output: Buffer[] = []; + let size = 0; + child.stdout.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_OUTPUT) { + end({ kind: 'failed', message: `${this.alias} printed more than ${String(MAX_OUTPUT)} bytes` }); + return; + } + output.push(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + for (const line of lines.push(chunk)) { + const url = tailscaleLoginUrl(line); + if (url === null || logins.has(url)) continue; + logins.add(url); + loggingIn(); + events.login(url); + } + }); + tail.follow(child.stderr, () => { + end({ kind: 'failed', message: `${this.alias} wrote too much to standard error` }); + }); + child.once('error', (error) => { + end({ kind: 'failed', message: `cannot run ${program}: ${error.message}` }); + }); + child.once('close', (code, signal) => { + end(this.result(code, signal, Buffer.concat(output), tail.reason(), program)); + }); + } + + private result( + code: number | null, + signal: NodeJS.Signals | null, + output: Buffer, + reason: string | null, + program: string, + ): HubOpenResult { + const outdated: HubOpenResult = { kind: 'outdated', message: `wtd on ${this.alias} is missing or too old to serve its hub` }; + if (code !== null && OUTDATED_STATUSES.has(code)) return outdated; + if (code === 0) { + try { + return { kind: 'opened', ...decodePrintCode(output) }; + } catch (error) { + if (!(error instanceof ProtocolError)) throw error; + return outdated; + } + } + const status = code === null ? `${program} was killed by ${String(signal)}` : `${program} exited with status ${String(code)}`; + return { kind: 'failed', message: reason ?? status }; + } +} + +/** One tunnelled connection to a remote hub. */ +export interface HubForward { + /** Resolves when both sides ended: null when the local side ended first, else SSH's last standard error line or exit. */ + ended: Promise; + /** Ends both sides. */ + close: () => void; +} + +/** Relays `socket` both ways to `127.0.0.1:` on `alias` through its own `ssh -W` process. */ +export const forwardToHub = (paths: Parameters[0], alias: string, remotePort: number, socket: Socket): HubForward => { + let child: ChildProcessByStdio; + let program: string; + try { + const [first = 'ssh', ...args] = sshForwardCommand(paths, alias, remotePort); + program = first; + child = spawn(program, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (error) { + socket.destroy(); + return { ended: Promise.resolve(error instanceof Error ? error.message : String(error)), close: () => undefined }; + } + const tail = new StderrTail(); + let localEnded = false; + let stopping = false; + /** Ends SSH's input, then signals it if it is still running a grace period later. */ + const stop = (): void => { + if (stopping) return; + stopping = true; + child.stdin.end(); + setTimeout(() => { + stopProcess(child, KILL_GRACE_MS); + }, KILL_GRACE_MS).unref(); + }; + const localEnd = (): void => { + localEnded = true; + stop(); + }; + socket.on('error', () => { + socket.destroy(); + }); + socket.once('end', localEnd); + socket.once('close', localEnd); + child.stdin.on('error', () => undefined); + socket.pipe(child.stdin); + // Ended only once the cause is recorded, so whoever reads the connection's end can ask for it. + child.stdout.pipe(socket, { end: false }); + const endLocal = (): void => { + setImmediate(() => { + socket.end(); + setTimeout(() => socket.destroy(), KILL_GRACE_MS).unref(); + }); + }; + tail.follow(child.stderr, () => { + stopProcess(child, KILL_GRACE_MS); + }); + const ended = new Promise((resolve) => { + child.once('error', (error) => { + resolve(localEnded ? null : `cannot run ${program}: ${error.message}`); + endLocal(); + }); + child.once('close', (code, signal) => { + const exit = code === null ? `${program} was killed by ${String(signal)}` : `${program} exited with status ${String(code)}`; + const failure = code === 0 ? null : exit; + resolve(localEnded ? null : (tail.reason() ?? failure)); + endLocal(); + }); + }); + return { + ended, + close: () => { + localEnded = true; + socket.destroy(); + stop(); + }, + }; +}; diff --git a/src/hub/links/index.ts b/src/hub/links/index.ts index 5c77e24..479e8d5 100644 --- a/src/hub/links/index.ts +++ b/src/hub/links/index.ts @@ -5,6 +5,8 @@ import { dial, LineScanner, spawnDetached, sshCommand, StderrTail, tailscaleLogi import { installRemote, type InstalledRemote } from '../../platform/install/index.js'; import { ProtocolError, StreamDecoder, type Frame } from '../../protocol/index.js'; +export { forwardToHub, HubOpener, PRINT_CODE_COMMAND, type HubForward, type HubOpening, type HubOpenResult } from './hub.js'; + /** The host files a local link needs. */ export type DaemonPaths = Parameters[0]; diff --git a/src/hub/main/index.ts b/src/hub/main/index.ts index 4cc7588..6a71797 100644 --- a/src/hub/main/index.ts +++ b/src/hub/main/index.ts @@ -1,7 +1,8 @@ import { randomBytes } from 'node:crypto'; import { hostname } from 'node:os'; -import { loadConfig } from '../config/index.js'; -import { startDetached, type DaemonPaths } from '../links/index.js'; +import { encodeHomeFragment, encodePrintCode } from '../../protocol/index.js'; +import { loadConfig, type HubConfig } from '../config/index.js'; +import { forwardToHub, startDetached, type DaemonPaths } from '../links/index.js'; import { Router } from '../router/index.js'; import { hubIdentity, @@ -12,6 +13,7 @@ import { requestCode, requestShutdown, startHubServer, + TunnelListeners, type HubPaths, } from '../server/index.js'; @@ -30,25 +32,44 @@ export interface HubRun { /** The web bundle directory. */ webDir: string; home: string; + /** Read for `WTD_HUB_OPEN_MS`, `WTD_HUB_PROBE_MS` and `WTD_HUB_INSTALL_MS`, which shorten the hub's time limits. */ + env: Readonly>; } -export interface UiRun { +/** What makes a hub of this version serve. */ +export interface HubStart { version: string; paths: HubHostPaths; wtd: readonly string[]; home: string; +} + +export interface UiRun extends HubStart { /** The browser command. */ browser: string; + /** The remote host whose hub the page opens; null for this machine's. */ + host: string | null; } const STOP_SIGNALS = ['SIGTERM', 'SIGINT'] as const; +const PROBE_MS = 10_000; const HUB_START_MS = 5000; const POLL_MS = 50; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +/** A positive integer number of milliseconds from `value`, else undefined. */ +const budget = (value: string | undefined): number | undefined => { + const ms = Number(value); + return value !== undefined && Number.isInteger(ms) && ms > 0 ? ms : undefined; +}; + +/** Whether `config` still forwards `port` to `host` over `alias`. */ +const maps = (config: HubConfig, { host, alias, port }: { host: string; alias: string; port: number }): boolean => + config.hosts.some((h) => h.name === host && h.ssh === alias && h.port === port); + /** Runs the hub until SIGTERM, SIGINT or an authenticated shutdown request. */ -export const runHub = async ({ version, paths, wtd, webDir, home }: HubRun): Promise => { +export const runHub = async ({ version, paths, wtd, webDir, home, env }: HubRun): Promise => { let stop = (): void => undefined; const stopped = new Promise((resolve) => { stop = resolve; @@ -57,6 +78,12 @@ export const runHub = async ({ version, paths, wtd, webDir, home }: HubRun): Pro try { const config = await loadConfig(paths.config, home); const instance = randomBytes(12).toString('base64url'); + let latest = config; + const tunnels = new TunnelListeners({ + forward: (alias, remotePort, socket) => forwardToHub(paths, alias, remotePort, socket), + current: (target) => maps(latest, target), + }); + const probeMs = budget(env['WTD_HUB_PROBE_MS']) ?? PROBE_MS; const router = new Router({ paths, daemonCommand: [...wtd, 'daemon'], @@ -70,6 +97,14 @@ export const runHub = async ({ version, paths, wtd, webDir, home }: HubRun): Pro report: (error) => { console.error(error); }, + tunnels: { + ensure: (target, signal) => tunnels.ensure(target, signal), + probe: (port, signal) => tunnels.probe(port, probeMs, signal), + }, + configRead: (read) => { + latest = read; + }, + budgets: { openMs: budget(env['WTD_HUB_OPEN_MS']), installMs: budget(env['WTD_HUB_INSTALL_MS']) }, }); const server = await startHubServer({ paths, @@ -81,7 +116,7 @@ export const runHub = async ({ version, paths, wtd, webDir, home }: HubRun): Pro shutdown: stop, }); await stopped; - await server.close(); + await Promise.all([server.close(), tunnels.close()]); } finally { for (const signal of STOP_SIGNALS) process.off(signal, stop); } @@ -106,7 +141,7 @@ const waitFor = async (probe: () => Promise, ms: number): Promise => { +const ensureHub = async ({ version, paths, wtd }: HubStart): Promise<{ port: number; token: string }> => { const running = await answeringHub(paths); if (running?.version === version) return running; if (running !== null) { @@ -121,10 +156,21 @@ const ensureHub = async ({ version, paths, wtd }: UiRun): Promise<{ port: number return started; }; -/** Makes a hub of this version serve, then opens the browser on it with a one-time code. */ +/** Makes a hub of this version serve, then opens the browser on it with a one-time code, asking it to open `run.host`'s hub. */ export const openUi = async (run: UiRun): Promise => { - await loadConfig(run.paths.config, run.home); + const config = await loadConfig(run.paths.config, run.home); + if (run.host !== null && !config.hosts.some((h) => h.name === run.host)) { + throw new Error(`no host named ${run.host} in ${run.paths.config}`); + } const { port, token } = await ensureHub(run); const code = await requestCode(port, token); - await startDetached([run.browser, `--app=http://127.0.0.1:${String(port)}/#code=${code}`], null); + const fragment = encodeHomeFragment({ code, hub: run.host }); + await startDetached([run.browser, `--app=http://127.0.0.1:${String(port)}/#${fragment}`], null); +}; + +/** Makes a hub of this version serve and gives the print-code line with its port and a one-time code. */ +export const printCode = async (run: HubStart): Promise => { + await loadConfig(run.paths.config, run.home); + const { port, token } = await ensureHub(run); + return encodePrintCode({ port, code: await requestCode(port, token) }); }; diff --git a/src/hub/router/coordinator.test.ts b/src/hub/router/coordinator.test.ts index ba30b89..e9fd960 100644 --- a/src/hub/router/coordinator.test.ts +++ b/src/hub/router/coordinator.test.ts @@ -18,16 +18,24 @@ const deferred = (): Deferred => { }; /** A coordinator whose operations record their start and finish when the test settles them. */ -const setup = (): { coordinator: HostCoordinator; started: string[]; settle: (kind: string, error?: Error) => void } => { +const setup = (): { + coordinator: HostCoordinator; + started: string[]; + settle: (kind: string, error?: Error) => void; + /** The most operations that ran at once. */ + peak: () => number; +} => { const started: string[] = []; const running = new Map(); + let peak = 0; const op = (kind: string) => (): Promise => { started.push(kind); const d = deferred(); running.set(kind, d); + peak = Math.max(peak, running.size); return d.promise; }; - const coordinator = new HostCoordinator({ restart: op('restart'), reinstall: op('reinstall') }); + const coordinator = new HostCoordinator({ restart: op('restart'), reinstall: op('reinstall'), install: op('install') }); const settle = (kind: string, error?: Error): void => { const d = running.get(kind); if (d === undefined) throw new Error(`${kind} is not running`); @@ -35,7 +43,7 @@ const setup = (): { coordinator: HostCoordinator; started: string[]; settle: (ki if (error === undefined) d.resolve(); else d.reject(error); }; - return { coordinator, started, settle }; + return { coordinator, started, settle, peak: () => peak }; }; const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); @@ -181,3 +189,162 @@ describe('HostCoordinator', () => { expect(coordinator.reinstalling()).toBeNull(); }); }); + +type Kind = 'install' | 'restart' | 'reinstall'; + +const request = (coordinator: HostCoordinator, kind: Kind): Promise => coordinator[kind](); + +const permutations = (items: readonly T[]): T[][] => + items.length <= 1 + ? [[...items]] + : items.flatMap((item, i) => permutations(items.filter((_, j) => j !== i)).map((rest) => [item, ...rest])); + +describe('HostCoordinator installs', () => { + it('shares one install between concurrent requests', async () => { + const { coordinator, started, settle } = setup(); + const a = coordinator.install(); + const b = coordinator.install(); + await tick(); + expect(started).toEqual(['install']); + settle('install'); + await expect(Promise.all([a, b])).resolves.toEqual([undefined, undefined]); + }); + + it('lets an install share a running reinstall', async () => { + const { coordinator, started, settle } = setup(); + const reinstall = coordinator.reinstall(); + await tick(); + const install = coordinator.install(); + await tick(); + expect(started).toEqual(['reinstall']); + expect(await settled(install)).toBe(false); + settle('reinstall'); + await expect(Promise.all([reinstall, install])).resolves.toEqual([undefined, undefined]); + expect(started).toEqual(['reinstall']); + }); + + it('lets an install share a queued reinstall', async () => { + const { coordinator, started, settle } = setup(); + void coordinator.restart(); + await tick(); + const reinstall = coordinator.reinstall(); + const install = coordinator.install(); + settle('restart'); + await tick(); + await tick(); + expect(started).toEqual(['restart', 'reinstall']); + settle('reinstall'); + await expect(Promise.all([reinstall, install])).resolves.toEqual([undefined, undefined]); + expect(started).toEqual(['restart', 'reinstall']); + }); + + it('lets an install share a queued install', async () => { + const { coordinator, started, settle } = setup(); + void coordinator.restart(); + await tick(); + const a = coordinator.install(); + const b = coordinator.install(); + settle('restart'); + await tick(); + await tick(); + expect(started).toEqual(['restart', 'install']); + settle('install'); + await expect(Promise.all([a, b])).resolves.toEqual([undefined, undefined]); + }); + + it.each(['restart', 'reinstall'] as const)('runs a %s requested during an install once the install ended', async (kind) => { + const { coordinator, started, settle } = setup(); + const install = coordinator.install(); + await tick(); + const later = request(coordinator, kind); + await tick(); + expect(started).toEqual(['install']); + expect(await settled(later)).toBe(false); + settle('install'); + await install; + await tick(); + expect(started).toEqual(['install', kind]); + settle(kind); + await expect(later).resolves.toBeUndefined(); + }); + + it('runs an install requested during a restart once the restart ended', async () => { + const { coordinator, started, settle } = setup(); + const restart = coordinator.restart(); + await tick(); + const install = coordinator.install(); + await tick(); + expect(started).toEqual(['restart']); + expect(await settled(install)).toBe(false); + settle('restart'); + await restart; + await tick(); + expect(started).toEqual(['restart', 'install']); + settle('install'); + await expect(install).resolves.toBeUndefined(); + }); + + it('runs a queued install even when the restart before it failed', async () => { + const { coordinator, started, settle } = setup(); + const restart = coordinator.restart(); + await tick(); + const install = coordinator.install(); + settle('restart', new Error('no new daemon')); + await expect(restart).rejects.toThrow('no new daemon'); + await tick(); + expect(started).toEqual(['restart', 'install']); + settle('install'); + await expect(install).resolves.toBeUndefined(); + }); + + it('gives an install sharing a failed reinstall that failure', async () => { + const { coordinator, settle } = setup(); + const reinstall = coordinator.reinstall(); + await tick(); + const install = coordinator.install(); + settle('reinstall', new Error('install: no Node')); + await expect(install).rejects.toThrow('install: no Node'); + await expect(reinstall).rejects.toThrow('install: no Node'); + }); + + /** The runs, in order, that requests made in this order start. */ + const RUNS: readonly (readonly [readonly Kind[], readonly Kind[]])[] = [ + [ + ['install', 'restart', 'reinstall'], + ['install', 'restart', 'reinstall'], + ], + [ + ['install', 'reinstall', 'restart'], + ['install', 'reinstall'], + ], + [ + ['restart', 'install', 'reinstall'], + ['restart', 'install', 'reinstall'], + ], + [ + ['restart', 'reinstall', 'install'], + ['restart', 'reinstall'], + ], + [['reinstall', 'install', 'restart'], ['reinstall']], + [['reinstall', 'restart', 'install'], ['reinstall']], + ]; + + it('lists every ordering of three requests', () => { + expect(RUNS.map(([kinds]) => kinds)).toEqual(expect.arrayContaining(permutations(['install', 'restart', 'reinstall']))); + expect(RUNS).toHaveLength(6); + }); + + it.each(RUNS)('runs requests made as %j one at a time, in order, as the runs %j', async (kinds, runs) => { + const { coordinator, started, settle, peak } = setup(); + const requests = kinds.map((kind) => request(coordinator, kind)); + for (let i = 0; i < runs.length; i++) { + await tick(); + await tick(); + expect(started).toEqual(runs.slice(0, i + 1)); + settle(runs[i] ?? ''); + } + await expect(Promise.all(requests)).resolves.toEqual([undefined, undefined, undefined]); + expect(started).toEqual(runs); + expect(peak()).toBe(1); + }); +}); diff --git a/src/hub/router/coordinator.ts b/src/hub/router/coordinator.ts index 70225a1..3028506 100644 --- a/src/hub/router/coordinator.ts +++ b/src/hub/router/coordinator.ts @@ -1,23 +1,31 @@ -type Kind = 'restart' | 'reinstall'; +type Kind = 'restart' | 'reinstall' | 'install'; interface Run { kind: Kind; promise: Promise; } -/** One host's restart and reinstall operations. */ +/** One host's restart, reinstall and install-only operations. */ export interface HostOperations { restart: () => Promise; reinstall: () => Promise; + install: () => Promise; } +/** The runs a request of each kind shares instead of queueing its own. */ +const SHARES: Readonly> = { + install: ['install', 'reinstall'], + restart: ['restart', 'reinstall'], + reinstall: ['reinstall'], +}; + /** - * Runs one host's restarts and reinstalls one at a time, across sessions: identical requests share - * a run, a restart shares a running or queued reinstall, and other requests wait for the running one. + * Runs one host's operations one at a time, in request order, across sessions: a request shares a + * running or queued run it is covered by, and otherwise queues behind every other. */ export class HostCoordinator { - private running: Run | null = null; - private queued: Run | null = null; + /** The running run first, then the queued ones. */ + private runs: Run[] = []; constructor(private readonly operations: HostOperations) {} @@ -27,39 +35,30 @@ export class HostCoordinator { /** The running or queued reinstall, which a restart shares whatever the host's status; null without one. */ reinstalling(): Promise | null { - return [this.running, this.queued].find((run) => run?.kind === 'reinstall')?.promise ?? null; + return this.runs.find((run) => run.kind === 'reinstall')?.promise ?? null; } reinstall(): Promise { return this.request('reinstall'); } - private request(kind: Kind): Promise { - const shared = [this.running, this.queued].find((run) => run !== null && (run.kind === kind || run.kind === 'reinstall')); - if (shared !== undefined && shared !== null) return shared.promise; - if (this.running === null) return this.start(kind).promise; - const before = this.running.promise; - const queued: Run = { - kind, - promise: before - .catch(() => undefined) - .then(() => { - this.queued = null; - return this.start(kind).promise; - }), - }; - this.queued = queued; - return queued.promise; + install(): Promise { + return this.request('install'); } - private start(kind: Kind): Run { + private request(kind: Kind): Promise { + const shared = this.runs.find((run) => SHARES[kind].includes(run.kind)); + if (shared !== undefined) return shared.promise; + const before = this.runs.at(-1)?.promise.catch(() => undefined) ?? Promise.resolve(); const run: Run = { kind, - promise: this.operations[kind]().finally(() => { - if (this.running === run) this.running = null; - }), + promise: before + .then(() => this.operations[kind]()) + .finally(() => { + this.runs = this.runs.filter((r) => r !== run); + }), }; - this.running = run; - return run; + this.runs.push(run); + return run.promise; } } diff --git a/src/hub/router/index.test.ts b/src/hub/router/index.test.ts new file mode 100644 index 0000000..d0c2774 --- /dev/null +++ b/src/hub/router/index.test.ts @@ -0,0 +1,103 @@ +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { decodeMessage, type MessageOf } from '../../protocol/index.js'; +import { hostPaths } from '../../platform/files/index.js'; +import { parseConfig } from '../config/index.js'; +import { Router, type HubTunnels, type RoutedSession } from './index.js'; + +let dir: string; +let routed: RoutedSession[]; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-router-'))); + routed = []; + // Records each print-code run; every other SSH run fails as an unreachable host. + const program = join(dir, 'ssh'); + writeFileSync(program, `#!/bin/sh\ncase "$*" in *--print-code*) echo $$ >> '${dir}/printed'; exec sleep 600;; esac\nexit 255\n`); + chmodSync(program, 0o755); + vi.stubEnv('WTD_SSH', program); +}); + +afterEach(() => { + for (const session of routed) session.close(); + vi.unstubAllEnvs(); + for (const pid of printRuns()) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone. + } + } + rmSync(dir, { recursive: true, force: true }); +}); + +const printRuns = (): number[] => { + const file = join(dir, 'printed'); + return existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(Number) : []; +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitFor = async (probe: () => boolean, what: string, timeout = 5000): Promise => { + const deadline = Date.now() + timeout; + while (!probe()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(20); + } +}; + +const tunnels: HubTunnels = { + ensure: () => Promise.reject(new Error('no tunnel expected')), + probe: () => Promise.resolve('no probe expected'), +}; + +/** A started session of a router whose config has one remote host `box`. */ +const session = async (): Promise => { + const configPath = join(dir, 'config.json'); + const text = JSON.stringify({ port: 7417, hosts: [{ name: 'box', ssh: 'box' }] }); + writeFileSync(configPath, text); + const router = new Router({ + paths: hostPaths({ env: { XDG_STATE_HOME: join(dir, 's') }, home: dir, host: 'local' }), + daemonCommand: ['true'], + configPath, + home: dir, + config: parseConfig(text, dir), + version: '0.0.0', + instance: 'router-test', + hostName: 'local', + bundle: join(dir, 'wtd.mjs'), + report: (error) => { + throw error; + }, + tunnels, + configRead: () => undefined, + }); + const sent: MessageOf<'hubToBrowser'>[] = []; + const opened = router.open({ + send: (data) => { + if (typeof data === 'string') sent.push(decodeMessage('hubToBrowser', data)); + }, + unsent: () => 0, + }); + routed.push(opened); + opened.hello(); + await waitFor(() => sent.some((m) => m.t === 'hosts'), 'the session to start'); + return opened; +}; + +it('runs print-code on the host for an opening', async () => { + const opened = await session(); + opened.receive({ t: 'openHub', req: 1, host: 1 }); + await waitFor(() => printRuns().length > 0, 'the print-code run'); + expect(printRuns()).toHaveLength(1); +}); + +it('runs no print-code for an opening its session closed while the configuration was read', async () => { + const opened = await session(); + opened.receive({ t: 'openHub', req: 1, host: 1 }); + opened.close(); + await sleep(500); + expect(printRuns()).toEqual([]); +}); diff --git a/src/hub/router/index.ts b/src/hub/router/index.ts index 5707d0d..b3839bf 100644 --- a/src/hub/router/index.ts +++ b/src/hub/router/index.ts @@ -1,10 +1,18 @@ -import { DAEMON_PROTOCOL_VERSION, type DataFrame, type MessageOf } from '../../protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION, encodeRemoteFragment, type DataFrame, type MessageOf } from '../../protocol/index.js'; import { ConfigEditor, ConfigSource, type ConfigSnapshot, type HubConfig } from '../config/index.js'; -import { installOnRemote, LocalDaemon, RemoteDaemon, type DaemonEndpoint, type DaemonPaths } from '../links/index.js'; +import { + HubOpener, + installOnRemote, + LocalDaemon, + RemoteDaemon, + type DaemonEndpoint, + type DaemonPaths, + type HubOpenResult, +} from '../links/index.js'; import { HostCoordinator } from './coordinator.js'; import { hostKey, localHostName } from './hosts.js'; import { DaemonRestarter } from './restart.js'; -import { Session, type BrowserChannel, type SessionContext, type SessionHost } from './session.js'; +import { HostGoneError, Session, type BrowserChannel, type HubOpenOutcome, type SessionContext, type SessionHost } from './session.js'; export { hubError, type BrowserChannel } from './session.js'; @@ -28,6 +36,35 @@ export interface RouterOptions { bundle: string; /** Reports an unexpected failure. */ report: (error: unknown) => void; + /** Tunnel listeners to remote hubs. */ + tunnels: HubTunnels; + /** A valid configuration was read; the latest one decides which tunnel connections are forwarded. */ + configRead: (config: HubConfig) => void; + /** Time limits, shortened in tests; each defaults when undefined. */ + budgets?: { [K in keyof RouterBudgets]?: number | undefined }; +} + +export interface RouterBudgets { + /** For `wtd ui --print-code` on a host without a login link. */ + openMs: number; + /** For an installation, alone or within a reinstall. */ + installMs: number; +} + +/** What a tunnel listener forwards to. */ +export interface TunnelTarget { + host: string; + alias: string; + port: number; + remotePort: number; +} + +/** The hub's listeners forwarding a loopback port per remote host to its hub. */ +export interface HubTunnels { + /** Makes the listener on `target.port` forward to `target`, unless `signal` aborts; rejects naming a port another process holds. */ + ensure: (target: TunnelTarget, signal: AbortSignal) => Promise; + /** Null when `GET /` through the listener on `port` answers 200 in time, else the failure; aborting ends it. */ + probe: (port: number, signal: AbortSignal) => Promise; } /** A browser session as the server drives it. */ @@ -40,8 +77,7 @@ export interface RoutedSession { close: () => void; } -/** How long a reinstall's installation step may take. */ -const INSTALL_MS = 120_000; +const DEFAULT_BUDGETS: RouterBudgets = { openMs: 30_000, installMs: 120_000 }; const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -54,8 +90,13 @@ export class Router { private readonly hello: SessionContext['hello']; private readonly sessions = new Set(); private readonly coordinators = new Map(); + private readonly budgets: RouterBudgets; constructor(private readonly options: RouterOptions) { + this.budgets = { + openMs: options.budgets?.openMs ?? DEFAULT_BUDGETS.openMs, + installMs: options.budgets?.installMs ?? DEFAULT_BUDGETS.installMs, + }; this.hello = { t: 'hello', protocol: DAEMON_PROTOCOL_VERSION, version: options.version, instance: options.instance }; this.local = new LocalDaemon(options.paths, options.daemonCommand); this.config = new ConfigSource(options.configPath, options.home, options.config); @@ -82,9 +123,104 @@ export class Router { greeted: (host, from) => { for (const session of this.sessions) if (session !== from) session.redialLogin(host); }, + openHub: (name, events) => this.openHub(name, events), + installHub: async (name) => { + const config = await this.readConfig(); + const host = config.hosts.find((h) => h.name === name); + if (host === undefined) throw new HostGoneError(`host ${name} is no longer configured`); + await this.coordinator({ idx: 0, name, ssh: host.ssh, repos: [], roots: [] }).install(); + }, + }; + } + + /** Reads the configuration, reporting a valid read; an invalid file gives the last valid one. */ + private async readConfig(): Promise { + return (await this.snapshot()).config; + } + + private async snapshot(): Promise { + const read = await this.config.snapshot(); + if (read.problem === null) this.options.configRead(read.config); + return read; + } + + /** Opens the hub of the remote host `name`; a cancelled opening changes no listener and reports nothing. */ + private openHub(name: string, events: Parameters[1]): { cancel: () => void } { + const controller = new AbortController(); + const { signal } = controller; + const login = (url: string): void => { + if (!signal.aborted) events.login(url); + }; + this.openSteps(name, login, signal).then( + (outcome) => { + if (!signal.aborted) events.done(outcome); + }, + (error: unknown) => { + if (!signal.aborted) events.done({ kind: 'error', code: 'internal', message: messageOf(error) }); + }, + ); + return { + cancel: () => { + controller.abort(); + }, }; } + private async openSteps(name: string, login: (url: string) => void, signal: AbortSignal): Promise { + const failed = (message: string): HubOpenOutcome => ({ kind: 'error', code: 'hub-failed', message }); + // Read through a call: the opening may end while any await is pending. + const ended = (): boolean => signal.aborted; + const config = await this.readConfig(); + if (ended()) return failed('cancelled'); + const host = config.hosts.find((h) => h.name === name); + if (host === undefined) return { kind: 'error', code: 'unknown-host', message: `host ${name} is no longer configured` }; + const printed = await this.printCode(host.ssh, login, signal); + if (printed === null) return failed('cancelled'); + if (printed.kind === 'outdated') return { kind: 'error', code: 'hub-outdated', message: printed.message }; + if (printed.kind === 'failed') return failed(printed.message); + if (ended()) return failed('cancelled'); + let port = host.port; + if (port === null) { + try { + port = (await this.editor.assignPort(name)).port; + } catch (error) { + return failed(messageOf(error)); + } + await this.readConfig(); + } + if (ended()) return failed('cancelled'); + try { + await this.options.tunnels.ensure({ host: name, alias: host.ssh, port, remotePort: printed.port }, signal); + } catch (error) { + return failed(messageOf(error)); + } + if (ended()) return failed('cancelled'); + const probe = await this.options.tunnels.probe(port, signal); + if (probe !== null) return failed(probe); + const fragment = encodeRemoteFragment({ + code: printed.code, + host: name, + home: this.options.config.port, + hubs: config.hosts.map((h) => h.name), + }); + return { kind: 'opened', url: `http://127.0.0.1:${String(port)}/#${fragment}` }; + } + + /** Runs `wtd ui --print-code` on `alias`; resolves null once `signal` aborts, killing the run. */ + private printCode(alias: string, login: (url: string) => void, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const run = new HubOpener(this.options.paths, alias, { timeoutMs: this.budgets.openMs }).open({ login, done: resolve }); + signal.addEventListener( + 'abort', + () => { + run.cancel(); + resolve(null); + }, + { once: true }, + ); + }); + } + /** The hosts of a session reading `config`: the local host, then the remote hosts in order. */ private hostsOf(config: HubConfig): SessionHost[] { const local: SessionHost = { @@ -113,21 +249,27 @@ export class Router { coordinator = new HostCoordinator({ restart: () => restarter.restart(), reinstall: async () => { - if (alias === null) throw new Error('the local daemon is restarted, not reinstalled'); - const { paths, bundle, version } = this.options; - await installOnRemote(paths, alias, bundle, version, INSTALL_MS).catch((error: unknown) => { - throw new Error(`install: ${messageOf(error)}`, { cause: error }); - }); - await restarter.replaceOther(version).catch((error: unknown) => { + await this.install(alias); + await restarter.replaceOther(this.options.version).catch((error: unknown) => { throw new Error(`restart: ${messageOf(error)}`, { cause: error }); }); }, + install: () => this.install(alias), }); this.coordinators.set(key, coordinator); } return coordinator; } + /** Installs the hub's bundle on `alias`. */ + private async install(alias: string | null): Promise { + if (alias === null) throw new Error('the local daemon is restarted, not reinstalled'); + const { paths, bundle, version } = this.options; + await installOnRemote(paths, alias, bundle, version, this.budgets.installMs).catch((error: unknown) => { + throw new Error(`install: ${messageOf(error)}`, { cause: error }); + }); + } + /** Starts a session for `channel`, reading its configuration snapshot now. */ open(channel: BrowserChannel): RoutedSession { let session: Session | null = null; @@ -147,7 +289,7 @@ export class Router { if (session === null) pending.push(action); else action(session); }; - this.config.snapshot().then( + this.snapshot().then( (read) => { snapshot = read; start(); diff --git a/src/hub/router/session.test.ts b/src/hub/router/session.test.ts index 82a2433..b48ced1 100644 --- a/src/hub/router/session.test.ts +++ b/src/hub/router/session.test.ts @@ -3,7 +3,7 @@ import { decodeMessage, DAEMON_PROTOCOL_VERSION, type HostEntry, type MessageOf import { parseConfig } from '../config/index.js'; import type { DaemonEndpoint, Link, LinkEvents } from '../links/index.js'; import { HostCoordinator } from './coordinator.js'; -import { Session, type BrowserChannel, type SessionContext, type SessionHost } from './session.js'; +import { HostGoneError, Session, type BrowserChannel, type HubOpenOutcome, type SessionContext, type SessionHost } from './session.js'; const LOGIN_URL = 'https://login.tailscale.com/a/abc123'; @@ -60,7 +60,7 @@ interface Started { host: () => HostEntry | undefined; } -const start = (): Started => { +const start = (overrides: Partial = {}, hosts: readonly SessionHost[] = [box]): Started => { const sent: MessageOf<'hubToBrowser'>[] = []; const channel: BrowserChannel = { send: (data) => { @@ -72,14 +72,22 @@ const start = (): Started => { const context: SessionContext = { hello: { t: 'hello', protocol: DAEMON_PROTOCOL_VERSION, version: '0.1.0', instance: 'hub_1' }, endpoint: () => endpoint, - coordinator: () => new HostCoordinator({ restart: unused, reinstall: unused }), + coordinator: () => new HostCoordinator({ restart: unused, reinstall: unused, install: unused }), listsRepo: unused, editRepos: unused, reposEdited: unused, editPresets: unused, presetsEdited: unused, + openHub: unused, + installHub: unused, + ...overrides, }; - const session = new Session(channel, context, { config: parseConfig(null, '/home/u'), problem: null }, [{ ...box }]); + const session = new Session( + channel, + context, + { config: parseConfig(null, '/home/u'), problem: null }, + hosts.map((h) => ({ ...h })), + ); session.start(); const host = (): HostEntry | undefined => sent.findLast((m) => m.t === 'hosts')?.hosts[0]; return { session, endpoint, sent, host }; @@ -132,3 +140,108 @@ describe('Session Tailscale login', () => { session.close(); }); }); + +type OpenEvents = Parameters[1]; + +/** An `openHub` recording each opening's host, events and cancellation. */ +const openings = (): { + openHub: SessionContext['openHub']; + opened: { name: string; events: OpenEvents; cancelled: boolean }[]; +} => { + const opened: { name: string; events: OpenEvents; cancelled: boolean }[] = []; + return { + opened, + openHub: (name, events) => { + const opening = { name, events, cancelled: false }; + opened.push(opening); + return { + cancel: () => { + opening.cancelled = true; + }, + }; + }, + }; +}; + +const local: SessionHost = { idx: 0, name: 'laptop', ssh: null, repos: [], roots: [] }; +const URL_OF_BOX = `http://127.0.0.1:7418/#code=${'a'.repeat(64)}&host=box&home=7417&hubs=box`; + +describe('Session hub openings', () => { + it('opens the remote host by name, sending its login link and then hubOpened', () => { + const { openHub, opened } = openings(); + const { session, sent } = start({ openHub }, [local, { ...box, idx: 1 }]); + session.receive({ t: 'openHub', req: 4, host: 1 }); + expect(opened.map((o) => o.name)).toEqual(['box']); + opened[0]?.events.login(LOGIN_URL); + opened[0]?.events.done({ kind: 'opened', url: URL_OF_BOX }); + expect(sent.slice(-2)).toEqual([ + { t: 'hubLogin', req: 4, login: LOGIN_URL }, + { t: 'hubOpened', req: 4, url: URL_OF_BOX }, + ]); + session.close(); + }); + + it.each<[string, HubOpenOutcome]>([ + ['hub-outdated', { kind: 'error', code: 'hub-outdated', message: 'wtd on box is missing or too old to serve its hub' }], + ['hub-failed', { kind: 'error', code: 'hub-failed', message: 'ssh: Could not resolve hostname box' }], + ])('answers a %s opening with the host index', (_code, outcome) => { + const { openHub, opened } = openings(); + const { session, sent } = start({ openHub }, [local, { ...box, idx: 1 }]); + session.receive({ t: 'openHub', req: 4, host: 1 }); + opened[0]?.events.done(outcome); + expect(sent.at(-1)).toEqual( + outcome.kind === 'error' ? { t: 'error', req: 4, host: 1, code: outcome.code, message: outcome.message } : null, + ); + session.close(); + }); + + it.each([ + ['the local host', 0], + ['an index beyond the hosts', 7], + ])('answers unknown-host for %s without opening anything', (_name, idx) => { + const { openHub, opened } = openings(); + const { session, sent } = start({ openHub }, [local, { ...box, idx: 1 }]); + session.receive({ t: 'openHub', req: 4, host: idx }); + expect(opened).toEqual([]); + expect(sent.at(-1)).toMatchObject({ t: 'error', req: 4, host: idx, code: 'unknown-host' }); + session.close(); + }); + + it('cancels the opening in progress when a newer one arrives', () => { + const { openHub, opened } = openings(); + const { session } = start({ openHub }, [local, { ...box, idx: 1 }, { ...box, idx: 2, name: 'gpu', ssh: 'gpu' }]); + session.receive({ t: 'openHub', req: 4, host: 1 }); + session.receive({ t: 'openHub', req: 5, host: 2 }); + expect(opened.map((o) => [o.name, o.cancelled])).toEqual([ + ['box', true], + ['gpu', false], + ]); + session.close(); + }); + + it('cancels the opening in progress when the session closes', () => { + const { openHub, opened } = openings(); + const { session } = start({ openHub }, [local, { ...box, idx: 1 }]); + session.receive({ t: 'openHub', req: 4, host: 1 }); + session.close(); + expect(opened[0]?.cancelled).toBe(true); + }); + + it('answers installHub with done, or unknown-host once the host is gone', async () => { + const installHub = vi + .fn() + .mockResolvedValueOnce() + .mockRejectedValueOnce(new HostGoneError('host box is no longer configured')); + const { session, sent } = start({ installHub }, [local, { ...box, idx: 1 }]); + session.receive({ t: 'installHub', req: 4, host: 1 }); + await vi.waitFor(() => { + expect(sent.at(-1)).toEqual({ t: 'done', req: 4 }); + }); + session.receive({ t: 'installHub', req: 5, host: 1 }); + await vi.waitFor(() => { + expect(sent.at(-1)).toMatchObject({ t: 'error', req: 5, host: 1, code: 'unknown-host' }); + }); + expect(installHub.mock.calls).toEqual([['box'], ['box']]); + session.close(); + }); +}); diff --git a/src/hub/router/session.ts b/src/hub/router/session.ts index 589ef0f..4a7267e 100644 --- a/src/hub/router/session.ts +++ b/src/hub/router/session.ts @@ -40,6 +40,16 @@ export interface SessionHost { roots: string[]; } +/** How a hub opening ended: the URL to navigate to, or the error to answer with. */ +export type HubOpenOutcome = + | { kind: 'opened'; url: string } + | { kind: 'error'; code: Extract; message: string }; + +/** The configuration no longer lists the session's host. */ +export class HostGoneError extends Error { + override readonly name = 'HostGoneError'; +} + /** What a session shares with the rest of the hub. */ export interface SessionContext { /** Our hello to a daemon. */ @@ -61,6 +71,10 @@ export interface SessionContext { presetsEdited: (presets: Preset[]) => void; /** A link of session `from` to `host` exchanged hello: redials the other sessions' links to it that need login. */ greeted?: (host: SessionHost, from: Session) => void; + /** Opens the hub of the remote host named `name`, read afresh from the configuration; `cancel` ends it silently. */ + openHub: (name: string, events: { login: (url: string) => void; done: (outcome: HubOpenOutcome) => void }) => { cancel: () => void }; + /** Installs the hub's bundle on the remote host named `name`; rejects with HostGoneError when it is no longer configured. */ + installHub: (name: string) => Promise; } /** Depth of hub-level repo discovery. */ @@ -236,6 +250,8 @@ class HostLink { /** One browser session: its configuration snapshot, its daemon links and its routing. */ export class Session { private readonly hosts: HostLink[]; + /** The hub opening in progress, at most one. */ + private opening: { cancel: () => void } | null = null; private paused = false; private closed = false; @@ -269,6 +285,12 @@ export class Session { case 'reinstallDaemon': this.reinstall(message.req, message.host); return; + case 'openHub': + this.openHub(message.req, message.host); + return; + case 'installHub': + this.installHub(message.req, message.host); + return; case 'discoverRepos': this.discover(message.req, message.host); return; @@ -324,6 +346,8 @@ export class Session { close(): void { this.closed = true; + this.opening?.cancel(); + this.opening = null; for (const host of this.hosts) host.close(); } @@ -417,6 +441,47 @@ export class Session { else this.reply(req, idx, this.context.coordinator(host.host).reinstall()); } + /** The remote host `idx`, or null after answering `unknown-host`. */ + private remoteHost(req: number, idx: number): HostLink | null { + const host = this.hosts[idx]; + if (host !== undefined && host.host.ssh !== null) return host; + this.error(req, idx, 'unknown-host', host === undefined ? `no host ${String(idx)}` : `host ${String(idx)} is this machine`); + return null; + } + + /** Ends any opening in progress, then opens the remote host's hub. */ + private openHub(req: number, idx: number): void { + this.opening?.cancel(); + this.opening = null; + const host = this.remoteHost(req, idx); + if (host === null) return; + const opening = this.context.openHub(host.host.name, { + login: (login) => { + this.message({ t: 'hubLogin', req, login }); + }, + done: (outcome) => { + if (this.opening === opening) this.opening = null; + if (outcome.kind === 'opened') this.message({ t: 'hubOpened', req, url: outcome.url }); + else this.error(req, idx, outcome.code, outcome.message); + }, + }); + this.opening = opening; + } + + private installHub(req: number, idx: number): void { + const host = this.remoteHost(req, idx); + if (host === null) return; + this.context.installHub(host.host.name).then( + () => { + this.message({ t: 'done', req }); + }, + (error: unknown) => { + const text = error instanceof Error ? error.message : String(error); + this.error(req, idx, error instanceof HostGoneError ? 'unknown-host' : 'internal', text); + }, + ); + } + private discover(req: number, idx: number): void { const host = this.connectedHost(req, idx); if (host === null) return; diff --git a/src/hub/server/index.ts b/src/hub/server/index.ts index 5c63fcb..a7eab05 100644 --- a/src/hub/server/index.ts +++ b/src/hub/server/index.ts @@ -11,6 +11,7 @@ export { hubIdentity, portServed, requestCode, requestShutdown, type HubIdentity export { PortInUseError } from './listener.js'; export { readHubRecord, type HubRecord } from './record.js'; export { readToken } from './token.js'; +export { TunnelListeners, type TunnelForward, type TunnelOptions, type TunnelTarget } from './tunnels.js'; /** The host files of the hub. */ export type HubPaths = Pick; diff --git a/src/hub/server/listener.ts b/src/hub/server/listener.ts index 432a26b..9a9277b 100644 --- a/src/hub/server/listener.ts +++ b/src/hub/server/listener.ts @@ -28,6 +28,7 @@ type ErrorCode = Extract, { t: 'error' }>['code']; const MAX_BODY = 1024; const BEARER = /^Bearer ([0-9a-f]{64})$/; +const LOOPBACK_HOST = /^127\.0\.0\.1:([1-9]\d{0,4})$/; /** Close code for a session that broke the protocol. */ const POLICY_VIOLATION = 1008; const GOING_AWAY = 1001; @@ -158,20 +159,18 @@ export class Listener { }); } - private get origin(): string { - return `http://127.0.0.1:${String(this.options.port)}`; - } - - private hostAllowed(req: IncomingMessage): boolean { + /** The request's single `127.0.0.1:` Host value, any port so a forwarded one reaches the hub; else null. */ + private host(req: IncomingMessage): string | null { const hosts = headerValues(req, 'host'); - return hosts.length === 1 && hosts[0] === `127.0.0.1:${String(this.options.port)}`; + const match = hosts.length === 1 ? LOOPBACK_HOST.exec(hosts[0] ?? '') : null; + return match?.[1] !== undefined && Number(match[1]) <= 65535 ? (hosts[0] ?? null) : null; } - /** Whether the request carries no Origin or exactly the hub's own. */ + /** Whether the request carries no Origin or exactly its own Host's. */ private originAllowed(req: IncomingMessage, required: boolean): boolean { const origins = headerValues(req, 'origin'); if (origins.length === 0) return !required; - return origins.length === 1 && origins[0] === this.origin; + return origins.length === 1 && origins[0] === `http://${this.host(req) ?? ''}`; } private authorized(req: IncomingMessage): boolean { @@ -189,7 +188,7 @@ export class Listener { } private request(req: IncomingMessage, res: ServerResponse): void { - if (!this.hostAllowed(req)) { + if (this.host(req) === null) { this.reply(res, 403); return; } @@ -294,7 +293,7 @@ export class Listener { /** The status an upgrade is refused with, or null to accept it. */ private upgradeRefusal(req: IncomingMessage, credential: Credential | null): number | null { - if (!this.hostAllowed(req)) return 403; + if (this.host(req) === null) return 403; if (!this.ready) return 503; if ((req.url ?? '') !== '/ws') return 404; if (!this.originAllowed(req, true)) return 403; diff --git a/src/hub/server/tunnels.test.ts b/src/hub/server/tunnels.test.ts new file mode 100644 index 0000000..d70f0fa --- /dev/null +++ b/src/hub/server/tunnels.test.ts @@ -0,0 +1,71 @@ +import { connect, createServer } from 'node:net'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { TunnelListeners, type TunnelTarget } from './tunnels.js'; + +let tunnels: TunnelListeners; + +beforeEach(() => { + tunnels = new TunnelListeners({ + forward: () => ({ ended: Promise.resolve(null), close: () => undefined }), + current: () => true, + }); +}); + +afterEach(async () => { + await tunnels.close(); +}); + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => { + if (address === null || typeof address === 'string') reject(new Error('no port')); + else resolve(address.port); + }); + }); + }); + +/** Whether something accepts connections on `port`. */ +const listening = (port: number): Promise => + new Promise((resolve) => { + const socket = connect(port, '127.0.0.1'); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => { + resolve(false); + }); + }); + +const target = (port: number, host = 'box'): TunnelTarget => ({ host, alias: host, port, remotePort: 7417 }); + +it('binds a listener on the target port', async () => { + const port = await freePort(); + await tunnels.ensure(target(port), new AbortController().signal); + expect(await listening(port)).toBe(true); +}); + +it('changes nothing for an aborted signal: no listener bound, the host’s other one kept', async () => { + const [kept, port] = [await freePort(), await freePort()]; + await tunnels.ensure(target(kept), new AbortController().signal); + await tunnels.ensure(target(port), AbortSignal.abort()); + expect(await listening(port)).toBe(false); + expect(await listening(kept)).toBe(true); +}); + +it('binds nothing for an ensure aborted while it waits behind another', async () => { + const [first, second] = [await freePort(), await freePort()]; + const controller = new AbortController(); + const waiting = Promise.all([ + tunnels.ensure(target(first, 'a'), new AbortController().signal), + tunnels.ensure(target(second, 'b'), controller.signal), + ]); + controller.abort(); + await waiting; + expect(await listening(first)).toBe(true); + expect(await listening(second)).toBe(false); +}); diff --git a/src/hub/server/tunnels.ts b/src/hub/server/tunnels.ts new file mode 100644 index 0000000..58b1723 --- /dev/null +++ b/src/hub/server/tunnels.ts @@ -0,0 +1,177 @@ +import { request } from 'node:http'; +import { createServer, type Server, type Socket } from 'node:net'; +import { PortInUseError } from './listener.js'; + +/** What a tunnel listener forwards: its host, the SSH alias, its local port and the remote hub's port. */ +export interface TunnelTarget { + host: string; + alias: string; + port: number; + remotePort: number; +} + +/** One relayed connection. */ +export interface TunnelForward { + /** Resolves when it ended: null when the local side ended first, else the cause. */ + ended: Promise; + close: () => void; +} + +export interface TunnelOptions { + /** Relays `socket` to `remotePort` on `alias`. */ + forward: (alias: string, remotePort: number, socket: Socket) => TunnelForward; + /** Whether the configuration the hub last read still maps `target`'s host to its alias and port. */ + current: (target: Omit) => boolean; +} + +const PROBE_GRACE_MS = 1000; + +/** A loopback listener forwarding each connection to one remote hub. */ +class Tunnel { + private readonly forwards = new Set(); + /** The cause the latest forward ended with since the last reset, if it failed. */ + private lastFailure: string | null = null; + + constructor( + readonly server: Server, + public target: TunnelTarget, + private readonly options: TunnelOptions, + ) { + server.on('connection', (socket: Socket) => { + this.accept(socket); + }); + } + + failure(): string | null { + return this.lastFailure; + } + + resetFailure(): void { + this.lastFailure = null; + } + + /** Whether its forwards are all ended. */ + get idle(): boolean { + return this.forwards.size === 0; + } + + private accept(socket: Socket): void { + const { host, alias, port, remotePort } = this.target; + if (!this.options.current({ host, alias, port })) { + socket.destroy(); + return; + } + const forward = this.options.forward(alias, remotePort, socket); + this.forwards.add(forward); + void forward.ended.then((cause) => { + this.forwards.delete(forward); + if (cause !== null) this.lastFailure = cause; + }); + } + + async close(): Promise { + const closed = new Promise((resolve) => { + this.server.close(() => { + resolve(); + }); + }); + for (const forward of this.forwards) forward.close(); + await closed; + } +} + +const listen = (port: number): Promise => + new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', (error) => { + reject('code' in error && error.code === 'EADDRINUSE' ? new PortInUseError(port) : error); + }); + server.listen(port, '127.0.0.1', () => { + resolve(server); + }); + }); + +/** The hub's tunnel listeners, at most one per port, changed one at a time. */ +export class TunnelListeners { + private readonly tunnels = new Map(); + private queue: Promise = Promise.resolve(); + private closed = false; + + constructor(private readonly options: TunnelOptions) {} + + /** + * Makes a listener on `target.port` forward to `target`: binds one, repoints the existing one, and + * closes the host's listener on any other port. Rejects with PortInUseError when another process holds the port. + * Once `signal` aborts it changes nothing more. + */ + ensure(target: TunnelTarget, signal: AbortSignal): Promise { + // Read through a call: `signal` may abort while any await is pending. + const ended = (): boolean => signal.aborted; + return this.serialised(async () => { + if (this.closed) throw new Error('the hub is shutting down'); + if (ended()) return; + const moved = [...this.tunnels].filter(([port, tunnel]) => port !== target.port && tunnel.target.host === target.host); + for (const [port] of moved) this.tunnels.delete(port); + await Promise.all(moved.map(([, tunnel]) => tunnel.close())); + if (ended()) return; + const existing = this.tunnels.get(target.port); + if (existing !== undefined) { + existing.target = target; + return; + } + const tunnel = new Tunnel(await listen(target.port), target, this.options); + if (ended()) await tunnel.close(); + else this.tunnels.set(target.port, tunnel); + }); + } + + /** + * Fetches `GET /` through the listener on `port`; resolves null on status 200 within `timeoutMs`, + * else the failure: the forward's cause when it gave one. Aborting `signal` ends the request. + */ + async probe(port: number, timeoutMs: number, signal: AbortSignal): Promise { + const tunnel = this.tunnels.get(port); + if (tunnel === undefined) return `no tunnel listens on port ${String(port)}`; + tunnel.resetFailure(); + const failure = await new Promise((resolve) => { + const req = request( + { host: '127.0.0.1', port, path: '/', method: 'GET', headers: { Host: `127.0.0.1:${String(port)}` }, agent: false, signal }, + (res) => { + res.resume(); + req.destroy(); + resolve(res.statusCode === 200 ? null : `the hub through port ${String(port)} answered ${String(res.statusCode)}`); + }, + ); + req.setTimeout(timeoutMs, () => { + req.destroy(); + resolve(`the hub through port ${String(port)} did not answer within ${String(timeoutMs / 1000)} s`); + }); + req.on('error', (error) => { + resolve(`the hub through port ${String(port)} did not answer: ${error.message}`); + }); + req.end(); + }); + if (failure === null) return null; + const deadline = Date.now() + PROBE_GRACE_MS; + while (tunnel.failure() === null && !tunnel.idle && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); // NOSONAR(S9382) — waits for the probe's forward to report + } + return tunnel.failure() ?? failure; + } + + /** Closes every listener and ends every forward; later `ensure` calls fail. */ + close(): Promise { + return this.serialised(async () => { + this.closed = true; + const tunnels = [...this.tunnels.values()]; + this.tunnels.clear(); + await Promise.all(tunnels.map((tunnel) => tunnel.close())); + }); + } + + private serialised(run: () => Promise): Promise { + const next = this.queue.then(run); + this.queue = next.catch(() => undefined); + return next; + } +} diff --git a/src/platform/dialer/dialer.test.ts b/src/platform/dialer/dialer.test.ts index 0750250..d5c7a37 100644 --- a/src/platform/dialer/dialer.test.ts +++ b/src/platform/dialer/dialer.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { hostPaths, type HostPaths } from '../files/index.js'; -import { dial, spawnDetached, sshCommand } from './index.js'; +import { dial, spawnDetached, sshCommand, sshForwardCommand } from './index.js'; /** Stand-in daemon: logs to stdout and stderr, records its pid, serves its pid to every connection. */ const FAKE_DAEMON = ` @@ -285,6 +285,22 @@ describe('sshCommand', () => { const long = hostPaths({ env: { XDG_STATE_HOME: '/' + 'a'.repeat(44) }, home: '/nonexistent', host: 'b' }); expect(() => sshCommand(long, 'box', REMOTE)).toThrow(`${long.runDir}/ssh-%C`); }); + + describe('sshForwardCommand', () => { + it('puts the options, -W to the loopback port, -- and the alias, in that order', () => { + vi.stubEnv('WTD_SSH', undefined); + expect(sshForwardCommand(paths, 'box', 7420)).toEqual(['ssh', ...options(), '-W', '127.0.0.1:7420', '--', 'box']); + }); + + it('runs WTD_SSH when set', () => { + vi.stubEnv('WTD_SSH', '/opt/fake ssh'); + expect(sshForwardCommand(paths, 'box', 7420)[0]).toBe('/opt/fake ssh'); + }); + + it('refuses an option-like alias, naming it', () => { + expect(() => sshForwardCommand(paths, '-oProxyCommand=x', 7420)).toThrow('-oProxyCommand=x'); + }); + }); }); /** Stand-in program: records its pid, session and stdio targets, then idles. */ diff --git a/src/platform/dialer/index.ts b/src/platform/dialer/index.ts index fc09f11..64b823a 100644 --- a/src/platform/dialer/index.ts +++ b/src/platform/dialer/index.ts @@ -87,13 +87,30 @@ export const checkSshAlias = (alias: string): void => { } }; -/** The SSH command line running `remote` on `alias`, sharing a control master per host. */ -export const sshCommand = (paths: Pick, alias: string, remote: string): string[] => { +/** The SSH program and its options, sharing a control master per host. */ +const sshPrefix = (paths: Pick, alias: string): string[] => { checkSshAlias(alias); const options = [...SSH_OPTIONS, `ControlPath=${sshControlPath(paths)}`, 'ServerAliveInterval=15'].flatMap((o) => ['-o', o]); - return [nonEmpty(process.env['WTD_SSH']) ?? 'ssh', ...options, '--', alias, remote]; + return [nonEmpty(process.env['WTD_SSH']) ?? 'ssh', ...options]; }; +/** The SSH command line running `remote` on `alias`, sharing a control master per host. */ +export const sshCommand = (paths: Pick, alias: string, remote: string): string[] => [ + ...sshPrefix(paths, alias), + '--', + alias, + remote, +]; + +/** The SSH command line relaying its standard input and output to `127.0.0.1:` on `alias` (`ssh -W`). */ +export const sshForwardCommand = (paths: Pick, alias: string, port: number): string[] => [ + ...sshPrefix(paths, alias), + '-W', + `127.0.0.1:${String(port)}`, + '--', + alias, +]; + const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** The connected socket, or the error when no daemon serves `path`; other failures throw. */ diff --git a/src/protocol/constants.test.ts b/src/protocol/constants.test.ts index 8afc33b..7ee5a9e 100644 --- a/src/protocol/constants.test.ts +++ b/src/protocol/constants.test.ts @@ -23,7 +23,7 @@ describe('shared constants', () => { LAG_EVICT_MS, }).toEqual({ DAEMON_PROTOCOL_VERSION: 6, - BROWSER_PROTOCOL_VERSION: 9, + BROWSER_PROTOCOL_VERSION: 10, MAX_FRAME: 16 * 1024 * 1024, MAX_INPUT: 64 * 1024, FLOW_HIGH: 512 * 1024, diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts index c3804a9..1fc93ec 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -4,7 +4,7 @@ const MiB = 1024 * KiB; /** Version of the daemon link (client ↔ daemon), whose two ends can run different releases. */ export const DAEMON_PROTOCOL_VERSION = 6; /** Version of the browser link (browser ↔ hub); the hub serves the page, so a bump needs no daemon restart. */ -export const BROWSER_PROTOCOL_VERSION = 9; +export const BROWSER_PROTOCOL_VERSION = 10; /** Largest stream frame payload. */ export const MAX_FRAME = 16 * MiB; diff --git a/src/protocol/frozen.golden.json b/src/protocol/frozen.golden.json index b7efb06..7a4e0ee 100644 --- a/src/protocol/frozen.golden.json +++ b/src/protocol/frozen.golden.json @@ -21,5 +21,34 @@ { "dirs": ["clientToDaemon"], "json": "{\"t\":\"shutdown\"}", "valid": true }, { "dirs": ["clientToDaemon"], "json": "{\"t\":\"shutdown\",\"req\":1}", "valid": false }, { "dirs": ["daemonToClient", "browserToHub", "hubToBrowser"], "json": "{\"t\":\"shutdown\"}", "valid": false } + ], + "printCode": [ + { "text": "{\"port\":7417,\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"}\n", "value": { "port": 7417, "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" } }, + { "text": "{\"port\":65535,\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"}\n", "value": { "port": 65535, "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" } }, + { "text": "{\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\",\"port\":1024} \n", "value": { "port": 1024, "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" } }, + { "text": "{\"port\":7417,\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\",\"token\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"}\n", "value": null }, + { "text": "motd\n{\"port\":7417,\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"}\n", "value": null }, + { "text": "{\"port\":80,\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"}\n", "value": null }, + { "text": "{\"port\":7417.5,\"code\":\"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"}\n", "value": null }, + { "text": "{\"port\":7417,\"code\":\"x\"}\n", "value": null } + ], + "homeFragments": [ + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "value": { "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "hub": null } }, + { "text": "hub=box", "value": { "code": null, "hub": "box" } }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&hub=box", "value": { "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "hub": "box" } }, + { "text": "hub=a b", "value": null }, + { "text": "hub=box&hub=gpu", "value": null }, + { "text": "code=x", "value": null }, + { "text": "hub=box&token=x", "value": null }, + { "text": "", "value": null } + ], + "remoteFragments": [ + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box,gpu", "value": { "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "host": "box", "home": 7417, "hubs": ["box", "gpu"] } }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=gpu&home=65535&hubs=gpu", "value": { "code": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "host": "gpu", "home": 65535, "hubs": ["gpu"] } }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&hubs=box", "value": null }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=0080&hubs=box", "value": null }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=gpu", "value": null }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box&token=x", "value": null }, + { "text": "code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box,box", "value": null } ] } diff --git a/src/protocol/hub.test.ts b/src/protocol/hub.test.ts new file mode 100644 index 0000000..1e0fade --- /dev/null +++ b/src/protocol/hub.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeHomeFragment, + decodePrintCode, + decodeRemoteFragment, + encodeHomeFragment, + encodePrintCode, + encodeRemoteFragment, + ProtocolError, +} from './index.js'; +import { CODE, raw, utf8 } from './test-samples.js'; + +const names = (n: number): string[] => Array.from({ length: n }, (_, i) => `h${String(i)}`); + +describe('print-code line', () => { + it('round-trips through text and bytes, ending with exactly one newline', () => { + const line = { port: 7417, code: CODE }; + const text = encodePrintCode(line); + expect(text).toBe(`${raw(line)}\n`); + expect(text.endsWith('\n')).toBe(true); + expect(text.endsWith('\n\n')).toBe(false); + expect(decodePrintCode(text)).toEqual(line); + expect(decodePrintCode(utf8(text))).toEqual(line); + }); + + it.each([ + ['no newline', raw({ port: 7417, code: CODE })], + ['trailing whitespace', `${raw({ port: 7417, code: CODE })}\n \n\t`], + ['the fields in another order', `${raw({ code: CODE, port: 7417 })}\n`], + ])('decodes a line with %s', (_name, text) => { + expect(decodePrintCode(text)).toEqual({ port: 7417, code: CODE }); + }); + + it.each([1024, 65535])('accepts port %i', (port) => { + expect(decodePrintCode(`${raw({ port, code: CODE })}\n`)).toEqual({ port, code: CODE }); + }); + + it.each([ + ['an extra token field', `${raw({ port: 7417, code: CODE, token: CODE })}\n`], + ['a leading motd line', `motd\n${raw({ port: 7417, code: CODE })}\n`], + ['a second line', `${raw({ port: 7417, code: CODE })}\n${raw({ port: 7417, code: CODE })}\n`], + ['leading whitespace text', `x ${raw({ port: 7417, code: CODE })}\n`], + ['port 80', `${raw({ port: 80, code: CODE })}\n`], + ['port 1023', `${raw({ port: 1023, code: CODE })}\n`], + ['port 65536', `${raw({ port: 65536, code: CODE })}\n`], + ['port 7417.5', `${raw({ port: 7417.5, code: CODE })}\n`], + ['a string port', `${raw({ port: '7417', code: CODE })}\n`], + ['a malformed code', `${raw({ port: 7417, code: 'x' })}\n`], + ['an uppercase code', `${raw({ port: 7417, code: CODE.toUpperCase() })}\n`], + ['no code', `${raw({ port: 7417 })}\n`], + ['an array', `${raw([7417, CODE])}\n`], + ['invalid JSON', '{"port":7417,\n'], + ['nothing', ''], + ])('rejects a line with %s', (_name, text) => { + expect(() => decodePrintCode(text)).toThrow(ProtocolError); + }); + + it('rejects invalid UTF-8', () => { + const bytes = new Uint8Array([0xff, 0x0a]); + expect(() => decodePrintCode(bytes)).toThrow(ProtocolError); + }); + + it('refuses to encode a value that would not decode', () => { + expect(() => encodePrintCode({ port: 80, code: CODE })).toThrow(ProtocolError); + expect(() => encodePrintCode({ port: 7417, code: 'x' })).toThrow(ProtocolError); + }); +}); + +describe('home fragment', () => { + it.each([ + [`code=${CODE}`, { code: CODE, hub: null }], + ['hub=box', { code: null, hub: 'box' }], + [`code=${CODE}&hub=box`, { code: CODE, hub: 'box' }], + [`hub=box&code=${CODE}`, { code: CODE, hub: 'box' }], + [`hub=${'A-z.9_'.repeat(10)}abcd`, { code: null, hub: `${'A-z.9_'.repeat(10)}abcd` }], + ])('decodes %s', (text, value) => { + expect(decodeHomeFragment(text)).toEqual(value); + }); + + it.each([ + [{ code: CODE, hub: null }, `code=${CODE}`], + [{ code: null, hub: 'box' }, 'hub=box'], + [{ code: CODE, hub: 'box' }, `code=${CODE}&hub=box`], + ])('encodes %o as %s', (value, text) => { + expect(encodeHomeFragment(value)).toBe(text); + expect(decodeHomeFragment(text)).toEqual(value); + }); + + it.each([ + ['a name with a space', 'hub=a b'], + ['an encoded space', 'hub=a%20b'], + ['a plus', 'hub=a+b'], + ['a repeated hub', 'hub=box&hub=gpu'], + ['a repeated code', `code=${CODE}&code=${CODE}`], + ['a malformed code', 'code=x'], + ['a malformed code beside a hub', 'code=x&hub=a b'], + ['an empty hub', 'hub='], + ['a 65-character hub', `hub=${'x'.repeat(65)}`], + ['another parameter', 'hub=box&token=x'], + ['only another parameter', 'token=x'], + ['a parameter without value', 'hub'], + ['nothing', ''], + ['a leading #', '#hub=box'], + ])('rejects %s', (_name, text) => { + expect(() => decodeHomeFragment(text)).toThrow(ProtocolError); + }); + + it('refuses to encode an empty fragment or a malformed value', () => { + expect(() => encodeHomeFragment({ code: null, hub: null })).toThrow(ProtocolError); + expect(() => encodeHomeFragment({ code: null, hub: 'a b' })).toThrow(ProtocolError); + }); +}); + +describe('remote fragment', () => { + const TEXT = `code=${CODE}&host=box&home=7417&hubs=box,gpu`; + const VALUE = { code: CODE, host: 'box', home: 7417, hubs: ['box', 'gpu'] }; + + it('decodes the code, host, home port and hubs in order', () => { + expect(decodeRemoteFragment(TEXT)).toEqual(VALUE); + }); + + it('encodes to the canonical text with literal commas', () => { + expect(encodeRemoteFragment(VALUE)).toBe(TEXT); + }); + + it.each([ + ['parameters in another order', `hubs=box,gpu&home=7417&host=box&code=${CODE}`], + ['an encoded comma', `code=${CODE}&host=box&home=7417&hubs=box%2Cgpu`], + ])('decodes %s', (_name, text) => { + expect(decodeRemoteFragment(text)).toEqual(VALUE); + }); + + it('accepts 63 hubs and home ports 1024 and 65535', () => { + const hubs = names(63); + for (const home of [1024, 65535]) { + const value = { code: CODE, host: 'h62', home, hubs }; + expect(decodeRemoteFragment(encodeRemoteFragment(value))).toEqual(value); + } + }); + + it.each([ + ['no home', `code=${CODE}&host=box&hubs=box`], + ['no code', 'host=box&home=7417&hubs=box'], + ['no host', `code=${CODE}&home=7417&hubs=box`], + ['no hubs', `code=${CODE}&host=box&home=7417`], + ['home 0080', `code=${CODE}&host=box&home=0080&hubs=box`], + ['home 07417', `code=${CODE}&host=box&home=07417&hubs=box`], + ['home 80', `code=${CODE}&host=box&home=80&hubs=box`], + ['home 65536', `code=${CODE}&host=box&home=65536&hubs=box`], + ['home 7417.5', `code=${CODE}&host=box&home=7417.5&hubs=box`], + ['hubs not naming the host', `code=${CODE}&host=box&home=7417&hubs=gpu`], + ['duplicate hubs', `code=${CODE}&host=box&home=7417&hubs=box,box`], + ['an empty hub name', `code=${CODE}&host=box&home=7417&hubs=box,`], + ['64 hubs', `code=${CODE}&host=h0&home=7417&hubs=${names(64).join(',')}`], + ['an empty host', `code=${CODE}&host=&home=7417&hubs=box`], + ['a host with a space', `code=${CODE}&host=a b&home=7417&hubs=a b`], + ['a malformed code', 'code=x&host=box&home=7417&hubs=box'], + ['an extra token parameter', `code=${CODE}&host=box&home=7417&hubs=box&token=${CODE}`], + ['a repeated host', `code=${CODE}&host=box&host=box&home=7417&hubs=box`], + ['a home fragment', `code=${CODE}&hub=box`], + ['nothing', ''], + ])('rejects a fragment with %s', (_name, text) => { + expect(() => decodeRemoteFragment(text)).toThrow(ProtocolError); + }); + + it('refuses to encode a value that would not decode', () => { + expect(() => encodeRemoteFragment({ ...VALUE, hubs: ['gpu'] })).toThrow(ProtocolError); + expect(() => encodeRemoteFragment({ ...VALUE, home: 80 })).toThrow(ProtocolError); + }); +}); diff --git a/src/protocol/hub.ts b/src/protocol/hub.ts new file mode 100644 index 0000000..157fcd5 --- /dev/null +++ b/src/protocol/hub.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; +import { ProtocolError } from './errors.js'; +import { parseJson, toText } from './json.js'; +import { hostName, port, secret } from './values.js'; + +// The print-code line and both fragments pass between `wtd` installations of any versions: never change their shape. + +const MAX_HUBS = 63; + +const printCode = z.strictObject({ port, code: secret }); + +const homeFragment = z + .strictObject({ code: secret.nullable(), hub: hostName.nullable() }) + .refine((f) => f.code !== null || f.hub !== null, { message: 'needs a code or a hub' }); + +const remoteFragment = z + .strictObject({ code: secret, host: hostName, home: port, hubs: z.array(hostName).min(1).max(MAX_HUBS) }) + .refine((f) => new Set(f.hubs).size === f.hubs.length, { message: 'hubs repeat a name', path: ['hubs'] }) + .refine((f) => f.hubs.includes(f.host), { message: 'hubs must name the host', path: ['hubs'] }); + +export type PrintCode = z.infer; +export type HomeFragment = z.infer; +export type RemoteFragment = z.infer; + +const parse = (schema: z.ZodType, what: string, value: unknown): T => { + const result = schema.safeParse(value); + if (!result.success) throw new ProtocolError(`invalid ${what}: ${z.prettifyError(result.error)}`); + return result.data; +}; + +/** Decodes the print-code line from text or UTF-8 bytes holding it and at most trailing whitespace. */ +export const decodePrintCode = (data: string | Uint8Array): PrintCode => { + const text = toText(data).trimEnd(); + if (/^\s/.test(text)) throw new ProtocolError('invalid print-code line: leading text'); + return parse(printCode, 'print-code line', parseJson(text)); +}; + +/** Encodes the print-code line, ending with one newline; refuses values that would not decode. */ +export const encodePrintCode = (line: PrintCode): string => `${JSON.stringify(parse(printCode, 'print-code line', line))}\n`; + +const decodeComponent = (text: string): string => { + try { + return decodeURIComponent(text.replaceAll('+', ' ')); + } catch (error) { + throw new ProtocolError('invalid fragment: malformed percent-encoding', { cause: error }); + } +}; + +/** The parameters of form-encoded `text`, each of `keys` at most once and no other. */ +const parameters = (text: string, keys: readonly string[]): Map => { + const params = new Map(); + for (const part of text.split('&')) { + const eq = part.indexOf('='); + if (eq < 0) throw new ProtocolError('invalid fragment: parameter without value'); + const key = decodeComponent(part.slice(0, eq)); + if (!keys.includes(key)) throw new ProtocolError(`invalid fragment: unknown parameter ${JSON.stringify(key)}`); + if (params.has(key)) throw new ProtocolError(`invalid fragment: repeated parameter ${key}`); + params.set(key, decodeComponent(part.slice(eq + 1))); + } + return params; +}; + +/** Decodes a home page fragment: `code`, `hub`, or both. */ +export const decodeHomeFragment = (text: string): HomeFragment => { + const params = parameters(text, ['code', 'hub']); + return parse(homeFragment, 'home fragment', { code: params.get('code') ?? null, hub: params.get('hub') ?? null }); +}; + +/** Encodes a home page fragment; refuses values that would not decode. */ +export const encodeHomeFragment = (fragment: HomeFragment): string => { + const { code, hub } = parse(homeFragment, 'home fragment', fragment); + return [code === null ? [] : [`code=${code}`], hub === null ? [] : [`hub=${hub}`]].flat().join('&'); +}; + +const decimalPort = (text: string | undefined): number | undefined => + text !== undefined && /^[1-9]\d*$/.test(text) ? Number(text) : undefined; + +/** Decodes a remote page fragment: `code`, `host`, `home` and `hubs`. */ +export const decodeRemoteFragment = (text: string): RemoteFragment => { + const params = parameters(text, ['code', 'host', 'home', 'hubs']); + return parse(remoteFragment, 'remote fragment', { + code: params.get('code'), + host: params.get('host'), + home: decimalPort(params.get('home')), + hubs: params.get('hubs')?.split(','), + }); +}; + +/** Encodes a remote page fragment; refuses values that would not decode. */ +export const encodeRemoteFragment = (fragment: RemoteFragment): string => { + const { code, host, home, hubs } = parse(remoteFragment, 'remote fragment', fragment); + return `code=${code}&host=${host}&home=${String(home)}&hubs=${hubs.join(',')}`; +}; + +const OPENED_URL = /^http:\/\/127\.0\.0\.1:([1-9]\d*)\/#([^]*)$/; + +const isOpenedUrl = (url: string): boolean => { + const match = OPENED_URL.exec(url); + if (match === null || !port.safeParse(Number(match[1])).success) return false; + try { + decodeRemoteFragment(match[2] ?? ''); + return true; + } catch (error) { + if (error instanceof ProtocolError) return false; + throw error; + } +}; + +/** A remote hub page on a loopback port, carrying a remote fragment. */ +export const openedUrl = z.string().max(8192).refine(isOpenedUrl, { message: 'must be a loopback remote hub page' }); diff --git a/src/protocol/index.ts b/src/protocol/index.ts index c6aa96f..2a78b29 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -20,6 +20,17 @@ export { type DataFrame, type Frame, } from './frames.js'; +export { + decodeHomeFragment, + decodePrintCode, + decodeRemoteFragment, + encodeHomeFragment, + encodePrintCode, + encodeRemoteFragment, + type HomeFragment, + type PrintCode, + type RemoteFragment, +} from './hub.js'; export { layout as layoutSchema, type Layout } from './layout.js'; export { root as discoveryRootSchema } from './values.js'; export { diff --git a/src/protocol/json.ts b/src/protocol/json.ts new file mode 100644 index 0000000..8733221 --- /dev/null +++ b/src/protocol/json.ts @@ -0,0 +1,42 @@ +import { ProtocolError } from './errors.js'; + +// No valid message nests deeper; checked before parsing so hostile nesting cannot exhaust the stack. +const MAX_NESTING = 32; + +const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +const nestingOf = (text: string): number => { + let level = 0; + let max = 0; + let inString = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inString) { + if (c === '\\') i++; + else if (c === '"') inString = false; + } else if (c === '"') inString = true; + else if (c === '{' || c === '[') max = Math.max(max, ++level); + else if (c === '}' || c === ']') level--; + } + return max; +}; + +/** Parses JSON text, refusing hostile nesting. */ +export const parseJson = (text: string): unknown => { + if (nestingOf(text) > MAX_NESTING) throw new ProtocolError('message nests too deep'); + try { + return JSON.parse(text); + } catch (error) { + throw new ProtocolError('invalid JSON', { cause: error }); + } +}; + +/** The text of `data`, decoded as strict UTF-8 when given bytes. */ +export const toText = (data: string | Uint8Array): string => { + if (typeof data === 'string') return data; + try { + return utf8.decode(data); + } catch (error) { + throw new ProtocolError('invalid UTF-8', { cause: error }); + } +}; diff --git a/src/protocol/messages.test.ts b/src/protocol/messages.test.ts index b7c3fb7..47a2c3b 100644 --- a/src/protocol/messages.test.ts +++ b/src/protocol/messages.test.ts @@ -10,12 +10,14 @@ import { type Preset, } from './index.js'; import { + CODE, DIRECTIONS, featWorktree, hello, hosts, liveTerminal, MESSAGE_TYPES, + OPENED_URL, raw, REPO, samples, @@ -262,6 +264,10 @@ describe('request correlation', () => { ['browserToHub', { t: 'addPreset', req: 1, preset: { name: 'p', command: null } }], ['browserToHub', { t: 'removePreset', req: 1, name: 'p' }], ['browserToHub', { t: 'movePreset', req: 1, name: 'p', to: 0 }], + ['browserToHub', { t: 'openHub', req: 1, host: 1 }], + ['browserToHub', { t: 'installHub', req: 1, host: 1 }], + ['hubToBrowser', { t: 'hubLogin', req: 1, login: 'https://login.tailscale.com/a/abc123' }], + ['hubToBrowser', { t: 'hubOpened', req: 1, url: OPENED_URL }], ]; const fireAndForget: readonly (readonly [Direction, Record])[] = [ @@ -727,3 +733,96 @@ describe('hub code response', () => { expect(() => encodeCodeResponse({ code: 'xyz' })).toThrow(ProtocolError); }); }); + +describe('hub opening messages', () => { + const LOGIN = 'https://login.tailscale.com/a/abc123'; + const opened = (url: unknown): unknown => ({ t: 'hubOpened', req: 1, url }); + const FRAGMENT = `code=${CODE}&host=box&home=7417&hubs=box`; + + it.each([ + ['openHub', { t: 'openHub', req: 1, host: 1 }], + ['installHub', { t: 'installHub', req: 1, host: 1 }], + ])('accepts %s only from the browser', (_name, message) => { + expect(decodes('browserToHub', message)).toBe(true); + expectRejected('hubToBrowser', raw(message)); + expectRejected('clientToDaemon', raw(message)); + }); + + it.each([ + ['hubLogin', { t: 'hubLogin', req: 1, login: LOGIN }], + ['hubOpened', { t: 'hubOpened', req: 1, url: OPENED_URL }], + ])('accepts %s only from the hub', (_name, message) => { + expect(decodes('hubToBrowser', message)).toBe(true); + expectRejected('browserToHub', raw(message)); + expectRejected('daemonToClient', raw(message)); + }); + + it.each([ + ['a host index above 65535', { t: 'openHub', req: 1, host: 65536 }], + ['a negative host index', { t: 'installHub', req: 1, host: -1 }], + ['no host', { t: 'openHub', req: 1 }], + ['an extra field', { t: 'openHub', req: 1, host: 1, name: 'box' }], + ])('rejects a hub request with %s', (_name, message) => { + expectRejected('browserToHub', raw(message)); + }); + + it('decodes a hubOpened URL to its value unchanged', () => { + expect(decodeMessage('hubToBrowser', raw(opened(OPENED_URL)))).toEqual({ t: 'hubOpened', req: 1, url: OPENED_URL }); + }); + + it.each([1024, 7418, 65535])('accepts a hubOpened URL on port %i', (port) => { + expect(decodes('hubToBrowser', opened(`http://127.0.0.1:${String(port)}/#${FRAGMENT}`))).toBe(true); + }); + + it.each([ + ['a foreign host', `http://evil.example:7418/#${FRAGMENT}`], + ['localhost', `http://localhost:7418/#${FRAGMENT}`], + ['https', `https://127.0.0.1:7418/#${FRAGMENT}`], + ['port 80', `http://127.0.0.1:80/#${FRAGMENT}`], + ['port 1023', `http://127.0.0.1:1023/#${FRAGMENT}`], + ['port 65536', `http://127.0.0.1:65536/#${FRAGMENT}`], + ['a leading-zero port', `http://127.0.0.1:07418/#${FRAGMENT}`], + ['no port', `http://127.0.0.1/#${FRAGMENT}`], + ['a path other than /', `http://127.0.0.1:7418/x#${FRAGMENT}`], + ['a query', `http://127.0.0.1:7418/?a=1#${FRAGMENT}`], + ['credentials', `http://u@127.0.0.1:7418/#${FRAGMENT}`], + ['no fragment', 'http://127.0.0.1:7418/'], + ['a home fragment', `http://127.0.0.1:7418/#code=${CODE}&hub=box`], + ['a malformed remote fragment', `http://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=gpu`], + ['a javascript: URL', 'javascript:alert(1)'], + ['a number', 7418], + ])('rejects a hubOpened URL with %s', (_name, url) => { + expect(decodes('hubToBrowser', opened(url))).toBe(false); + }); + + it.each([ + ['a lookalike host', 'https://login.tailscale.com.evil/a/x'], + ['plain http', 'http://login.tailscale.com/a/x'], + ['a javascript: URL', 'javascript:alert(1)'], + ['1025 characters', LOGIN + 'x'.repeat(1025 - LOGIN.length)], + ['null', null], + ])('rejects a hubLogin URL that is %s', (_name, login) => { + expect(decodes('hubToBrowser', { t: 'hubLogin', req: 1, login })).toBe(false); + }); + + it('accepts a hubLogin URL of 1024 characters', () => { + expect(decodes('hubToBrowser', { t: 'hubLogin', req: 1, login: LOGIN + 'x'.repeat(1024 - LOGIN.length) })).toBe(true); + }); + + it.each(['hub-outdated', 'hub-failed'])('accepts the hub error code %s from the hub only', (code) => { + expect(decodes('hubToBrowser', { t: 'error', req: 1, host: 1, code, message: 'x' })).toBe(true); + expect(decodes('hubToBrowser', { t: 'error', req: null, host: null, code, message: 'x' })).toBe(true); + expectRejected('daemonToClient', raw({ t: 'error', req: 1, code, message: 'x' })); + expectRejected('hubToBrowser', raw({ t: 'host', host: 1, m: { t: 'error', req: 1, code, message: 'x' } })); + }); + + it('still accepts every daemon error code from the hub', () => { + for (const code of ['bad-message', 'unknown-host', 'busy', 'version-mismatch', 'host-unavailable', 'internal']) { + expect(decodes('hubToBrowser', { t: 'error', req: 1, host: 0, code, message: 'x' })).toBe(true); + } + }); + + it('rejects an unknown hub error code', () => { + expectRejected('hubToBrowser', raw({ t: 'error', req: 1, host: 0, code: 'hub-gone', message: 'x' })); + }); +}); diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index 56eb380..296719b 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; import { ProtocolError } from './errors.js'; +import { openedUrl } from './hub.js'; +import { parseJson, toText } from './json.js'; import { layout } from './layout.js'; import { count, @@ -8,6 +10,7 @@ import { errorMessage, head, hostIdx, + hubErrorCode, instance, loginUrl, longText, @@ -140,6 +143,8 @@ export const messageSchemas = { message('discoverRepos', { req, host: hostIdx }), message('restartDaemon', { req, host: hostIdx }), message('reinstallDaemon', { req, host: hostIdx }), + message('openHub', { req, host: hostIdx }), + message('installHub', { req, host: hostIdx }), message('addPreset', { req, preset }), message('removePreset', { req, name: presetName }), message('movePreset', { @@ -158,8 +163,10 @@ export const messageSchemas = { message('hosts', { hosts: z.array(hostEntry).max(MAX_HOSTS) }), message('presets', { presets: z.array(preset).max(MAX_PRESETS) }), message('done', { req }), - message('error', { req: req.nullable(), host: hostIdx.nullable(), code: errorCode, message: errorMessage }), + message('error', { req: req.nullable(), host: hostIdx.nullable(), code: hubErrorCode, message: errorMessage }), message('reposDiscovered', { req, host: hostIdx, repos }), + message('hubLogin', { req, login: loginUrl }), + message('hubOpened', { req, url: openedUrl }), ]), }; @@ -174,45 +181,6 @@ export type Terminal = z.infer; export type AttentionState = z.infer; export type Preset = z.infer; -// No valid message nests deeper; checked before parsing so hostile nesting cannot exhaust the stack. -const MAX_NESTING = 32; - -const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); - -const nestingOf = (text: string): number => { - let level = 0; - let max = 0; - let inString = false; - for (let i = 0; i < text.length; i++) { - const c = text[i]; - if (inString) { - if (c === '\\') i++; - else if (c === '"') inString = false; - } else if (c === '"') inString = true; - else if (c === '{' || c === '[') max = Math.max(max, ++level); - else if (c === '}' || c === ']') level--; - } - return max; -}; - -const parseJson = (text: string): unknown => { - if (nestingOf(text) > MAX_NESTING) throw new ProtocolError('message nests too deep'); - try { - return JSON.parse(text); - } catch (error) { - throw new ProtocolError('invalid JSON', { cause: error }); - } -}; - -const toText = (data: string | Uint8Array): string => { - if (typeof data === 'string') return data; - try { - return utf8.decode(data); - } catch (error) { - throw new ProtocolError('invalid UTF-8', { cause: error }); - } -}; - function validate(dir: D, value: unknown): MessageOf; function validate(dir: Direction, value: unknown): MessageOf { const result = messageSchemas[dir].safeParse(value); diff --git a/src/protocol/test-samples.ts b/src/protocol/test-samples.ts index 19463d1..17576ab 100644 --- a/src/protocol/test-samples.ts +++ b/src/protocol/test-samples.ts @@ -149,6 +149,10 @@ export const hosts: HostEntry[] = [ ]; export const TOKEN = '0123456789abcdef'.repeat(4); +export const CODE = 'fedcba9876543210'.repeat(4); + +/** A `hubOpened` URL for host `box` of a hub on port 7417. */ +export const OPENED_URL = `http://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=box,gpu`; export const clientToDaemonSamples: MessageOf<'clientToDaemon'>[] = [ hello, @@ -222,6 +226,8 @@ export const browserToHubSamples: MessageOf<'browserToHub'>[] = [ { t: 'addPreset', req: 7, preset: { name: 'login shell', command: null } }, { t: 'removePreset', req: 8, name: 'codex' }, { t: 'movePreset', req: 9, name: 'codex', to: 0 }, + { t: 'openHub', req: 10, host: 1 }, + { t: 'installHub', req: 11, host: 65535 }, ]; export const hubToBrowserSamples: MessageOf<'hubToBrowser'>[] = [ @@ -242,6 +248,11 @@ export const hubToBrowserSamples: MessageOf<'hubToBrowser'>[] = [ { t: 'error', req: null, host: null, code: 'version-mismatch', message: 'outdated' }, { t: 'error', req: 3, host: 0, code: 'host-unavailable', message: 'host 0 is reconnecting' }, { t: 'reposDiscovered', req: 3, host: 0, repos: [REPO] }, + { t: 'hubLogin', req: 4, login: 'https://login.tailscale.com/a/abc123' }, + { t: 'hubOpened', req: 5, url: OPENED_URL }, + { t: 'hubOpened', req: 6, url: `http://127.0.0.1:65535/#code=${CODE}&host=${'x'.repeat(64)}&home=1024&hubs=${'x'.repeat(64)}` }, + { t: 'error', req: 7, host: 1, code: 'hub-outdated', message: 'wtd on box is missing or too old to serve its hub' }, + { t: 'error', req: 8, host: 2, code: 'hub-failed', message: 'ssh: Could not resolve hostname box: Name or service not known' }, ]; export const samples: { readonly [D in Direction]: readonly MessageOf[] } = { @@ -298,8 +309,10 @@ export const MESSAGE_TYPES: Readonly> = { 'addPreset', 'removePreset', 'movePreset', + 'openHub', + 'installHub', ], - hubToBrowser: ['hello', 'token', 'host', 'hosts', 'presets', 'done', 'error', 'reposDiscovered'], + hubToBrowser: ['hello', 'token', 'host', 'hosts', 'presets', 'done', 'error', 'reposDiscovered', 'hubLogin', 'hubOpened'], }; /** Encodes a JSON value as control-message text, bypassing the protocol encoder. */ diff --git a/src/protocol/values.ts b/src/protocol/values.ts index 835b0e8..260256c 100644 --- a/src/protocol/values.ts +++ b/src/protocol/values.ts @@ -48,6 +48,12 @@ export const errorCode = z.enum([ 'host-unavailable', 'internal', ]); +/** Error codes of the hub-to-browser `error`: the daemon's and the hub opening's. */ +export const hubErrorCode = z.enum([...errorCode.options, 'hub-outdated', 'hub-failed']); +/** A remote host's name, safe in a URL fragment without encoding. */ +export const hostName = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/); +/** A port a hub or tunnel may listen on. */ +export const port = z.int().min(1024).max(65535); export const MAX_ENTRIES = 1024; export const MAX_ROOTS = 32; diff --git a/src/protocol/wire.golden.json b/src/protocol/wire.golden.json index 829415f..e2b1d45 100644 --- a/src/protocol/wire.golden.json +++ b/src/protocol/wire.golden.json @@ -1,7 +1,7 @@ { "protocolVersions": { "daemon": 6, - "browser": 9 + "browser": 10 }, "constants": { "MAX_FRAME": 16777216, @@ -2008,6 +2008,56 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "openHub" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "host": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "t", + "req", + "host" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "installHub" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "host": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "t", + "req", + "host" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -3252,7 +3302,9 @@ "version-mismatch", "not-a-repo", "host-unavailable", - "internal" + "internal", + "hub-outdated", + "hub-failed" ] }, "message": { @@ -3303,6 +3355,56 @@ "repos" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "hubLogin" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "login": { + "type": "string", + "maxLength": 1024, + "format": "starts_with", + "pattern": "^https:\\/\\/login\\.tailscale\\.com\\/.*" + } + }, + "required": [ + "t", + "req", + "login" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "hubOpened" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "url": { + "type": "string", + "maxLength": 8192 + } + }, + "required": [ + "t", + "req", + "url" + ], + "additionalProperties": false } ], "$defs": { @@ -3688,6 +3790,16 @@ "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"openHub\",\"req\":10,\"host\":1}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"installHub\",\"req\":11,\"host\":65535}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -3833,6 +3945,31 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubLogin\",\"req\":4,\"login\":\"https://login.tailscale.com/a/abc123\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":5,\"url\":\"http://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box,gpu\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":6,\"url\":\"http://127.0.0.1:65535/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&home=1024&hubs=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"error\",\"req\":7,\"host\":1,\"code\":\"hub-outdated\",\"message\":\"wtd on box is missing or too old to serve its hub\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"error\",\"req\":8,\"host\":2,\"code\":\"hub-failed\",\"message\":\"ssh: Could not resolve hostname box: Name or service not known\"}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -4123,6 +4260,66 @@ "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"openHub\",\"req\":1}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"openHub\",\"req\":1,\"host\":65536}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"installHub\",\"req\":1,\"host\":1,\"force\":true}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubLogin\",\"req\":1,\"login\":\"https://login.tailscale.com.evil/a/x\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://evil.example:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"https://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:80/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:7418/x#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"javascript:alert(1)\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-outdated\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-failed\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"error\",\"req\":1,\"host\":1,\"code\":\"hub-gone\",\"message\":\"x\"}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -4453,6 +4650,16 @@ "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"openHub\",\"req\":10,\"host\":1}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"installHub\",\"req\":11,\"host\":65535}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -4598,6 +4805,31 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubLogin\",\"req\":4,\"login\":\"https://login.tailscale.com/a/abc123\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":5,\"url\":\"http://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box,gpu\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":6,\"url\":\"http://127.0.0.1:65535/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&home=1024&hubs=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"error\",\"req\":7,\"host\":1,\"code\":\"hub-outdated\",\"message\":\"wtd on box is missing or too old to serve its hub\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"error\",\"req\":8,\"host\":2,\"code\":\"hub-failed\",\"message\":\"ssh: Could not resolve hostname box: Name or service not known\"}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -4888,6 +5120,66 @@ "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"openHub\",\"req\":1}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"openHub\",\"req\":1,\"host\":65536}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"installHub\",\"req\":1,\"host\":1,\"force\":true}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubLogin\",\"req\":1,\"login\":\"https://login.tailscale.com.evil/a/x\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://evil.example:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"https://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:80/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:7418/x#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"javascript:alert(1)\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-outdated\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-failed\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"error\",\"req\":1,\"host\":1,\"code\":\"hub-gone\",\"message\":\"x\"}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -5218,6 +5510,16 @@ "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", "valid": true }, + { + "dir": "browserToHub", + "json": "{\"t\":\"openHub\",\"req\":10,\"host\":1}", + "valid": true + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"installHub\",\"req\":11,\"host\":65535}", + "valid": true + }, { "dir": "browserToHub", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -5363,6 +5665,31 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubLogin\",\"req\":4,\"login\":\"https://login.tailscale.com/a/abc123\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":5,\"url\":\"http://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box,gpu\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":6,\"url\":\"http://127.0.0.1:65535/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&home=1024&hubs=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"error\",\"req\":7,\"host\":1,\"code\":\"hub-outdated\",\"message\":\"wtd on box is missing or too old to serve its hub\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"error\",\"req\":8,\"host\":2,\"code\":\"hub-failed\",\"message\":\"ssh: Could not resolve hostname box: Name or service not known\"}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -5653,6 +5980,66 @@ "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"openHub\",\"req\":1}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"openHub\",\"req\":1,\"host\":65536}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"installHub\",\"req\":1,\"host\":1,\"force\":true}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubLogin\",\"req\":1,\"login\":\"https://login.tailscale.com.evil/a/x\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://evil.example:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"https://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:80/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:7418/x#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"javascript:alert(1)\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-outdated\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-failed\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"error\",\"req\":1,\"host\":1,\"code\":\"hub-gone\",\"message\":\"x\"}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -5983,6 +6370,16 @@ "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"openHub\",\"req\":10,\"host\":1}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"installHub\",\"req\":11,\"host\":65535}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -6128,6 +6525,31 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": true }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubLogin\",\"req\":4,\"login\":\"https://login.tailscale.com/a/abc123\"}", + "valid": true + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":5,\"url\":\"http://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box,gpu\"}", + "valid": true + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":6,\"url\":\"http://127.0.0.1:65535/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&home=1024&hubs=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "valid": true + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"error\",\"req\":7,\"host\":1,\"code\":\"hub-outdated\",\"message\":\"wtd on box is missing or too old to serve its hub\"}", + "valid": true + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"error\",\"req\":8,\"host\":2,\"code\":\"hub-failed\",\"message\":\"ssh: Could not resolve hostname box: Name or service not known\"}", + "valid": true + }, { "dir": "hubToBrowser", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -6417,6 +6839,66 @@ "dir": "hubToBrowser", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"openHub\",\"req\":1}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"openHub\",\"req\":1,\"host\":65536}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"installHub\",\"req\":1,\"host\":1,\"force\":true}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubLogin\",\"req\":1,\"login\":\"https://login.tailscale.com.evil/a/x\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://evil.example:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"https://127.0.0.1:7418/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:80/#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"http://127.0.0.1:7418/x#code=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210&host=box&home=7417&hubs=box\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hubOpened\",\"req\":1,\"url\":\"javascript:alert(1)\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-outdated\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"error\",\"req\":1,\"code\":\"hub-failed\",\"message\":\"x\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"error\",\"req\":1,\"host\":1,\"code\":\"hub-gone\",\"message\":\"x\"}", + "valid": false } ] } diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index 3eb4e05..b183a40 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -4,7 +4,13 @@ import frozenJson from './frozen.golden.json' with { type: 'json' }; import wireJson from './wire.golden.json' with { type: 'json' }; import { ACK_EVERY, + decodeHomeFragment, decodeMessage, + decodePrintCode, + decodeRemoteFragment, + encodeHomeFragment, + encodePrintCode, + encodeRemoteFragment, encodeFrame, FLOW_HIGH, FLOW_LOW, @@ -19,7 +25,7 @@ import { type Direction, type Frame, } from './index.js'; -import { DIRECTIONS, featWorktree, hello, hosts, layout, liveTerminal, raw, REPO, samples, WT } from './test-samples.js'; +import { CODE, DIRECTIONS, featWorktree, hello, hosts, layout, liveTerminal, raw, REPO, samples, WT } from './test-samples.js'; const directionSchema = z.enum(['clientToDaemon', 'daemonToClient', 'browserToHub', 'hubToBrowser']); @@ -202,6 +208,18 @@ const EDGE_CASES: readonly unknown[] = [ }, ], }, + { t: 'openHub', req: 1 }, + { t: 'openHub', req: 1, host: 65536 }, + { t: 'installHub', req: 1, host: 1, force: true }, + { t: 'hubLogin', req: 1, login: 'https://login.tailscale.com.evil/a/x' }, + { t: 'hubOpened', req: 1, url: `http://evil.example:7418/#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: `https://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: `http://127.0.0.1:80/#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: `http://127.0.0.1:7418/x#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: 'javascript:alert(1)' }, + { t: 'error', req: 1, code: 'hub-outdated', message: 'x' }, + { t: 'error', req: 1, code: 'hub-failed', message: 'x' }, + { t: 'error', req: 1, host: 1, code: 'hub-gone', message: 'x' }, ]; const allJson = [...new Set([...DIRECTIONS.flatMap((dir) => samples[dir].map((m) => raw(m))), ...EDGE_CASES.map((m) => raw(m))])]; @@ -309,8 +327,8 @@ describe('wire golden', () => { await expect(JSON.stringify(current, null, 2) + '\n').toMatchFileSnapshot('./wire.golden.json'); }); - it('is blessed for daemon protocol 6 and browser protocol 9', () => { - expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 9 }); + it('is blessed for daemon protocol 6 and browser protocol 10', () => { + expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 10 }); }); it('records every sample as valid in its own direction', () => { @@ -323,11 +341,28 @@ describe('wire golden', () => { }); }); +const shapeSample = (value: T) => z.object({ text: z.string(), value: value.nullable() }); + const frozenSchema = z.object({ frames: z.array(z.object({ kind: z.number(), payload: z.string(), bytes: z.string() })), messages: z.array(z.object({ dirs: z.array(directionSchema), json: z.string(), valid: z.boolean() })), + printCode: z.array(shapeSample(z.object({ port: z.number(), code: z.string() }))).min(1), + homeFragments: z.array(shapeSample(z.object({ code: z.string().nullable(), hub: z.string().nullable() }))).min(1), + remoteFragments: z + .array(shapeSample(z.object({ code: z.string(), host: z.string(), home: z.number(), hubs: z.array(z.string()) }))) + .min(1), }); +/** The decoded value of `text`, or null when `decode` rejects it with a ProtocolError. */ +const decodedOrNull = (decode: (text: string) => T, text: string): T | null => { + try { + return decode(text); + } catch (error) { + if (!(error instanceof ProtocolError)) throw error; + return null; + } +}; + const hex = (bytes: Uint8Array): string => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); const fromHex = (text: string): Uint8Array => Uint8Array.from({ length: text.length / 2 }, (_, i) => parseInt(text.slice(i * 2, i * 2 + 2), 16)); @@ -353,3 +388,29 @@ describe('frozen handshake and frame header', () => { } }); }); + +describe('frozen print-code line and page fragments', () => { + const frozen = frozenSchema.parse(frozenJson); + + it.each(frozen.printCode)('decodes print-code line $text to $value', ({ text, value }) => { + expect(decodedOrNull(decodePrintCode, text)).toEqual(value); + if (value !== null) expect(encodePrintCode(value)).toBe(`{"port":${String(value.port)},"code":"${value.code}"}\n`); + }); + + it.each(frozen.homeFragments)('decodes home fragment "$text" to $value', ({ text, value }) => { + expect(decodedOrNull(decodeHomeFragment, text)).toEqual(value); + if (value !== null) expect(encodeHomeFragment(value)).toBe(text); + }); + + it.each(frozen.remoteFragments)('decodes remote fragment "$text" to $value', ({ text, value }) => { + expect(decodedOrNull(decodeRemoteFragment, text)).toEqual(value); + if (value !== null) expect(encodeRemoteFragment(value)).toBe(text); + }); + + it('holds valid and rejected samples of every shape', () => { + for (const samples of [frozen.printCode, frozen.homeFragments, frozen.remoteFragments]) { + expect(samples.some((s) => s.value === null)).toBe(true); + expect(samples.some((s) => s.value !== null)).toBe(true); + } + }); +}); diff --git a/src/web/client/connection.ts b/src/web/client/connection.ts index f511bf3..b95f645 100644 --- a/src/web/client/connection.ts +++ b/src/web/client/connection.ts @@ -14,7 +14,8 @@ import { } from '../../protocol/index.js'; import { reconnectDelay } from './backoff.js'; import { hostKey, TerminalMemory, type TerminalStorage } from './memory.js'; -import { PendingRequests } from './requests.js'; +import { keptRemote, readFragment, type RemotePage } from './page.js'; +import { OpeningRequests, PendingRequests } from './requests.js'; import { checkBundle } from './stale.js'; import { HubStore } from './store.js'; @@ -30,9 +31,12 @@ export type DaemonRequestBody = Unnumbered; /** A successful reply to a daemon request. */ export type DaemonReply = Extract; +/** What the hub sends about a hub opening or an install for one: a login link, the URL, done or an error. */ +export type HubOpeningMessage = Extract; type Reply = - { ok: true; m: DaemonReply | Extract } | { ok: false; code: ErrorCode; message: string }; + | { ok: true; m: DaemonReply | Extract } + | { ok: false; code: ErrorCode; message: string }; /** A request the daemon or the hub answered with an error. */ export class RequestError extends Error { @@ -53,7 +57,6 @@ interface Credential { const TOKEN_KEY = 'wtd.token'; const RELOADED_KEY = 'wtd.reloadedFor'; -const CODE_FRAGMENT = /^#code=([0-9a-f]{64})$/; const SECRET = /^[0-9a-f]{64}$/; const randomInstance = (): string => { @@ -92,6 +95,12 @@ export class HubClient { private readonly dataListeners = new Set<(host: number, frame: DataFrame) => void>(); private readonly eventListeners = new Set<(host: number, m: DaemonEvent) => void>(); private readonly connectedListeners = new Set<(host: number, instance: string, repos: readonly string[]) => void>(); + private readonly openingListeners = new Set<(m: HubOpeningMessage) => void>(); + private readonly openings = new OpeningRequests(); + /** The remote hub this page shows, kept across reloads; null on a home page. */ + remote: RemotePage | null = null; + /** The host whose hub the address asked a home page to open. */ + requestedHub: string | null = null; private readonly instance = randomInstance(); private readonly encoder = new TextEncoder(); private credential: Credential | null = null; @@ -107,12 +116,15 @@ export class HubClient { constructor(private readonly version: string) {} - /** Takes the one-time code from the address or the stored token, and connects. */ + /** Takes the fragment from the address, keeping a remote hub's values, then connects with its code or the stored token. */ start(): void { - const code = CODE_FRAGMENT.exec(location.hash)?.[1]; + const fragment = readFragment(location.hash); if (location.hash !== '') history.replaceState(null, '', `${location.pathname}${location.search}`); + this.remote = keptRemote(sessionStorage, fragment); + if (this.remote === null && fragment.kind === 'home') this.requestedHub = fragment.hub; + const code = fragment.kind === 'none' ? null : fragment.code; const token = sessionStorage.getItem(TOKEN_KEY); - if (code !== undefined) this.credential = { kind: 'code', secret: code }; + if (code !== null) this.credential = { kind: 'code', secret: code }; else if (token !== null && SECRET.test(token)) this.credential = { kind: 'token', secret: token }; if (this.credential === null) { this.stop('auth'); @@ -129,6 +141,40 @@ export class HubClient { this.eventListeners.add(listener); } + /** Called with each login link, URL, done and error the hub sends for a request. */ + onHubOpening(listener: (m: HubOpeningMessage) => void): () => void { + this.openingListeners.add(listener); + return () => { + this.openingListeners.delete(listener); + }; + } + + /** Asks the hub to open remote `host`'s hub, ending the previous opening; its messages reach `onHubOpening`. Null while disconnected. */ + openHub(host: number): number | null { + const req = this.hubOperation(host, (r) => ({ t: 'openHub', req: r, host })); + if (req === null) return null; + // The hub ends the previous opening without a reply. + const previous = this.openings.start(req); + if (previous !== null) this.pending.forget(previous); + return req; + } + + /** Asks the hub to install its bundle on remote `host`, leaving its daemon running; its reply reaches `onHubOpening`. */ + installHub(host: number): number | null { + return this.hubOperation(host, (req) => ({ t: 'installHub', req, host })); + } + + private hubOperation(host: number, message: (req: number) => BrowserMessage): number | null { + if (this.ws?.readyState !== WebSocket.OPEN) return null; + const { req, reply } = this.pending.add(null); + // Its outcome reaches the opening listeners; a closed connection ends it as an error there. + reply.catch((error: unknown) => { + this.opening({ t: 'error', req, host, code: 'internal', message: error instanceof Error ? error.message : String(error) }); + }); + this.sendMessage(message(req)); + return req; + } + /** Called whenever a host becomes connected to a daemon instance. */ onHostConnected(listener: (host: number, instance: string, repos: readonly string[]) => void): void { this.connectedListeners.add(listener); @@ -137,7 +183,7 @@ export class HubClient { /** Sends a daemon request to `host`; resolves with its reply, rejects with RequestError or when it cannot complete. */ async request(host: number, body: DaemonRequestBody): Promise { const m = await this.exchange(host, (req) => ({ t: 'host', host, m: { ...body, req } })); - if ('host' in m) throw new Error(`unexpected hub reply ${m.t}`); + if ('host' in m || m.t === 'hubOpened') throw new Error(`unexpected hub reply ${m.t}`); return m; } @@ -237,6 +283,7 @@ export class HubClient { this.hostStates = new Map(); this.store.sessionClosed(); this.pending.failAll('the connection to the hub closed'); + this.openings.clear(); if (this.stopped) return; this.store.status.value = 'reconnecting'; if (!opened) { @@ -309,13 +356,22 @@ export class HubClient { this.daemonEvent(m.host, m.m); return; case 'done': + case 'hubOpened': + this.pending.resolve(m.req, { ok: true, m }); + this.opening(m); + return; case 'reposDiscovered': this.pending.resolve(m.req, { ok: true, m }); return; + case 'hubLogin': + this.opening(m); + return; case 'error': - if (m.req === null || !this.pending.resolve(m.req, { ok: false, code: m.code, message: m.message })) { + // A superseded opening's error, sent before the hub ended it, is no notice. + if (m.req === null || (!this.pending.resolve(m.req, { ok: false, code: m.code, message: m.message }) && !this.openings.isSuperseded(m.req))) { this.store.notice.value = `${m.code}: ${m.message}`; } + if (m.req !== null) this.opening(m); return; case 'presets': this.store.setPresets(m.presets); @@ -323,6 +379,10 @@ export class HubClient { } } + private opening(m: HubOpeningMessage): void { + for (const listener of this.openingListeners) listener(m); + } + private hello(m: Extract): void { const check = checkBundle({ protocol: BROWSER_PROTOCOL_VERSION, version: this.version }, m, sessionStorage.getItem(RELOADED_KEY)); if (check === 'reload') { diff --git a/src/web/client/index.ts b/src/web/client/index.ts index 5d62ad7..ad0d4be 100644 --- a/src/web/client/index.ts +++ b/src/web/client/index.ts @@ -1,5 +1,14 @@ export { ACK_EVERY } from '../../protocol/index.js'; -export { HubClient, RequestError, type DaemonEvent, type DaemonNotice, type DaemonReply, type DaemonRequestBody } from './connection.js'; +export { + HubClient, + RequestError, + type DaemonEvent, + type DaemonNotice, + type DaemonReply, + type DaemonRequestBody, + type HubOpeningMessage, +} from './connection.js'; +export { homePageUrl, pageTitle, type RemotePage } from './page.js'; export { Restorer, type RestoreDeps } from './restore.js'; export { hostKey, TerminalMemory, type Recalled, type RememberedTerminal, type TerminalStorage } from './memory.js'; export { repoKey, SHORT_HEAD, worktreeLabel, type ConnectionStatus, type HubStore, type RepoState } from './store.js'; diff --git a/src/web/client/page.test.ts b/src/web/client/page.test.ts new file mode 100644 index 0000000..31fa8a4 --- /dev/null +++ b/src/web/client/page.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { homePageUrl, keptRemote, pageTitle, readFragment, type PageFragment, type RemotePage } from './page.js'; + +const CODE = '0123456789abcdef'.repeat(4); +const OTHER = 'f'.repeat(64); + +/** An in-memory stand-in for `sessionStorage`. */ +const mapStorage = (): Pick & { items: Map } => { + const items = new Map(); + return { + items, + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => { + items.set(key, value); + }, + }; +}; + +const remoteFragment = (host: string, hubs: string[], code = CODE): PageFragment => ({ kind: 'remote', code, host, home: 7417, hubs }); + +describe('readFragment', () => { + it('reads a remote fragment', () => { + expect(readFragment(`#code=${CODE}&host=box&home=7417&hubs=box,gpu`)).toEqual({ + kind: 'remote', + code: CODE, + host: 'box', + home: 7417, + hubs: ['box', 'gpu'], + }); + }); + + it.each([ + [`#code=${CODE}`, { code: CODE, hub: null }], + ['#hub=box', { code: null, hub: 'box' }], + [`#code=${CODE}&hub=box`, { code: CODE, hub: 'box' }], + ])('reads %s as a home fragment', (hash, fragment) => { + expect(readFragment(hash)).toEqual({ kind: 'home', ...fragment }); + }); + + it.each(['', '#'])('reads %j as no fragment', (hash) => { + expect(readFragment(hash)).toEqual({ kind: 'none' }); + }); + + it.each([ + '#code=x&hub=a b', + '#code=x&hub=a%20b', + '#hub=box&hub=gpu', + `#token=${CODE}`, + '#code=x', + `#code=${CODE}&host=box&hubs=box`, + `#code=${CODE}&host=box&home=0080&hubs=box`, + `#code=${CODE}&host=box&home=7417&hubs=gpu`, + ])('ignores the malformed fragment %s', (hash) => { + expect(readFragment(hash)).toEqual({ kind: 'none' }); + }); +}); + +describe('keptRemote', () => { + it('keeps a remote fragment and returns it', () => { + const storage = mapStorage(); + const kept: RemotePage = { host: 'box', home: 7417, hubs: ['box', 'gpu'] }; + expect(keptRemote(storage, remoteFragment('box', ['box', 'gpu']))).toEqual(kept); + expect(storage.items.size).toBeGreaterThan(0); + }); + + it('gives the kept values after a reload without a fragment', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box', 'gpu'])); + expect(keptRemote(storage, { kind: 'none' })).toEqual({ host: 'box', home: 7417, hubs: ['box', 'gpu'] }); + }); + + it('replaces the kept values with a new remote fragment', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box', 'gpu'])); + expect(keptRemote(storage, remoteFragment('gpu', ['gpu'], OTHER))).toEqual({ host: 'gpu', home: 7417, hubs: ['gpu'] }); + expect(keptRemote(storage, { kind: 'none' })).toEqual({ host: 'gpu', home: 7417, hubs: ['gpu'] }); + }); + + it('never keeps the one-time code', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box'])); + expect([...storage.items.values()].join('')).not.toContain(CODE); + }); + + it.each([{ kind: 'none' }, { kind: 'home', code: CODE, hub: null }, { kind: 'home', code: null, hub: 'box' }])( + 'is a home page for %j with nothing kept', + (fragment) => { + expect(keptRemote(mapStorage(), fragment)).toBeNull(); + }, + ); + + it('ignores a kept value that does not parse', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box'])); + for (const key of storage.items.keys()) storage.items.set(key, '{"host":"a b","home":1,"hubs":[]}'); + expect(keptRemote(storage, { kind: 'none' })).toBeNull(); + for (const key of storage.items.keys()) storage.items.set(key, 'not json'); + expect(keptRemote(storage, { kind: 'none' })).toBeNull(); + }); +}); + +describe('pageTitle', () => { + it('titles a home page worktree-term', () => { + expect(pageTitle(null)).toBe('worktree-term'); + }); + + it('titles a remote page by its host', () => { + expect(pageTitle({ host: 'devbox', home: 7417, hubs: ['devbox'] })).toBe('worktree-term — devbox'); + }); +}); + +describe('homePageUrl', () => { + it('links the home hub’s page without a fragment', () => { + expect(homePageUrl(7417, null)).toBe('http://127.0.0.1:7417/'); + }); + + it('asks the home hub to open a host’s hub', () => { + expect(homePageUrl(7417, 'gpu')).toBe('http://127.0.0.1:7417/#hub=gpu'); + expect(readFragment('#hub=gpu')).toEqual({ kind: 'home', code: null, hub: 'gpu' }); + }); +}); diff --git a/src/web/client/page.ts b/src/web/client/page.ts new file mode 100644 index 0000000..1ec50fb --- /dev/null +++ b/src/web/client/page.ts @@ -0,0 +1,74 @@ +import { decodeHomeFragment, decodeRemoteFragment, encodeHomeFragment, encodeRemoteFragment, ProtocolError } from '../../protocol/index.js'; + +/** The page's URL fragment: a remote hub page's, a home page's, or none (also for a malformed one). */ +export type PageFragment = + | { kind: 'remote'; code: string; host: string; home: number; hubs: string[] } + | { kind: 'home'; code: string | null; hub: string | null } + | { kind: 'none' }; + +/** What makes a page a remote hub's: its host, its home hub's port and the hubs its menu lists. */ +export interface RemotePage { + host: string; + home: number; + hubs: string[]; +} + +const REMOTE_KEY = 'wtd.remote'; +/** Stands in for the one-time code, never kept, when checking kept values against the fragment's rules. */ +const PLACEHOLDER_CODE = '0'.repeat(64); + +const attempt = (decode: () => T): T | null => { + try { + return decode(); + } catch (error) { + if (error instanceof ProtocolError) return null; + throw error; + } +}; + +/** Reads `location.hash` as a remote fragment, else a home fragment, else none. */ +export const readFragment = (hash: string): PageFragment => { + const text = hash.startsWith('#') ? hash.slice(1) : hash; + if (text === '') return { kind: 'none' }; + const remote = attempt(() => decodeRemoteFragment(text)); + if (remote !== null) return { kind: 'remote', ...remote }; + const home = attempt(() => decodeHomeFragment(text)); + return home === null ? { kind: 'none' } : { kind: 'home', ...home }; +}; + +const parseKept = (text: string): RemotePage | null => { + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } + if (typeof value !== 'object' || value === null || !('host' in value) || !('home' in value) || !('hubs' in value)) return null; + const { host, home, hubs } = value; + if (typeof host !== 'string' || typeof home !== 'number' || !Array.isArray(hubs) || !hubs.every((h) => typeof h === 'string')) { + return null; + } + const page: RemotePage = { host, home, hubs }; + return attempt(() => encodeRemoteFragment({ code: PLACEHOLDER_CODE, ...page })) === null ? null : page; +}; + +/** Keeps a remote fragment's values in `storage`, replacing earlier ones; gives the page's kept values, null on a home page. */ +export const keptRemote = (storage: Pick, fragment: PageFragment): RemotePage | null => { + if (fragment.kind === 'remote') { + const page: RemotePage = { host: fragment.host, home: fragment.home, hubs: fragment.hubs }; + storage.setItem(REMOTE_KEY, JSON.stringify(page)); + return page; + } + const kept = storage.getItem(REMOTE_KEY); + return kept === null ? null : parseKept(kept); +}; + +/** The page title: `worktree-term`, or `worktree-term — ` on a remote hub's page. */ +export const pageTitle = (remote: RemotePage | null): string => (remote === null ? 'worktree-term' : `worktree-term — ${remote.host}`); + +/** The home hub's page on `home`, asking it to open `hub`'s hub when given. */ +export const homePageUrl = (home: number, hub: string | null): string => { + const fragment = hub === null ? '' : `#${encodeHomeFragment({ code: null, hub })}`; + return `http://127.0.0.1:${String(home)}/${fragment}`; +}; diff --git a/src/web/client/requests.test.ts b/src/web/client/requests.test.ts index 318e2ae..b2caacb 100644 --- a/src/web/client/requests.test.ts +++ b/src/web/client/requests.test.ts @@ -1,5 +1,37 @@ import { describe, expect, it } from 'vitest'; -import { PendingRequests } from './requests.js'; +import { OpeningRequests, PendingRequests } from './requests.js'; + +describe('OpeningRequests', () => { + it('returns the opening each new one supersedes, none for the first', () => { + const openings = new OpeningRequests(); + expect(openings.start(1)).toBeNull(); + expect(openings.start(4)).toBe(1); + expect(openings.start(9)).toBe(4); + }); + + it('knows the superseded openings, not the latest or unrelated requests', () => { + const openings = new OpeningRequests(); + openings.start(1); + openings.start(2); + openings.start(3); + expect([1, 2, 3, 7].map((req) => openings.isSuperseded(req))).toEqual([true, true, false, false]); + }); + + it('keeps only the 8 most recently superseded openings', () => { + const openings = new OpeningRequests(); + for (let req = 1; req <= 10; req++) openings.start(req); + expect([1, 2, 3, 9].map((req) => openings.isSuperseded(req))).toEqual([false, true, true, true]); + }); + + it('forgets everything on clear, so the next opening supersedes none', () => { + const openings = new OpeningRequests(); + openings.start(1); + openings.start(2); + openings.clear(); + expect(openings.isSuperseded(1)).toBe(false); + expect(openings.start(3)).toBeNull(); + }); +}); describe('PendingRequests', () => { it('numbers requests from 1 and delivers each reply to its request', async () => { @@ -35,6 +67,22 @@ describe('PendingRequests', () => { expect(await hub.reply).toBe('hub'); }); + it('forgets a request: neither a reply nor a failure reaches it', async () => { + const pending = new PendingRequests(); + const forgotten = pending.add(null); + const kept = pending.add(null); + let settled = false; + forgotten.reply.then( + () => (settled = true), + () => (settled = true), + ); + pending.forget(forgotten.req); + expect(pending.resolve(forgotten.req, 'late')).toBe(false); + pending.failAll('closed'); + await expect(kept.reply).rejects.toThrow('closed'); + expect(settled).toBe(false); + }); + it('fails every pending request when the session closes', async () => { const pending = new PendingRequests(); const requests = [pending.add(0), pending.add(3), pending.add(null)]; diff --git a/src/web/client/requests.ts b/src/web/client/requests.ts index 4f43fa0..f938d35 100644 --- a/src/web/client/requests.ts +++ b/src/web/client/requests.ts @@ -32,6 +32,11 @@ export class PendingRequests { return true; } + /** Drops request `req` unsettled: no reply or failure reaches it. */ + forget(req: number): void { + this.entries.delete(req); + } + /** Fails the requests to `host`. */ failHost(host: number, message: string): void { for (const [req, entry] of this.entries) { @@ -47,3 +52,33 @@ export class PendingRequests { for (const entry of entries) entry.reject(new Error(message)); } } + +const MAX_SUPERSEDED = 8; + +/** The latest hub opening's request, and the few it superseded whose reply may still be in flight. */ +export class OpeningRequests { + private latest: number | null = null; + private readonly superseded: number[] = []; + + /** Records `req` as the latest opening; returns the opening it supersedes, if any. */ + start(req: number): number | null { + const previous = this.latest; + this.latest = req; + if (previous !== null) { + this.superseded.push(previous); + if (this.superseded.length > MAX_SUPERSEDED) this.superseded.shift(); + } + return previous; + } + + /** Whether `req` is a recently superseded opening. */ + isSuperseded(req: number): boolean { + return this.superseded.includes(req); + } + + /** Forgets every opening, as when the session closes. */ + clear(): void { + this.latest = null; + this.superseded.length = 0; + } +} diff --git a/src/web/main/index.ts b/src/web/main/index.ts index 951b34b..20fd7b4 100644 --- a/src/web/main/index.ts +++ b/src/web/main/index.ts @@ -1,5 +1,5 @@ import { h, render } from 'preact'; -import { HubClient, RequestError, Restorer } from '../client/index.js'; +import { HubClient, pageTitle, RequestError, Restorer } from '../client/index.js'; import { TerminalManager } from '../terminals/index.js'; import { App, View } from '../ui/index.js'; @@ -56,3 +56,4 @@ const root = document.getElementById('app'); if (root === null) throw new Error('no #app element'); render(h(App, { client, manager, view }), root); client.start(); +document.title = pageTitle(client.remote); diff --git a/src/web/main/styles.css b/src/web/main/styles.css index f9e0b50..b95959f 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -97,6 +97,52 @@ input:focus-visible, margin-right: 8px; } +.hubs { + position: relative; +} + +.hubs-list { + position: absolute; + top: 34px; + right: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 160px; + padding: 4px; + background: var(--panel-raised); + border: 1px solid #3a414c; + border-radius: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 50%); +} + +.hubs-entry { + text-align: left; +} + +.hubs-entry.marked { + box-shadow: inset 2px 0 var(--accent); +} + +.hub-status { + margin-right: 8px; +} + +.hub-status button, +.hub-status a.button { + margin-left: 6px; + border-color: var(--text); +} + +.hub-status a.button { + display: inline-block; + color: inherit; + text-decoration: none; + border: 1px solid var(--text); + padding: 5px 9px; + border-radius: 5px; +} + .banner { padding: 6px 10px; background: #5a1d1d; diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index 660ccd2..00eeebd 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -5,6 +5,7 @@ import { repoKey, type HubClient } from '../client/index.js'; import { termsOf, type Divider } from '../layout/index.js'; import { followArea, type TerminalManager } from '../terminals/index.js'; import { hasBanner, hostAction, statusText } from './hosts.js'; +import { hubLineText, hubsMenu, hubStep, initialHubState, type HubAction, type HubLine, type HubTarget } from './hubs.js'; import { aggregate, markTitle, worktreeTitle, type Aggregate } from './marks.js'; import { dropPosition, pickerKey, presetFromForm } from './picker.js'; import { SIDEBAR_WIDTH, type SidebarEntry } from './sidebar.js'; @@ -81,6 +82,137 @@ const HostBanner = ({ host, view }: { host: HostEntry; view: View }) => { ); }; +/** The status line of the latest hub opening, with its login link, reinstall button or ×. */ +const HubStatus = ({ line, install, dismiss }: { line: HubLine; install: () => void; dismiss: () => void }) => ( + + {hubLineText(line)} + {line.kind === 'login' && ( + <> + {' '} + + Open login link + + + )} + {line.kind === 'outdated' && ( + <> + {' '} + + + )} + {line.kind === 'failed' && ( + + )} + +); + +/** The Hubs menu and the status line of the latest hub opening; a home page opens hubs, a remote page links home. */ +const Hubs = ({ client }: { client: HubClient }) => { + const [state, setState] = useState(initialHubState); + const [open, setOpen] = useState(false); + const stateRef = useRef(state); + const menuButton = useRef(null); + const hosts = client.store.hosts.value; + const dispatch = (action: HubAction): void => { + const { state: next, effect } = hubStep(stateRef.current, action); + stateRef.current = next; + setState(next); + if (effect?.t === 'navigate') location.assign(effect.url); + else if (effect?.t === 'reopen') openHub(effect.host, effect.name); + }; + const openHub = (host: number, name: string): void => { + const req = client.openHub(host); + if (req !== null) dispatch({ t: 'open', req, host, name }); + }; + useEffect( + () => + client.onHubOpening((m) => { + dispatch({ t: 'reply', m }); + }), + [client], + ); + useEffect(() => { + const name = client.requestedHub; + if (name === null || client.store.status.value !== 'open' || hosts.length === 0) return; + client.requestedHub = null; + const host = hosts.find((h) => h.remote && h.name === name); + if (host === undefined) dispatch({ t: 'unknown', name }); + else openHub(host.idx, host.name); + }, [hosts]); + const entries = hubsMenu(client.remote, hosts); + const pick = (target: HubTarget): void => { + setOpen(false); + if (target.t === 'navigate') location.assign(target.url); + else if (target.t === 'open') openHub(target.host, target.name); + }; + const closeOnEscape = (event: TargetedKeyboardEvent): void => { + if (event.key !== 'Escape') return; + event.stopPropagation(); + setOpen(false); + // The focused entry goes with the menu: keep the keyboard on the Hubs button. + menuButton.current?.focus(); + }; + const install = (): void => { + const pending = stateRef.current.line; + const host = hosts.find((h) => h.remote && h.name === pending?.name); + const req = host === undefined ? null : client.installHub(host.idx); + if (host !== undefined && req !== null) dispatch({ t: 'install', req, host: host.idx, name: host.name }); + }; + return ( + <> + {state.line !== null && ( + { + dispatch({ t: 'dismiss' }); + }} + /> + )} + {entries !== null && ( + + + {open && ( + + )} + + )} + + ); +}; + /** Handles Enter and Escape on a dialog: Enter confirms unless a button has the focus, Escape cancels. */ const dialogKeys = (confirm: (() => void) | null, cancel: () => void) => @@ -355,11 +487,7 @@ const WorktreeEntry = ({ }} /> )} - @@ -1108,6 +1236,7 @@ export const App = ({ client, manager, view }: AppProps): JSX.Element => { + Add repo + {status === 'reconnecting' && ( Reconnecting… diff --git a/src/web/ui/hubs.test.ts b/src/web/ui/hubs.test.ts new file mode 100644 index 0000000..49453b8 --- /dev/null +++ b/src/web/ui/hubs.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import type { MessageOf } from '../../protocol/index.js'; +import { hubLineText, hubsMenu, hubStep, initialHubState, type HubAction, type HubEffect, type HubState } from './hubs.js'; + +type HubMessage = MessageOf<'hubToBrowser'>; + +const CODE = '0123456789abcdef'.repeat(4); +const LOGIN = 'https://login.tailscale.com/a/abc123'; +const BOX_URL = `http://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=box,gpu`; + +const local = { idx: 0, name: 'laptop', remote: false }; +const box = { idx: 1, name: 'box', remote: true }; +const gpu = { idx: 2, name: 'gpu', remote: true }; + +/** Applies `actions` in order from the initial state, collecting every effect. */ +const run = (...actions: HubAction[]): { state: HubState; effects: HubEffect[] } => { + let state = initialHubState; + const effects: HubEffect[] = []; + for (const action of actions) { + const step = hubStep(state, action); + state = step.state; + effects.push(step.effect); + } + return { state, effects }; +}; + +const reply = (m: HubMessage): HubAction => ({ t: 'reply', m }); +const openBox = (req = 1): HubAction => ({ t: 'open', req, host: 1, name: 'box' }); +const failed = (req: number, code: Extract['code'], message: string): HubAction => + reply({ t: 'error', req, host: 1, code, message }); + +describe('hubsMenu', () => { + it('lists This machine, marked, then each remote host on a home page', () => { + expect(hubsMenu(null, [local, box, gpu])).toEqual([ + { label: 'This machine', marked: true, target: { t: 'stay' } }, + { label: 'box', marked: false, target: { t: 'open', host: 1, name: 'box' } }, + { label: 'gpu', marked: false, target: { t: 'open', host: 2, name: 'gpu' } }, + ]); + }); + + it('shows no menu without remote hosts', () => { + expect(hubsMenu(null, [local])).toBeNull(); + expect(hubsMenu(null, [])).toBeNull(); + }); + + it('routes a remote page back through its home hub, marking its own host', () => { + expect(hubsMenu({ host: 'box', home: 7417, hubs: ['box', 'gpu'] }, [local])).toEqual([ + { label: 'This machine', marked: false, target: { t: 'navigate', url: 'http://127.0.0.1:7417/' } }, + { label: 'box', marked: true, target: { t: 'stay' } }, + { label: 'gpu', marked: false, target: { t: 'navigate', url: 'http://127.0.0.1:7417/#hub=gpu' } }, + ]); + }); + + it('lists a remote page’s kept hubs, not its session’s hosts', () => { + const entries = hubsMenu({ host: 'gpu', home: 9000, hubs: ['gpu'] }, [local, box]); + expect(entries?.map((e) => e.label)).toEqual(['This machine', 'gpu']); + expect(entries?.[0]?.target).toEqual({ t: 'navigate', url: 'http://127.0.0.1:9000/' }); + }); +}); + +describe('hubLineText', () => { + it.each([ + [{ kind: 'opening', name: 'box' }, 'Opening box…'], + [{ kind: 'login', name: 'box', login: LOGIN }, 'box needs Tailscale login'], + [{ kind: 'outdated', name: 'box', message: 'wtd is too old' }, 'box: wtd is too old'], + [{ kind: 'installing', name: 'box' }, 'Installing on box…'], + [{ kind: 'failed', name: 'box', message: 'ssh: Could not resolve hostname box' }, 'box: ssh: Could not resolve hostname box'], + [{ kind: 'unknown', name: 'nope' }, 'no host named nope'], + ] as const)('shows %j as %j', (line, text) => { + expect(hubLineText(line)).toBe(text); + }); +}); + +describe('hubStep', () => { + it('starts with no line and nothing pending', () => { + expect(initialHubState).toEqual({ line: null, pending: null }); + }); + + it('shows Opening while the opening waits', () => { + const { state, effects } = run(openBox()); + expect(state.line).toEqual({ kind: 'opening', name: 'box' }); + expect(state.pending).toEqual({ req: 1, host: 1, name: 'box' }); + expect(effects).toEqual([null]); + }); + + it('shows the login link of the pending opening', () => { + const { state } = run(openBox(), reply({ t: 'hubLogin', req: 1, login: LOGIN })); + expect(state.line).toEqual({ kind: 'login', name: 'box', login: LOGIN }); + }); + + it('navigates to the URL of the pending opening', () => { + const { effects } = run(openBox(), reply({ t: 'hubLogin', req: 1, login: LOGIN }), reply({ t: 'hubOpened', req: 1, url: BOX_URL })); + expect(effects).toEqual([null, null, { t: 'navigate', url: BOX_URL }]); + }); + + it('ignores replies to an earlier pick', () => { + const { state, effects } = run( + openBox(1), + { t: 'open', req: 2, host: 2, name: 'gpu' }, + reply({ t: 'hubOpened', req: 1, url: BOX_URL }), + reply({ t: 'hubLogin', req: 1, login: LOGIN }), + failed(1, 'hub-failed', 'late'), + ); + expect(state.line).toEqual({ kind: 'opening', name: 'gpu' }); + expect(effects).toEqual([null, null, null, null, null]); + }); + + it('offers a reinstall for an outdated host', () => { + const { state } = run(openBox(), failed(1, 'hub-outdated', 'wtd on the host is missing or too old to serve its hub')); + expect(state.line).toEqual({ kind: 'outdated', name: 'box', message: 'wtd on the host is missing or too old to serve its hub' }); + }); + + it('shows Installing, then reopens the hub once the install is done', () => { + const { state, effects } = run(openBox(1), failed(1, 'hub-outdated', 'too old'), { t: 'install', req: 2, host: 1, name: 'box' }); + expect(state.line).toEqual({ kind: 'installing', name: 'box' }); + expect(effects.at(-1)).toBeNull(); + expect(hubStep(state, reply({ t: 'done', req: 2 })).effect).toEqual({ t: 'reopen', host: 1, name: 'box' }); + }); + + it('shows a failed install with its reason', () => { + const { state, effects } = run( + openBox(1), + failed(1, 'hub-outdated', 'too old'), + { t: 'install', req: 2, host: 1, name: 'box' }, + failed(2, 'internal', 'install: no Node'), + ); + expect(state.line).toEqual({ kind: 'failed', name: 'box', message: 'install: no Node' }); + expect(effects.at(-1)).toBeNull(); + }); + + it('ignores a done for an opening', () => { + const { state, effects } = run(openBox(), reply({ t: 'done', req: 1 })); + expect(state.line).toEqual({ kind: 'opening', name: 'box' }); + expect(effects).toEqual([null, null]); + }); + + it.each([ + ['hub-failed', 'ssh: Could not resolve hostname box'], + ['unknown-host', 'no such host'], + ] as const)('shows a %s error with its reason until dismissed', (code, message) => { + const { state } = run(openBox(), failed(1, code, message)); + expect(state.line).toEqual({ kind: 'failed', name: 'box', message }); + expect(state.pending).toBeNull(); + expect(hubStep(state, { t: 'dismiss' }).state.line).toBeNull(); + }); + + it('shows an unknown hub name', () => { + const { state, effects } = run({ t: 'unknown', name: 'nope' }); + expect(state.line).toEqual({ kind: 'unknown', name: 'nope' }); + expect(effects).toEqual([null]); + }); + + it('drops a pending opening when an unknown name follows it', () => { + const { state, effects } = run(openBox(), { t: 'unknown', name: 'nope' }, reply({ t: 'hubOpened', req: 1, url: BOX_URL })); + expect(state.line).toEqual({ kind: 'unknown', name: 'nope' }); + expect(effects.at(-1)).toBeNull(); + }); +}); diff --git a/src/web/ui/hubs.ts b/src/web/ui/hubs.ts new file mode 100644 index 0000000..1a1323a --- /dev/null +++ b/src/web/ui/hubs.ts @@ -0,0 +1,128 @@ +import type { MessageOf } from '../../protocol/index.js'; +import { homePageUrl, type RemotePage } from '../client/index.js'; + +type HubMessage = MessageOf<'hubToBrowser'>; + +/** Where picking a Hubs menu entry goes: nowhere, another page, or an opening of a host's hub. */ +export type HubTarget = { t: 'stay' } | { t: 'navigate'; url: string } | { t: 'open'; host: number; name: string }; + +export interface HubsEntry { + label: string; + /** The page's own hub. */ + marked: boolean; + target: HubTarget; +} + +const THIS_MACHINE = 'This machine'; + +/** The Hubs menu: on a home page its session's remote hosts, on a remote page its kept hubs; null without a remote host. */ +export const hubsMenu = ( + remote: RemotePage | null, + hosts: readonly { idx: number; name: string; remote: boolean }[], +): HubsEntry[] | null => { + if (remote !== null) { + return [ + { label: THIS_MACHINE, marked: false, target: { t: 'navigate', url: homePageUrl(remote.home, null) } }, + ...remote.hubs.map((name) => + name === remote.host + ? { label: name, marked: true, target: { t: 'stay' } as const } + : { label: name, marked: false, target: { t: 'navigate', url: homePageUrl(remote.home, name) } as const }, + ), + ]; + } + const remotes = hosts.filter((h) => h.remote); + if (remotes.length === 0) return null; + return [ + { label: THIS_MACHINE, marked: true, target: { t: 'stay' } }, + ...remotes.map((h) => ({ label: h.name, marked: false, target: { t: 'open', host: h.idx, name: h.name } as const })), + ]; +}; + +/** The status line of the latest hub opening. */ +export type HubLine = + | { kind: 'opening'; name: string } + | { kind: 'login'; name: string; login: string } + | { kind: 'outdated'; name: string; message: string } + | { kind: 'installing'; name: string } + | { kind: 'failed'; name: string; message: string } + | { kind: 'unknown'; name: string }; + +export interface HubState { + line: HubLine | null; + /** The request whose replies the line follows. */ + pending: { req: number; host: number; name: string } | null; +} + +export type HubAction = + | { t: 'open'; req: number; host: number; name: string } + | { t: 'install'; req: number; host: number; name: string } + | { t: 'reply'; m: HubMessage } + | { t: 'dismiss' } + /** The address asked for a hub no remote host carries. */ + | { t: 'unknown'; name: string }; + +/** What the page does after a step: nothing, navigate the tab, or open the host's hub again. */ +export type HubEffect = null | { t: 'navigate'; url: string } | { t: 'reopen'; host: number; name: string }; + +export const initialHubState: HubState = { line: null, pending: null }; + +export const hubLineText = (line: HubLine): string => { + switch (line.kind) { + case 'opening': + return `Opening ${line.name}…`; + case 'login': + return `${line.name} needs Tailscale login`; + case 'outdated': + case 'failed': + return `${line.name}: ${line.message}`; + case 'installing': + return `Installing on ${line.name}…`; + case 'unknown': + return `no host named ${line.name}`; + } +}; + +const replyStep = (state: HubState, m: HubMessage): { state: HubState; effect: HubEffect } => { + const { pending } = state; + const unchanged = { state, effect: null }; + if (pending === null || !('req' in m) || m.req !== pending.req) return unchanged; + const { name } = pending; + switch (m.t) { + case 'hubLogin': + return { state: { ...state, line: { kind: 'login', name, login: m.login } }, effect: null }; + case 'hubOpened': + return { state: { ...state, pending: null }, effect: { t: 'navigate', url: m.url } }; + case 'done': + if (state.line?.kind !== 'installing') return unchanged; + return { state: { ...state, pending: null }, effect: { t: 'reopen', host: pending.host, name } }; + case 'error': { + const outdated = m.code === 'hub-outdated' && state.line?.kind !== 'installing'; + const line: HubLine = outdated ? { kind: 'outdated', name, message: m.message } : { kind: 'failed', name, message: m.message }; + return { state: { line, pending: null }, effect: null }; + } + case 'reposDiscovered': + return unchanged; + } +}; + +/** The status line after `action`, for the latest opening only, and what the page then does. */ +export const hubStep = (state: HubState, action: HubAction): { state: HubState; effect: HubEffect } => { + switch (action.t) { + case 'open': + return { + state: { line: { kind: 'opening', name: action.name }, pending: { req: action.req, host: action.host, name: action.name } }, + effect: null, + }; + case 'install': + return { + state: { line: { kind: 'installing', name: action.name }, pending: { req: action.req, host: action.host, name: action.name } }, + effect: null, + }; + case 'reply': + return replyStep(state, action.m); + case 'dismiss': + return { state: { ...state, line: null }, effect: null }; + case 'unknown': + return { state: { line: { kind: 'unknown', name: action.name }, pending: null }, effect: null }; + } +}; diff --git a/test/architecture/dependencies.ts b/test/architecture/dependencies.ts index ec0b6de..2c0f17d 100644 --- a/test/architecture/dependencies.ts +++ b/test/architecture/dependencies.ts @@ -29,7 +29,7 @@ export const ALLOWLIST: Readonly> = { 'daemon.terminals': ['@homebridge/node-pty-prebuilt-multiarch', '@xterm/headless', '@xterm/addon-serialize', 'fs'], 'daemon.state': ['zod'], 'daemon.server': ['net'], - 'hub.server': ['http', 'ws'], + 'hub.server': ['http', 'net', 'ws'], 'hub.links': ['net', 'child_process'], 'hub.config': ['zod'], cli: ['child_process'], diff --git a/test/e2e/contract.ts b/test/e2e/contract.ts index 830ca1b..b5f2bf7 100644 --- a/test/e2e/contract.ts +++ b/test/e2e/contract.ts @@ -130,6 +130,18 @@ export const TID = { notice: 'notice', /** Terminal container, `data-host` and `data-term`; holds the `.xterm` element; hidden ones are not visible. */ terminal: 'terminal', + /** Header button "Hubs", present only while there is a remote host; opens the list of `hubsEntry`. */ + hubsMenu: 'hubs-menu', + /** One per hub in the open Hubs menu, in order: text = "This machine" or the host name; `aria-current="true"` on the page's own hub. */ + hubsEntry: 'hubs-entry', + /** Status line beside the Hubs menu for the latest hub opening: `Opening …`, ` needs Tailscale login`, `: `, `Installing on …` or `no host named `. */ + hubStatus: 'hub-status', + /** "Open login link" anchor inside the status line: `href` = the login URL, `target="_blank"`, `rel` with `noopener` and `noreferrer`. */ + hubLogin: 'hub-login', + /** "Reinstall on " button inside the status line of an outdated remote; sends `installHub`, then opens the hub again. */ + hubReinstall: 'hub-reinstall', + /** × inside the status line of a failed opening; clears the line. */ + hubDismiss: 'hub-dismiss', } as const; /** Mark names in rank order, as `data-mark` values. */ @@ -157,5 +169,7 @@ export const termTab = (termId: number): string => `${byTestId(TID.termTab)}[dat export const pane = (termId: number): string => `${byTestId(TID.pane)}[data-term="${String(termId)}"]`; +export const hubsEntry = (label: string): string => `${byTestId(TID.hubsEntry)}:text-is("${label}")`; + export const terminalBox = (host: number, termId: number): string => `${byTestId(TID.terminal)}[data-host="${String(host)}"][data-term="${String(termId)}"]`; diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts index 5199fc6..01e46d9 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/fixture.ts @@ -60,10 +60,11 @@ export const test = base.extend({ }, }); -/** A configured remote host: its `name`, SSH alias, absolute repos and discovery roots. */ +/** A configured remote host: its `name`, SSH alias, hub tunnel port, absolute repos and discovery roots. */ export interface HostConfig { name: string; ssh: string; + port?: number; repos?: readonly string[]; roots?: readonly string[]; } @@ -129,10 +130,10 @@ export const refuseBrowserDialogs = (page: Page): string[] => { return seen; }; -/** Runs `wtd ui` with the stub browser and returns the URL it opened. */ -export const runUi = async (hub: HubHost): Promise => { +/** Runs `wtd ui [args]` with the stub browser and returns the URL it opened. */ +export const runUi = async (hub: HubHost, args: readonly string[] = []): Promise => { const before = hub.browserCalls().length; - const ui = hub.wtd(['ui']); + const ui = hub.wtd(['ui', ...args]); const exit = await ui.exited; if (exit.code !== 0) throw new Error(`wtd ui exited ${JSON.stringify(exit)}: ${ui.stderr}`); // The browser runs detached; wait until it has recorded its call. diff --git a/test/e2e/hosts.spec.ts b/test/e2e/hosts.spec.ts index 1ba81c9..c1f9f74 100644 --- a/test/e2e/hosts.spec.ts +++ b/test/e2e/hosts.spec.ts @@ -1,9 +1,9 @@ -import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { makeRepo, waitUntil } from '../support/daemon-host.js'; import { FakeDaemon } from '../support/fake-daemon.js'; -import { installRemote, type FakeRemote } from '../support/fake-ssh.js'; +import { installRemote } from '../support/fake-ssh.js'; import { byTestId, hostBanner, repoTab, terminalBox, TID, worktreeEntry } from './contract.js'; import { confirmAction, @@ -34,18 +34,11 @@ import { const RESOLVE_ERROR = 'ssh: Could not resolve hostname box'; -/** Replaces the remote's `wtd` with a shell script. */ -const plantWtd = (remote: FakeRemote, script: string): void => { - mkdirSync(dirname(remote.shim), { recursive: true }); - writeFileSync(remote.shim, `#!/bin/sh\n${script}\n`); - chmodSync(remote.shim, 0o755); -}; - test.describe('host status on repo tabs', () => { test('a remote tab is labelled with its host and shows a reconnecting host status, without a banner', async ({ hub, page, ssh }) => { const remote = ssh.addHost('box'); // The first connect fails; the retry never answers, so the host stays reconnecting. - plantWtd(remote, 'if [ -e "$HOME/once" ]; then exec sleep 600; fi; : > "$HOME/once"; exit 1'); + remote.plantWtd('if [ -e "$HOME/once" ]; then exec sleep 600; fi; : > "$HOME/once"; exit 1'); const app = join(remote.home, 'srv', 'app'); writeHubConfig(hub, { hosts: [{ name: 'box', ssh: 'box', repos: [app] }] }); await openUi(hub, page); diff --git a/test/e2e/hubs.spec.ts b/test/e2e/hubs.spec.ts new file mode 100644 index 0000000..beda317 --- /dev/null +++ b/test/e2e/hubs.spec.ts @@ -0,0 +1,267 @@ +import type { Page } from '@playwright/test'; +import { join } from 'node:path'; +import { makeRepo } from '../support/daemon-host.js'; +import { freePort, type HubHost } from '../support/hub-host.js'; +import { remoteHub } from '../support/remote-hub.js'; +import { byTestId, hubsEntry, repoTab, TID } from './contract.js'; +import { expect, hubRequests, makeRepoWith, nodeRemote, openUi, runUi, test, writeHubConfig, type HostConfig } from './fixture.js'; + +const RESOLVE_ERROR = 'ssh: Could not resolve hostname box: Name or service not known'; + +/** A free port that is neither `hub`'s, which it may not listen on yet, nor any of `taken`. */ +const tunnelPort = async (hub: HubHost, ...taken: readonly { port: number }[]): Promise => { + for (;;) { + const port = await freePort(); // NOSONAR(S9382) — retries until a port outside `taken` is free + if (port !== hub.port && !taken.some((t) => t.port === port)) return port; + } +}; + +const originOf = (port: number): string => `http://127.0.0.1:${String(port)}`; + +/** Waits until the tab shows `origin` with its fragment removed. */ +const landedOn = async (page: Page, origin: string): Promise => { + await expect.poll(() => page.url(), { timeout: 30_000 }).toBe(`${origin}/`); +}; + +/** Opens the Hubs menu and picks the entry labelled `label`. */ +const pick = async (page: Page, label: string): Promise => { + await page.locator(byTestId(TID.hubsMenu)).click(); + await page.locator(hubsEntry(label)).click(); +}; + +/** The labels of the open Hubs menu, in order. */ +const menuLabels = async (page: Page): Promise => { + await page.locator(byTestId(TID.hubsMenu)).click(); + const labels = await page.locator(byTestId(TID.hubsEntry)).allTextContents(); + await page.keyboard.press('Escape'); + return labels.map((label) => label.trim()); +}; + +/** Configures a local repo and `hosts`, opens the home page, and returns the local repo. */ +const home = async (hub: HubHost, page: Page, hosts: readonly HostConfig[]): Promise => { + const { repo } = makeRepoWith(hub, 'local-app', []); + writeHubConfig(hub, { repos: [repo], hosts }); + await openUi(hub, page); + await expect(page.locator(repoTab(repo))).toBeVisible(); + return repo; +}; + +test.describe('Hubs menu', () => { + test('lists This machine, marked, and every remote host', async ({ hub, page, ssh }) => { + ssh.failHost('box', RESOLVE_ERROR); + ssh.failHost('gpu', RESOLVE_ERROR); + await home(hub, page, [ + { name: 'box', ssh: 'box' }, + { name: 'gpu', ssh: 'gpu' }, + ]); + expect(await menuLabels(page)).toEqual(['This machine', 'box', 'gpu']); + await page.locator(byTestId(TID.hubsMenu)).click(); + await expect(page.locator(hubsEntry('This machine'))).toHaveAttribute('aria-current', 'true'); + await expect(page.locator(hubsEntry('box'))).not.toHaveAttribute('aria-current', 'true'); + }); + + test('Escape on an entry closes the menu and keeps the focus on the Hubs button', async ({ hub, page, ssh }) => { + ssh.failHost('box', RESOLVE_ERROR); + await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await page.locator(byTestId(TID.hubsMenu)).click(); + await page.locator(hubsEntry('box')).focus(); + await page.keyboard.press('Escape'); + await expect(page.locator(byTestId(TID.hubsEntry))).toHaveCount(0); + await expect(page.locator(byTestId(TID.hubsMenu))).toBeFocused(); + }); + + test('is absent without remote hosts', async ({ hub, page }) => { + await home(hub, page, []); + await expect(page.locator(byTestId(TID.hubsMenu))).toHaveCount(0); + }); + + test('picking a host shows its own hub in the same tab, titled by the host', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box', [hub.port]); + const port = await tunnelPort(hub, box); + await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + await expect(page).toHaveTitle('worktree-term'); + let pages = 0; + page.context().on('page', () => pages++); + + await pick(page, 'box'); + await landedOn(page, originOf(port)); + await expect(page).toHaveTitle('worktree-term — box'); + await expect(page.locator(repoTab(box.repo))).toBeVisible(); + await expect(page.locator(byTestId(TID.authMessage))).toHaveCount(0); + expect(pages).toBe(0); + }); + + test('a remote page keeps its title and menu across a reload', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box', [hub.port]); + ssh.failHost('gpu', RESOLVE_ERROR); + const port = await tunnelPort(hub, box); + await home(hub, page, [ + { name: 'box', ssh: 'box', port }, + { name: 'gpu', ssh: 'gpu' }, + ]); + await pick(page, 'box'); + await landedOn(page, originOf(port)); + + await page.reload(); + await expect(page).toHaveTitle('worktree-term — box'); + expect(await menuLabels(page)).toEqual(['This machine', 'box', 'gpu']); + await page.locator(byTestId(TID.hubsMenu)).click(); + await expect(page.locator(hubsEntry('box'))).toHaveAttribute('aria-current', 'true'); + }); + + test('This machine on a remote page goes back home, connected with the stored token', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box', [hub.port]); + const port = await tunnelPort(hub, box); + const repo = await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + await pick(page, 'box'); + await landedOn(page, originOf(port)); + + await pick(page, 'This machine'); + await landedOn(page, hub.origin); + await expect(page.locator(repoTab(repo))).toBeVisible(); + await expect(page.locator(byTestId(TID.authMessage))).toHaveCount(0); + await expect(page).toHaveTitle('worktree-term'); + }); + + test('switching from one remote to another goes through the home page', async ({ hub, page, ssh }) => { + test.setTimeout(180_000); + const box = await remoteHub(hub, ssh, 'box', [hub.port]); + const gpu = await remoteHub(hub, ssh, 'gpu', [hub.port, box.port]); + const boxPort = await tunnelPort(hub, box, gpu); + const gpuPort = await tunnelPort(hub, box, gpu, { port: boxPort }); + await home(hub, page, [ + { name: 'box', ssh: 'box', port: boxPort }, + { name: 'gpu', ssh: 'gpu', port: gpuPort }, + ]); + await pick(page, 'box'); + await landedOn(page, originOf(boxPort)); + const visited: string[] = []; + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) visited.push(frame.url()); + }); + + await pick(page, 'gpu'); + await landedOn(page, originOf(gpuPort)); + await expect(page).toHaveTitle('worktree-term — gpu'); + await expect(page.locator(repoTab(gpu.repo))).toBeVisible(); + expect(visited).toContain(`${hub.origin}/#hub=gpu`); + }); +}); + +test.describe('hub opening status', () => { + test('shows the opening while the host has not answered', async ({ hub, page, ssh }) => { + const remote = ssh.addHost('box'); + remote.plantWtd('exec sleep 600'); + await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await pick(page, 'box'); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveText('Opening box…'); + expect(page.url()).toBe(`${hub.origin}/`); + }); + + test('an opening the lost hub connection ended shows as failed, with a ×', async ({ hub, page, ssh }) => { + ssh.addHost('box').plantWtd('exec sleep 600'); + await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toHaveText('Opening box…'); + // Killed, not stopped: the fake remote's `sleep` keeps a SIGTERMed hub's daemon-link pipes open. + for (const pid of hub.hubPids()) process.kill(pid, 'SIGKILL'); + await expect(status).toContainText('box: the connection to the hub closed'); + await expect(status.locator(byTestId(TID.hubDismiss))).toBeVisible(); + }); + + test('shows the Tailscale login link while waiting, then navigates once approved', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box', [hub.port]); + const port = await tunnelPort(hub, box); + await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + ssh.loginHost('box'); + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toContainText('box needs Tailscale login', { timeout: 15_000 }); + const link = status.locator(byTestId(TID.hubLogin)); + await expect(link).toHaveText('Open login link'); + await expect(link).toHaveAttribute('href', /^https:\/\/login\.tailscale\.com\/a\/wtd\d+$/); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', /\bnoopener\b/); + await expect(link).toHaveAttribute('rel', /\bnoreferrer\b/); + + const url = (await link.getAttribute('href')) ?? ''; + ssh.skipLogin('box'); + ssh.approveLogin(url); + await landedOn(page, originOf(port)); + await expect(page).toHaveTitle('worktree-term — box'); + }); + + test('an outdated remote is reinstalled from the status line, then opened', async ({ hub, page, ssh, wire }) => { + test.setTimeout(180_000); + const remote = nodeRemote(ssh, 'box'); + remote.plantWtd('exit 2'); + const remotePort = await tunnelPort(hub); + remote.writeHubConfig({ port: remotePort, repos: [makeRepo(join(remote.home, 'app'))] }); + const port = await tunnelPort(hub, { port: remotePort }); + await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toContainText(/^box: .*missing or too old/, { timeout: 15_000 }); + const reinstall = status.locator(byTestId(TID.hubReinstall)); + await expect(reinstall).toHaveText('Reinstall on box'); + const mark = wire.markSent(); + await reinstall.click(); + await expect(status).toHaveText('Installing on box…'); + await landedOn(page, originOf(port)); + expect(hubRequests(wire, mark).map((m) => m.t)).toEqual(['installHub', 'openHub']); + }); + + test('a failure shows its reason with a × that clears it, leaving the tab home', async ({ hub, page, ssh }) => { + ssh.failHost('box', RESOLVE_ERROR); + await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toContainText(`box: ${RESOLVE_ERROR}`, { timeout: 15_000 }); + await status.locator(byTestId(TID.hubDismiss)).click(); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveCount(0); + expect(page.url()).toBe(`${hub.origin}/`); + }); +}); + +test.describe('hub opening from the address', () => { + test('wtd ui opens that host’s hub without a click', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box', [hub.port]); + const port = await tunnelPort(hub, box); + const { repo } = makeRepoWith(hub, 'local-app', []); + writeHubConfig(hub, { repos: [repo], hosts: [{ name: 'box', ssh: 'box', port }] }); + await page.goto(await runUi(hub, ['box'])); + await landedOn(page, originOf(port)); + await expect(page.locator(repoTab(box.repo))).toBeVisible(); + }); + + test('a hub no remote host carries is reported and the page stays', async ({ hub, page, ssh, wire }) => { + ssh.failHost('box', RESOLVE_ERROR); + const { repo } = makeRepoWith(hub, 'local-app', []); + writeHubConfig(hub, { repos: [repo], hosts: [{ name: 'box', ssh: 'box' }] }); + await page.goto(`${await runUi(hub)}&hub=nope`); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveText('no host named nope'); + await expect(page.locator(repoTab(repo))).toBeVisible(); + expect(page.url()).toBe(`${hub.origin}/`); + expect(hubRequests(wire).filter((m) => m.t === 'openHub')).toEqual([]); + }); + + test('a malformed fragment is removed and ignored, the stored token connecting', async ({ hub, page, ssh, wire }) => { + ssh.failHost('box', RESOLVE_ERROR); + const repo = await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await page.goto('about:blank'); + await page.goto(`${hub.origin}/#code=x&hub=a b`); + await expect(page.locator(repoTab(repo))).toBeVisible(); + await expect(page.locator(byTestId(TID.authMessage))).toHaveCount(0); + expect(await page.evaluate(() => location.hash)).toBe(''); + await page.waitForTimeout(1000); // NOSONAR(S2925) — absence check: nothing to synchronise on + expect(hubRequests(wire).filter((m) => m.t === 'openHub')).toEqual([]); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveCount(0); + }); +}); diff --git a/test/integration/ssh-localhost.test.ts b/test/integration/ssh-localhost.test.ts index 76309d6..5bdf0d0 100644 --- a/test/integration/ssh-localhost.test.ts +++ b/test/integration/ssh-localhost.test.ts @@ -1,15 +1,18 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { connect, createServer, type Server, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { forwardToHub, type HubForward } from '../../src/hub/links/index.js'; +import { hostPaths } from '../../src/platform/files/index.js'; +import { DAEMON_PROTOCOL_VERSION, decodePrintCode, decodeRemoteFragment } from '../../src/protocol/index.js'; import { alive, makeRepo, waitUntil } from '../support/daemon-host.js'; import { exec, REPO_ROOT } from '../support/exec.js'; import { FakeDaemon } from '../support/fake-daemon.js'; import { FakeRemote, shellQuote } from '../support/fake-ssh.js'; -import type { HubClient } from '../support/hub-client.js'; -import { HubHost } from '../support/hub-host.js'; +import { HubClient } from '../support/hub-client.js'; +import { freePort, HubHost, rawHttp } from '../support/hub-host.js'; // Opt-in: needs sshd on localhost accepting this user's key with BatchMode. vi.setConfig({ testTimeout: 180_000, hookTimeout: 300_000 }); @@ -19,13 +22,16 @@ const REMOTE = 1; /** * `WTD_SSH` wrapper: the real `ssh` with every option the caller gave, the remote command run under - * `home` so the test never touches this user's own installation. + * `home` so the test never touches this user's own installation; a `-W` forward is passed through as given. */ const sshWrapper = (dir: string, home: string): string => { const path = join(dir, 'ssh'); writeFileSync( path, `#!/bin/sh +for a do + if [ "$a" = -W ]; then exec ssh "$@"; fi +done n=$# for a do shift @@ -78,6 +84,7 @@ const procs = (): { pid: number; ppid: number; argv: string[] }[] => { let host: HubHost; let remote: FakeRemote; +const servers: Server[] = []; let remoteHome: string; beforeAll(() => { @@ -95,6 +102,8 @@ beforeEach(async () => { }); afterEach(async () => { + vi.unstubAllEnvs(); + for (const server of servers.splice(0)) server.close(); for (const pid of host.hubPids()) process.kill(pid, 'SIGKILL'); for (const pid of mastersIn(host.runDir)) process.kill(pid, 'SIGTERM'); await remote.cleanup(); @@ -196,3 +205,150 @@ describe('remote host over real ssh', () => { expect(sshChildrenOf(hub).filter((pid) => !firstClients.includes(pid))).toHaveLength(1); }); }); + +/** Pids of `ssh -W` forwards whose parent is `parent`. */ +const forwardsOf = (parent: number): number[] => + procs() + .filter((p) => p.ppid === parent && p.argv.includes('-W')) + .map((p) => p.pid); + +/** Starts localhost's hub under the throwaway home with `wtd ui --print-code`, without a control master; returns its port and code. */ +const printCode = (): { port: number; code: string } => { + const run = spawnSync(host.sshProgram ?? 'ssh', ['-o', 'BatchMode=yes', ALIAS, '"$HOME/.local/bin/wtd" ui --print-code'], { + encoding: 'utf8', + }); + if (run.status !== 0) throw new Error(`wtd ui --print-code failed: ${run.stderr}`); + return decodePrintCode(run.stdout); +}; + +/** A local listener on a free port relaying each connection to `remotePort` on localhost with `forwardToHub`. */ +const tunnel = async (remotePort: number): Promise<{ port: number; forwards: HubForward[] }> => { + vi.stubEnv('WTD_SSH', host.sshProgram ?? 'ssh'); + const paths = hostPaths({ env: { XDG_STATE_HOME: host.stateHome }, home: host.home, host: 'local' }); + const forwards: HubForward[] = []; + const server = createServer((socket) => { + forwards.push(forwardToHub(paths, ALIAS, remotePort, socket)); + }); + servers.push(server); + const port = await freePort(); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { port, forwards }; +}; + +/** An open connection to 127.0.0.1:`port` and a promise of its close. */ +const held = async (port: number): Promise<{ socket: Socket; closed: Promise }> => { + const socket = connect(port, '127.0.0.1'); + socket.on('error', () => undefined); + const closed = new Promise((resolve) => { + socket.once('close', () => { + resolve(); + }); + }); + await new Promise((resolve) => { + socket.once('connect', () => { + resolve(); + }); + }); + return { socket, closed }; +}; + +/** Configures localhost's hub with a repo and a free port, and the local hub with `lh` on the tunnel port `tunnelPort`. */ +const configureHubs = async (): Promise<{ repo: string; remotePort: number; tunnelPort: number }> => { + await install(); + const repo = makeRepo(join(remoteHome, 'repo')); + const remotePort = await freePort(); + let tunnelPort = await freePort(); + while (tunnelPort === remotePort) tunnelPort = await freePort(); // NOSONAR(S9382) — retries until the ports differ + remote.writeHubConfig({ port: remotePort, repos: [repo] }); + host.writeConfig({ port: host.port, hosts: [{ name: 'lh', ssh: ALIAS, port: tunnelPort }] }); + return { repo, remotePort, tunnelPort }; +}; + +/** Sends `openHub` for `lh` and returns the opened page's port and code. */ +const openLh = async (client: HubClient): Promise<{ port: number; code: string }> => { + const from = client.mark(); + client.send({ t: 'openHub', req: 1000, host: REMOTE }); + const reply = await client.waitFor(['hubOpened', 'error'], (m) => m.req === 1000, { from, timeout: 60_000 }); + if (reply.t !== 'hubOpened') throw new Error(`openHub failed: ${JSON.stringify(reply)}`); + const url = new URL(reply.url); + return { port: Number(url.port), code: decodeRemoteFragment(url.hash.slice(1)).code }; +}; + +describe('remote hub over real ssh', () => { + it('forwards to the remote hub with ssh -W when no control master exists', async () => { + const { remotePort } = await configureHubs(); + expect(printCode().port).toBe(remotePort); + expect(mastersIn(host.runDir)).toEqual([]); + const { port, forwards } = await tunnel(remotePort); + const response = await rawHttp(port, 'GET', '/'); + expect(response.status).toBe(200); + expect(forwards).toHaveLength(1); + await expect(forwards[0]?.ended).resolves.not.toMatch(/open failed|forwarding failed/); + }); + + it('opens the remote hub through the local hub over the existing control master', async () => { + const { repo, tunnelPort } = await configureHubs(); + await host.startHub(); + const client = await host.session(); + await connectedInstance(client); + const masters = mastersIn(host.runDir); + expect(masters).toHaveLength(1); + + const { port, code } = await openLh(client); + expect(port).toBe(tunnelPort); + expect((await rawHttp(port, 'GET', '/')).status).toBe(200); + const page = await HubClient.open(`ws://127.0.0.1:${String(port)}/ws`, { + protocols: ['wtd', `wtd.code.${code}`], + origin: `http://127.0.0.1:${String(port)}`, + }); + const { hosts } = await page.handshake(); + expect(hosts.hosts[0]?.repos).toEqual([repo]); + expect(mastersIn(host.runDir)).toEqual(masters); + page.close(); + }); + + it('reports a forward to a port nothing listens on with the line ssh wrote', async () => { + await install(); + const { port, forwards } = await tunnel(await freePort()); + const { closed } = await held(port); + await closed; + await expect(forwards[0]?.ended).resolves.toMatch(/open failed|forwarding failed/); + }); + + it('ends the forwards when the control master is lost', async () => { + await configureHubs(); + await host.startHub(); + const client = await host.session(); + await connectedInstance(client); + const { port } = await openLh(client); + const { closed } = await held(port); + const hub = host.record()?.pid ?? 0; + const forwards = await waitUntil(() => { + const found = forwardsOf(hub); + return found.length > 0 ? found : undefined; + }, 'a forward of the held connection'); + + for (const pid of mastersIn(host.runDir)) process.kill(pid, 'SIGTERM'); + await closed; + await waitUntil(() => forwards.every((pid) => !alive(pid)), 'the forwards to end', 10_000); + expect(forwards.filter((pid) => alive(pid))).toEqual([]); + }); + + it('leaves no ssh process behind once the hub stops', async () => { + await configureHubs(); + await host.startHub(); + const client = await host.session(); + await connectedInstance(client); + const { port } = await openLh(client); + const { closed } = await held(port); + const hub = host.record()?.pid ?? 0; + await waitUntil(() => forwardsOf(hub).length > 0, 'a forward of the held connection'); + const children = sshChildrenOf(hub); + + process.kill(hub, 'SIGTERM'); + await closed; + await waitUntil(() => host.hubPids().length === 0, 'the hub to exit', 10_000); + await waitUntil(() => children.every((pid) => !alive(pid)), 'the hub’s ssh processes to end', 10_000); + await expect(rawHttp(port, 'GET', '/')).rejects.toThrow(); + }); +}); diff --git a/test/process/cli.test.ts b/test/process/cli.test.ts index 8dee168..3468570 100644 --- a/test/process/cli.test.ts +++ b/test/process/cli.test.ts @@ -38,7 +38,7 @@ describe('dist/wtd.mjs', () => { it('prints its version', () => { expect(wtd('--version')).toEqual({ status: 0, - stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`, + stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`, stderr: '', }); }); diff --git a/test/process/fake-ssh.test.ts b/test/process/fake-ssh.test.ts index 3dbfb99..e304629 100644 --- a/test/process/fake-ssh.test.ts +++ b/test/process/fake-ssh.test.ts @@ -1,10 +1,13 @@ -import { spawn } from 'node:child_process'; -import { copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { expect, it } from 'vitest'; -import { waitUntil } from '../support/daemon-host.js'; +import type { Readable, Writable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { alive, waitUntil } from '../support/daemon-host.js'; import { FakeSsh } from '../support/fake-ssh.js'; +import { freePort } from '../support/hub-host.js'; it('cleanup removes its directory while runs it cannot see keep recording their calls for a while', async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-fake-ssh-'))); @@ -28,3 +31,149 @@ it('cleanup removes its directory while runs it cannot see keep recording their rmSync(elsewhere, { recursive: true, force: true }); } }); + +type Run = ChildProcessByStdio; + +interface Finished { + code: number | null; + stdout: string; + stderr: string; +} + +describe('FakeSsh forwards and planted programs', () => { + let dir: string; + let ssh: FakeSsh; + const servers: Server[] = []; + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-fake-ssh-'))); + ssh = new FakeSsh(join(dir, 'ssh')); + }); + + afterEach(async () => { + for (const server of servers.splice(0)) server.close(); + await ssh.cleanup(); + rmSync(dir, { recursive: true, force: true }); + }); + + const run = (args: readonly string[]): Run => spawn(ssh.program, [...args], { stdio: ['pipe', 'pipe', 'pipe'] }); + + const finished = (child: Run): Promise => + new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + child.once('close', (code) => { + resolve({ code, stdout, stderr }); + }); + }); + + const forward = (alias: string, port: number): Run => + run(['-o', 'BatchMode=yes', '-o', 'ControlMaster=auto', '-W', `127.0.0.1:${String(port)}`, '--', alias]); + + /** A TCP server on a free port answering each line with `echo:` and the line. */ + const echoServer = async (): Promise => { + const port = await freePort(); + const server = createServer((socket) => { + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => socket.write(`echo:${chunk}`)); + socket.on('end', () => socket.end()); + }); + servers.push(server); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return port; + }; + + it('relays a -W run’s standard input and output to the TCP port, both ways, and exits when its input ends', async () => { + ssh.addHost('box'); + const port = await echoServer(); + const child = forward('box', port); + const done = finished(child); + child.stdin.write('ping\n'); + await waitUntil(() => ssh.forwardPids('box').length === 1, 'the forward to run'); + child.stdin.end(); + expect(await done).toEqual({ code: 0, stdout: 'echo:ping\n', stderr: '' }); + }); + + it('records a forward’s target and no command', async () => { + ssh.addHost('box'); + const port = await echoServer(); + const child = forward('box', port); + const done = finished(child); + child.stdin.end(); + await done; + expect(ssh.calls()).toEqual([expect.objectContaining({ alias: 'box', command: '', forward: `127.0.0.1:${String(port)}` })]); + }); + + it('records no forward for a command run', async () => { + ssh.addHost('box'); + expect((await finished(run(['-o', 'BatchMode=yes', '--', 'box', 'echo hi']))).stdout).toBe('hi\n'); + expect(ssh.calls().map((c) => ({ command: c.command, forward: c.forward }))).toEqual([{ command: 'echo hi', forward: null }]); + }); + + it('reports a forward to a closed port as OpenSSH does', async () => { + ssh.addHost('box'); + const result = await finished(forward('box', await freePort())); + expect(result.code).toBe(255); + expect(result.stderr).toBe('channel 0: open failed: connect failed: Connection refused\nstdio forwarding failed\n'); + }); + + it('refuses forwards for a host after refuseForwards, leaving its command runs alone', async () => { + ssh.addHost('box'); + ssh.refuseForwards('box'); + const refused = await finished(forward('box', await echoServer())); + expect(refused).toEqual({ code: 255, stdout: '', stderr: 'channel 0: open failed: administratively prohibited\n' }); + expect(await finished(run(['--', 'box', 'echo hi']))).toEqual({ code: 0, stdout: 'hi\n', stderr: '' }); + }); + + it('lists only live forward runs in forwardPids', async () => { + ssh.addHost('box'); + const port = await echoServer(); + const child = forward('box', port); + const command = run(['--', 'box', 'sleep 30']); + await waitUntil(() => ssh.forwardPids('box').length === 1 && ssh.livePids('box').length === 2, 'both runs'); + expect(ssh.forwardPids('box')).toEqual([child.pid]); + const done = finished(child); + child.stdin.end(); + await done; + expect(ssh.forwardPids('box')).toEqual([]); + command.kill('SIGKILL'); + }); + + it('runs a planted wtd as the remote’s ~/.local/bin/wtd', async () => { + const remote = ssh.addHost('box'); + remote.plantWtd('echo "planted $*"; exit 2'); + expect(statSync(remote.shim).mode & 0o111).not.toBe(0); + expect(await finished(run(['--', 'box', '"$HOME/.local/bin/wtd" ui --print-code']))).toEqual({ + code: 2, + stdout: 'planted ui --print-code\n', + stderr: '', + }); + }); + + it('writes the remote hub’s configuration under the remote’s home', () => { + const remote = ssh.addHost('box'); + remote.writeHubConfig({ port: 9000 }); + expect(JSON.parse(readFileSync(join(remote.home, '.config', 'worktree-term', 'config.json'), 'utf8'))).toEqual({ port: 9000 }); + }); + + it('kills the remote’s hubs on cleanup', async () => { + const remote = ssh.addHost('box'); + const script = join(dir, 'idle.cjs'); + writeFileSync(script, 'setInterval(() => {}, 1000);\n'); + // Looks like a hub of the remote: node