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