From bc40125d53a3299c567a0e66e8ea2a8add0cc179 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 13:57:07 +0200 Subject: [PATCH 1/8] Plan DEV-2107: show a remote host's hub in the current tab --- .../.openspec.yaml | 2 + .../design.md | 60 +++++ .../proposal.md | 35 +++ .../specs/cli/spec.md | 89 +++++++ .../specs/hub/spec.md | 249 ++++++++++++++++++ .../specs/protocol/spec.md | 180 +++++++++++++ .../specs/web/spec.md | 79 ++++++ .../tasks.md | 39 +++ 8 files changed, 733 insertions(+) create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/.openspec.yaml create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/design.md create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/proposal.md create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/cli/spec.md create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/hub/spec.md create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/protocol/spec.md create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/web/spec.md create mode 100644 openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md diff --git a/openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/.openspec.yaml b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/design.md b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/proposal.md b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/cli/spec.md b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/hub/spec.md b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/protocol/spec.md b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/specs/web/spec.md b/openspec/changes/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/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/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md b/openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md new file mode 100644 index 0000000..622db11 --- /dev/null +++ b/openspec/changes/dev-2107-worktree-term-open-a-remote-hosts-hub-in-its-own-window/tasks.md @@ -0,0 +1,39 @@ +## 1. Tests — unit + +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 + +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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` +- [ ] 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 +- [ ] 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 +- [ ] 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` +- [ ] 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 + +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 3.4 `src/hub/config`: the `port` field and the assignment edit in `ConfigEditor`; test 1.4 passes +- [ ] 3.5 `src/hub/router/coordinator.ts`: the FIFO queue with `install`; test 1.5 passes +- [ ] 3.6 `src/hub/links`: `HubOpener` and `forward`; test 1.7 passes +- [ ] 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) +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] 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 + +- [ ] 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 From 4bcb84fa480c2d36e5329540d533b6c0d5bfe5a8 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 15:13:20 +0200 Subject: [PATCH 2/8] Add failing tests for DEV-2107: show a remote host's hub in the current tab Unit, process, e2e and opt-in real-SSH tests for opening a remote host's hub through the home page: browser protocol 10 messages and hub error codes, the frozen print-code line and page fragments, per-host port assignment, the install-only coordinator queue, the print-code opener and ssh -W forwards, the any-port Host/Origin rule, wtd ui and --print-code, tunnel listeners, the Hubs menu and status line. The fake SSH learns -W forwarding; hub budgets are shortened in process tests via WTD_HUB_OPEN_MS, WTD_HUB_PROBE_MS and WTD_HUB_INSTALL_MS. --- src/cli/run.test.ts | 85 ++- src/hub/config/config.test.ts | 77 ++- src/hub/config/editor.test.ts | 135 ++++ src/hub/links/hub.test.ts | 287 +++++++++ src/hub/router/coordinator.test.ts | 173 +++++- src/protocol/constants.test.ts | 2 +- src/protocol/frozen.golden.json | 29 + src/protocol/hub.test.ts | 169 +++++ src/protocol/messages.test.ts | 99 +++ src/protocol/test-samples.ts | 15 +- src/protocol/wire.golden.test.ts | 67 +- src/web/client/page.test.ts | 110 ++++ src/web/ui/hubs.test.ts | 158 +++++ test/e2e/contract.ts | 14 + test/e2e/fixture.ts | 9 +- test/e2e/hosts.spec.ts | 15 +- test/e2e/hubs.spec.ts | 245 ++++++++ test/integration/ssh-localhost.test.ts | 163 ++++- test/process/fake-ssh.test.ts | 157 ++++- test/process/hub-cli.test.ts | 197 +++++- test/process/hub-router.test.ts | 827 ++++++++++++++++++++++++- test/process/hub-server.test.ts | 60 +- test/support/fake-ssh.ts | 86 ++- test/support/hub-client.ts | 9 +- test/support/remote-hub.ts | 25 + 25 files changed, 3123 insertions(+), 90 deletions(-) create mode 100644 src/hub/links/hub.test.ts create mode 100644 src/protocol/hub.test.ts create mode 100644 src/web/client/page.test.ts create mode 100644 src/web/ui/hubs.test.ts create mode 100644 test/e2e/hubs.spec.ts create mode 100644 test/support/remote-hub.ts 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/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/editor.test.ts b/src/hub/config/editor.test.ts index a6b2ee0..11f5fc1 100644 --- a/src/hub/config/editor.test.ts +++ b/src/hub/config/editor.test.ts @@ -300,3 +300,138 @@ 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(); + await expect(editor().assignPort('nope')).rejects.toThrow(ConfigError); + await expect(editor().assignPort('nope')).rejects.toThrow('nope'); + expect(text()).toBe(before); + }); + + it('refuses an absent file, which lists no host', async () => { + await expect(editor().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(); + await expect(editor().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/links/hub.test.ts b/src/hub/links/hub.test.ts new file mode 100644 index 0000000..5094c64 --- /dev/null +++ b/src/hub/links/hub.test.ts @@ -0,0 +1,287 @@ +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'); + }); + + 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 forward.ended; + }); + + 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/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/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/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..de2ab63 --- /dev/null +++ b/src/protocol/hub.test.ts @@ -0,0 +1,169 @@ +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', () => { + expect(() => decodePrintCode(new Uint8Array([0xff, 0x0a]))).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/messages.test.ts b/src/protocol/messages.test.ts index b7c3fb7..47a2c3b 100644 --- a/src/protocol/messages.test.ts +++ b/src/protocol/messages.test.ts @@ -10,12 +10,14 @@ import { type Preset, } from './index.js'; import { + CODE, DIRECTIONS, featWorktree, hello, hosts, liveTerminal, MESSAGE_TYPES, + OPENED_URL, raw, REPO, samples, @@ -262,6 +264,10 @@ describe('request correlation', () => { ['browserToHub', { t: 'addPreset', req: 1, preset: { name: 'p', command: null } }], ['browserToHub', { t: 'removePreset', req: 1, name: 'p' }], ['browserToHub', { t: 'movePreset', req: 1, name: 'p', to: 0 }], + ['browserToHub', { t: 'openHub', req: 1, host: 1 }], + ['browserToHub', { t: 'installHub', req: 1, host: 1 }], + ['hubToBrowser', { t: 'hubLogin', req: 1, login: 'https://login.tailscale.com/a/abc123' }], + ['hubToBrowser', { t: 'hubOpened', req: 1, url: OPENED_URL }], ]; const fireAndForget: readonly (readonly [Direction, Record])[] = [ @@ -727,3 +733,96 @@ describe('hub code response', () => { expect(() => encodeCodeResponse({ code: 'xyz' })).toThrow(ProtocolError); }); }); + +describe('hub opening messages', () => { + const LOGIN = 'https://login.tailscale.com/a/abc123'; + const opened = (url: unknown): unknown => ({ t: 'hubOpened', req: 1, url }); + const FRAGMENT = `code=${CODE}&host=box&home=7417&hubs=box`; + + it.each([ + ['openHub', { t: 'openHub', req: 1, host: 1 }], + ['installHub', { t: 'installHub', req: 1, host: 1 }], + ])('accepts %s only from the browser', (_name, message) => { + expect(decodes('browserToHub', message)).toBe(true); + expectRejected('hubToBrowser', raw(message)); + expectRejected('clientToDaemon', raw(message)); + }); + + it.each([ + ['hubLogin', { t: 'hubLogin', req: 1, login: LOGIN }], + ['hubOpened', { t: 'hubOpened', req: 1, url: OPENED_URL }], + ])('accepts %s only from the hub', (_name, message) => { + expect(decodes('hubToBrowser', message)).toBe(true); + expectRejected('browserToHub', raw(message)); + expectRejected('daemonToClient', raw(message)); + }); + + it.each([ + ['a host index above 65535', { t: 'openHub', req: 1, host: 65536 }], + ['a negative host index', { t: 'installHub', req: 1, host: -1 }], + ['no host', { t: 'openHub', req: 1 }], + ['an extra field', { t: 'openHub', req: 1, host: 1, name: 'box' }], + ])('rejects a hub request with %s', (_name, message) => { + expectRejected('browserToHub', raw(message)); + }); + + it('decodes a hubOpened URL to its value unchanged', () => { + expect(decodeMessage('hubToBrowser', raw(opened(OPENED_URL)))).toEqual({ t: 'hubOpened', req: 1, url: OPENED_URL }); + }); + + it.each([1024, 7418, 65535])('accepts a hubOpened URL on port %i', (port) => { + expect(decodes('hubToBrowser', opened(`http://127.0.0.1:${String(port)}/#${FRAGMENT}`))).toBe(true); + }); + + it.each([ + ['a foreign host', `http://evil.example:7418/#${FRAGMENT}`], + ['localhost', `http://localhost:7418/#${FRAGMENT}`], + ['https', `https://127.0.0.1:7418/#${FRAGMENT}`], + ['port 80', `http://127.0.0.1:80/#${FRAGMENT}`], + ['port 1023', `http://127.0.0.1:1023/#${FRAGMENT}`], + ['port 65536', `http://127.0.0.1:65536/#${FRAGMENT}`], + ['a leading-zero port', `http://127.0.0.1:07418/#${FRAGMENT}`], + ['no port', `http://127.0.0.1/#${FRAGMENT}`], + ['a path other than /', `http://127.0.0.1:7418/x#${FRAGMENT}`], + ['a query', `http://127.0.0.1:7418/?a=1#${FRAGMENT}`], + ['credentials', `http://u@127.0.0.1:7418/#${FRAGMENT}`], + ['no fragment', 'http://127.0.0.1:7418/'], + ['a home fragment', `http://127.0.0.1:7418/#code=${CODE}&hub=box`], + ['a malformed remote fragment', `http://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=gpu`], + ['a javascript: URL', 'javascript:alert(1)'], + ['a number', 7418], + ])('rejects a hubOpened URL with %s', (_name, url) => { + expect(decodes('hubToBrowser', opened(url))).toBe(false); + }); + + it.each([ + ['a lookalike host', 'https://login.tailscale.com.evil/a/x'], + ['plain http', 'http://login.tailscale.com/a/x'], + ['a javascript: URL', 'javascript:alert(1)'], + ['1025 characters', LOGIN + 'x'.repeat(1025 - LOGIN.length)], + ['null', null], + ])('rejects a hubLogin URL that is %s', (_name, login) => { + expect(decodes('hubToBrowser', { t: 'hubLogin', req: 1, login })).toBe(false); + }); + + it('accepts a hubLogin URL of 1024 characters', () => { + expect(decodes('hubToBrowser', { t: 'hubLogin', req: 1, login: LOGIN + 'x'.repeat(1024 - LOGIN.length) })).toBe(true); + }); + + it.each(['hub-outdated', 'hub-failed'])('accepts the hub error code %s from the hub only', (code) => { + expect(decodes('hubToBrowser', { t: 'error', req: 1, host: 1, code, message: 'x' })).toBe(true); + expect(decodes('hubToBrowser', { t: 'error', req: null, host: null, code, message: 'x' })).toBe(true); + expectRejected('daemonToClient', raw({ t: 'error', req: 1, code, message: 'x' })); + expectRejected('hubToBrowser', raw({ t: 'host', host: 1, m: { t: 'error', req: 1, code, message: 'x' } })); + }); + + it('still accepts every daemon error code from the hub', () => { + for (const code of ['bad-message', 'unknown-host', 'busy', 'version-mismatch', 'host-unavailable', 'internal']) { + expect(decodes('hubToBrowser', { t: 'error', req: 1, host: 0, code, message: 'x' })).toBe(true); + } + }); + + it('rejects an unknown hub error code', () => { + expectRejected('hubToBrowser', raw({ t: 'error', req: 1, host: 0, code: 'hub-gone', message: 'x' })); + }); +}); diff --git a/src/protocol/test-samples.ts b/src/protocol/test-samples.ts index 19463d1..17576ab 100644 --- a/src/protocol/test-samples.ts +++ b/src/protocol/test-samples.ts @@ -149,6 +149,10 @@ export const hosts: HostEntry[] = [ ]; export const TOKEN = '0123456789abcdef'.repeat(4); +export const CODE = 'fedcba9876543210'.repeat(4); + +/** A `hubOpened` URL for host `box` of a hub on port 7417. */ +export const OPENED_URL = `http://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=box,gpu`; export const clientToDaemonSamples: MessageOf<'clientToDaemon'>[] = [ hello, @@ -222,6 +226,8 @@ export const browserToHubSamples: MessageOf<'browserToHub'>[] = [ { t: 'addPreset', req: 7, preset: { name: 'login shell', command: null } }, { t: 'removePreset', req: 8, name: 'codex' }, { t: 'movePreset', req: 9, name: 'codex', to: 0 }, + { t: 'openHub', req: 10, host: 1 }, + { t: 'installHub', req: 11, host: 65535 }, ]; export const hubToBrowserSamples: MessageOf<'hubToBrowser'>[] = [ @@ -242,6 +248,11 @@ export const hubToBrowserSamples: MessageOf<'hubToBrowser'>[] = [ { t: 'error', req: null, host: null, code: 'version-mismatch', message: 'outdated' }, { t: 'error', req: 3, host: 0, code: 'host-unavailable', message: 'host 0 is reconnecting' }, { t: 'reposDiscovered', req: 3, host: 0, repos: [REPO] }, + { t: 'hubLogin', req: 4, login: 'https://login.tailscale.com/a/abc123' }, + { t: 'hubOpened', req: 5, url: OPENED_URL }, + { t: 'hubOpened', req: 6, url: `http://127.0.0.1:65535/#code=${CODE}&host=${'x'.repeat(64)}&home=1024&hubs=${'x'.repeat(64)}` }, + { t: 'error', req: 7, host: 1, code: 'hub-outdated', message: 'wtd on box is missing or too old to serve its hub' }, + { t: 'error', req: 8, host: 2, code: 'hub-failed', message: 'ssh: Could not resolve hostname box: Name or service not known' }, ]; export const samples: { readonly [D in Direction]: readonly MessageOf[] } = { @@ -298,8 +309,10 @@ export const MESSAGE_TYPES: Readonly> = { 'addPreset', 'removePreset', 'movePreset', + 'openHub', + 'installHub', ], - hubToBrowser: ['hello', 'token', 'host', 'hosts', 'presets', 'done', 'error', 'reposDiscovered'], + hubToBrowser: ['hello', 'token', 'host', 'hosts', 'presets', 'done', 'error', 'reposDiscovered', 'hubLogin', 'hubOpened'], }; /** Encodes a JSON value as control-message text, bypassing the protocol encoder. */ diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index 3eb4e05..b183a40 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -4,7 +4,13 @@ import frozenJson from './frozen.golden.json' with { type: 'json' }; import wireJson from './wire.golden.json' with { type: 'json' }; import { ACK_EVERY, + decodeHomeFragment, decodeMessage, + decodePrintCode, + decodeRemoteFragment, + encodeHomeFragment, + encodePrintCode, + encodeRemoteFragment, encodeFrame, FLOW_HIGH, FLOW_LOW, @@ -19,7 +25,7 @@ import { type Direction, type Frame, } from './index.js'; -import { DIRECTIONS, featWorktree, hello, hosts, layout, liveTerminal, raw, REPO, samples, WT } from './test-samples.js'; +import { CODE, DIRECTIONS, featWorktree, hello, hosts, layout, liveTerminal, raw, REPO, samples, WT } from './test-samples.js'; const directionSchema = z.enum(['clientToDaemon', 'daemonToClient', 'browserToHub', 'hubToBrowser']); @@ -202,6 +208,18 @@ const EDGE_CASES: readonly unknown[] = [ }, ], }, + { t: 'openHub', req: 1 }, + { t: 'openHub', req: 1, host: 65536 }, + { t: 'installHub', req: 1, host: 1, force: true }, + { t: 'hubLogin', req: 1, login: 'https://login.tailscale.com.evil/a/x' }, + { t: 'hubOpened', req: 1, url: `http://evil.example:7418/#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: `https://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: `http://127.0.0.1:80/#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: `http://127.0.0.1:7418/x#code=${CODE}&host=box&home=7417&hubs=box` }, + { t: 'hubOpened', req: 1, url: 'javascript:alert(1)' }, + { t: 'error', req: 1, code: 'hub-outdated', message: 'x' }, + { t: 'error', req: 1, code: 'hub-failed', message: 'x' }, + { t: 'error', req: 1, host: 1, code: 'hub-gone', message: 'x' }, ]; const allJson = [...new Set([...DIRECTIONS.flatMap((dir) => samples[dir].map((m) => raw(m))), ...EDGE_CASES.map((m) => raw(m))])]; @@ -309,8 +327,8 @@ describe('wire golden', () => { await expect(JSON.stringify(current, null, 2) + '\n').toMatchFileSnapshot('./wire.golden.json'); }); - it('is blessed for daemon protocol 6 and browser protocol 9', () => { - expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 9 }); + it('is blessed for daemon protocol 6 and browser protocol 10', () => { + expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 10 }); }); it('records every sample as valid in its own direction', () => { @@ -323,11 +341,28 @@ describe('wire golden', () => { }); }); +const shapeSample = (value: T) => z.object({ text: z.string(), value: value.nullable() }); + const frozenSchema = z.object({ frames: z.array(z.object({ kind: z.number(), payload: z.string(), bytes: z.string() })), messages: z.array(z.object({ dirs: z.array(directionSchema), json: z.string(), valid: z.boolean() })), + printCode: z.array(shapeSample(z.object({ port: z.number(), code: z.string() }))).min(1), + homeFragments: z.array(shapeSample(z.object({ code: z.string().nullable(), hub: z.string().nullable() }))).min(1), + remoteFragments: z + .array(shapeSample(z.object({ code: z.string(), host: z.string(), home: z.number(), hubs: z.array(z.string()) }))) + .min(1), }); +/** The decoded value of `text`, or null when `decode` rejects it with a ProtocolError. */ +const decodedOrNull = (decode: (text: string) => T, text: string): T | null => { + try { + return decode(text); + } catch (error) { + if (!(error instanceof ProtocolError)) throw error; + return null; + } +}; + const hex = (bytes: Uint8Array): string => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); const fromHex = (text: string): Uint8Array => Uint8Array.from({ length: text.length / 2 }, (_, i) => parseInt(text.slice(i * 2, i * 2 + 2), 16)); @@ -353,3 +388,29 @@ describe('frozen handshake and frame header', () => { } }); }); + +describe('frozen print-code line and page fragments', () => { + const frozen = frozenSchema.parse(frozenJson); + + it.each(frozen.printCode)('decodes print-code line $text to $value', ({ text, value }) => { + expect(decodedOrNull(decodePrintCode, text)).toEqual(value); + if (value !== null) expect(encodePrintCode(value)).toBe(`{"port":${String(value.port)},"code":"${value.code}"}\n`); + }); + + it.each(frozen.homeFragments)('decodes home fragment "$text" to $value', ({ text, value }) => { + expect(decodedOrNull(decodeHomeFragment, text)).toEqual(value); + if (value !== null) expect(encodeHomeFragment(value)).toBe(text); + }); + + it.each(frozen.remoteFragments)('decodes remote fragment "$text" to $value', ({ text, value }) => { + expect(decodedOrNull(decodeRemoteFragment, text)).toEqual(value); + if (value !== null) expect(encodeRemoteFragment(value)).toBe(text); + }); + + it('holds valid and rejected samples of every shape', () => { + for (const samples of [frozen.printCode, frozen.homeFragments, frozen.remoteFragments]) { + expect(samples.some((s) => s.value === null)).toBe(true); + expect(samples.some((s) => s.value !== null)).toBe(true); + } + }); +}); diff --git a/src/web/client/page.test.ts b/src/web/client/page.test.ts new file mode 100644 index 0000000..4ada902 --- /dev/null +++ b/src/web/client/page.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { keptRemote, pageTitle, readFragment, type PageFragment, type RemotePage } from './page.js'; + +const CODE = '0123456789abcdef'.repeat(4); +const OTHER = 'f'.repeat(64); + +/** An in-memory stand-in for `sessionStorage`. */ +const mapStorage = (): Pick & { items: Map } => { + const items = new Map(); + return { + items, + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => { + items.set(key, value); + }, + }; +}; + +const remoteFragment = (host: string, hubs: string[], code = CODE): PageFragment => ({ kind: 'remote', code, host, home: 7417, hubs }); + +describe('readFragment', () => { + it('reads a remote fragment', () => { + expect(readFragment(`#code=${CODE}&host=box&home=7417&hubs=box,gpu`)).toEqual({ + kind: 'remote', + code: CODE, + host: 'box', + home: 7417, + hubs: ['box', 'gpu'], + }); + }); + + it.each([ + [`#code=${CODE}`, { code: CODE, hub: null }], + ['#hub=box', { code: null, hub: 'box' }], + [`#code=${CODE}&hub=box`, { code: CODE, hub: 'box' }], + ])('reads %s as a home fragment', (hash, fragment) => { + expect(readFragment(hash)).toEqual({ kind: 'home', ...fragment }); + }); + + it.each(['', '#'])('reads %j as no fragment', (hash) => { + expect(readFragment(hash)).toEqual({ kind: 'none' }); + }); + + it.each([ + '#code=x&hub=a b', + '#code=x&hub=a%20b', + '#hub=box&hub=gpu', + `#token=${CODE}`, + '#code=x', + `#code=${CODE}&host=box&hubs=box`, + `#code=${CODE}&host=box&home=0080&hubs=box`, + `#code=${CODE}&host=box&home=7417&hubs=gpu`, + ])('ignores the malformed fragment %s', (hash) => { + expect(readFragment(hash)).toEqual({ kind: 'none' }); + }); +}); + +describe('keptRemote', () => { + it('keeps a remote fragment and returns it', () => { + const storage = mapStorage(); + const kept: RemotePage = { host: 'box', home: 7417, hubs: ['box', 'gpu'] }; + expect(keptRemote(storage, remoteFragment('box', ['box', 'gpu']))).toEqual(kept); + expect(storage.items.size).toBeGreaterThan(0); + }); + + it('gives the kept values after a reload without a fragment', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box', 'gpu'])); + expect(keptRemote(storage, { kind: 'none' })).toEqual({ host: 'box', home: 7417, hubs: ['box', 'gpu'] }); + }); + + it('replaces the kept values with a new remote fragment', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box', 'gpu'])); + expect(keptRemote(storage, remoteFragment('gpu', ['gpu'], OTHER))).toEqual({ host: 'gpu', home: 7417, hubs: ['gpu'] }); + expect(keptRemote(storage, { kind: 'none' })).toEqual({ host: 'gpu', home: 7417, hubs: ['gpu'] }); + }); + + it('never keeps the one-time code', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box'])); + expect([...storage.items.values()].join('')).not.toContain(CODE); + }); + + it.each([{ kind: 'none' }, { kind: 'home', code: CODE, hub: null }, { kind: 'home', code: null, hub: 'box' }])( + 'is a home page for %j with nothing kept', + (fragment) => { + expect(keptRemote(mapStorage(), fragment)).toBeNull(); + }, + ); + + it('ignores a kept value that does not parse', () => { + const storage = mapStorage(); + keptRemote(storage, remoteFragment('box', ['box'])); + for (const key of storage.items.keys()) storage.items.set(key, '{"host":"a b","home":1,"hubs":[]}'); + expect(keptRemote(storage, { kind: 'none' })).toBeNull(); + for (const key of storage.items.keys()) storage.items.set(key, 'not json'); + expect(keptRemote(storage, { kind: 'none' })).toBeNull(); + }); +}); + +describe('pageTitle', () => { + it('titles a home page worktree-term', () => { + expect(pageTitle(null)).toBe('worktree-term'); + }); + + it('titles a remote page by its host', () => { + expect(pageTitle({ host: 'devbox', home: 7417, hubs: ['devbox'] })).toBe('worktree-term — devbox'); + }); +}); diff --git a/src/web/ui/hubs.test.ts b/src/web/ui/hubs.test.ts new file mode 100644 index 0000000..49453b8 --- /dev/null +++ b/src/web/ui/hubs.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import type { MessageOf } from '../../protocol/index.js'; +import { hubLineText, hubsMenu, hubStep, initialHubState, type HubAction, type HubEffect, type HubState } from './hubs.js'; + +type HubMessage = MessageOf<'hubToBrowser'>; + +const CODE = '0123456789abcdef'.repeat(4); +const LOGIN = 'https://login.tailscale.com/a/abc123'; +const BOX_URL = `http://127.0.0.1:7418/#code=${CODE}&host=box&home=7417&hubs=box,gpu`; + +const local = { idx: 0, name: 'laptop', remote: false }; +const box = { idx: 1, name: 'box', remote: true }; +const gpu = { idx: 2, name: 'gpu', remote: true }; + +/** Applies `actions` in order from the initial state, collecting every effect. */ +const run = (...actions: HubAction[]): { state: HubState; effects: HubEffect[] } => { + let state = initialHubState; + const effects: HubEffect[] = []; + for (const action of actions) { + const step = hubStep(state, action); + state = step.state; + effects.push(step.effect); + } + return { state, effects }; +}; + +const reply = (m: HubMessage): HubAction => ({ t: 'reply', m }); +const openBox = (req = 1): HubAction => ({ t: 'open', req, host: 1, name: 'box' }); +const failed = (req: number, code: Extract['code'], message: string): HubAction => + reply({ t: 'error', req, host: 1, code, message }); + +describe('hubsMenu', () => { + it('lists This machine, marked, then each remote host on a home page', () => { + expect(hubsMenu(null, [local, box, gpu])).toEqual([ + { label: 'This machine', marked: true, target: { t: 'stay' } }, + { label: 'box', marked: false, target: { t: 'open', host: 1, name: 'box' } }, + { label: 'gpu', marked: false, target: { t: 'open', host: 2, name: 'gpu' } }, + ]); + }); + + it('shows no menu without remote hosts', () => { + expect(hubsMenu(null, [local])).toBeNull(); + expect(hubsMenu(null, [])).toBeNull(); + }); + + it('routes a remote page back through its home hub, marking its own host', () => { + expect(hubsMenu({ host: 'box', home: 7417, hubs: ['box', 'gpu'] }, [local])).toEqual([ + { label: 'This machine', marked: false, target: { t: 'navigate', url: 'http://127.0.0.1:7417/' } }, + { label: 'box', marked: true, target: { t: 'stay' } }, + { label: 'gpu', marked: false, target: { t: 'navigate', url: 'http://127.0.0.1:7417/#hub=gpu' } }, + ]); + }); + + it('lists a remote page’s kept hubs, not its session’s hosts', () => { + const entries = hubsMenu({ host: 'gpu', home: 9000, hubs: ['gpu'] }, [local, box]); + expect(entries?.map((e) => e.label)).toEqual(['This machine', 'gpu']); + expect(entries?.[0]?.target).toEqual({ t: 'navigate', url: 'http://127.0.0.1:9000/' }); + }); +}); + +describe('hubLineText', () => { + it.each([ + [{ kind: 'opening', name: 'box' }, 'Opening box…'], + [{ kind: 'login', name: 'box', login: LOGIN }, 'box needs Tailscale login'], + [{ kind: 'outdated', name: 'box', message: 'wtd is too old' }, 'box: wtd is too old'], + [{ kind: 'installing', name: 'box' }, 'Installing on box…'], + [{ kind: 'failed', name: 'box', message: 'ssh: Could not resolve hostname box' }, 'box: ssh: Could not resolve hostname box'], + [{ kind: 'unknown', name: 'nope' }, 'no host named nope'], + ] as const)('shows %j as %j', (line, text) => { + expect(hubLineText(line)).toBe(text); + }); +}); + +describe('hubStep', () => { + it('starts with no line and nothing pending', () => { + expect(initialHubState).toEqual({ line: null, pending: null }); + }); + + it('shows Opening while the opening waits', () => { + const { state, effects } = run(openBox()); + expect(state.line).toEqual({ kind: 'opening', name: 'box' }); + expect(state.pending).toEqual({ req: 1, host: 1, name: 'box' }); + expect(effects).toEqual([null]); + }); + + it('shows the login link of the pending opening', () => { + const { state } = run(openBox(), reply({ t: 'hubLogin', req: 1, login: LOGIN })); + expect(state.line).toEqual({ kind: 'login', name: 'box', login: LOGIN }); + }); + + it('navigates to the URL of the pending opening', () => { + const { effects } = run(openBox(), reply({ t: 'hubLogin', req: 1, login: LOGIN }), reply({ t: 'hubOpened', req: 1, url: BOX_URL })); + expect(effects).toEqual([null, null, { t: 'navigate', url: BOX_URL }]); + }); + + it('ignores replies to an earlier pick', () => { + const { state, effects } = run( + openBox(1), + { t: 'open', req: 2, host: 2, name: 'gpu' }, + reply({ t: 'hubOpened', req: 1, url: BOX_URL }), + reply({ t: 'hubLogin', req: 1, login: LOGIN }), + failed(1, 'hub-failed', 'late'), + ); + expect(state.line).toEqual({ kind: 'opening', name: 'gpu' }); + expect(effects).toEqual([null, null, null, null, null]); + }); + + it('offers a reinstall for an outdated host', () => { + const { state } = run(openBox(), failed(1, 'hub-outdated', 'wtd on the host is missing or too old to serve its hub')); + expect(state.line).toEqual({ kind: 'outdated', name: 'box', message: 'wtd on the host is missing or too old to serve its hub' }); + }); + + it('shows Installing, then reopens the hub once the install is done', () => { + const { state, effects } = run(openBox(1), failed(1, 'hub-outdated', 'too old'), { t: 'install', req: 2, host: 1, name: 'box' }); + expect(state.line).toEqual({ kind: 'installing', name: 'box' }); + expect(effects.at(-1)).toBeNull(); + expect(hubStep(state, reply({ t: 'done', req: 2 })).effect).toEqual({ t: 'reopen', host: 1, name: 'box' }); + }); + + it('shows a failed install with its reason', () => { + const { state, effects } = run( + openBox(1), + failed(1, 'hub-outdated', 'too old'), + { t: 'install', req: 2, host: 1, name: 'box' }, + failed(2, 'internal', 'install: no Node'), + ); + expect(state.line).toEqual({ kind: 'failed', name: 'box', message: 'install: no Node' }); + expect(effects.at(-1)).toBeNull(); + }); + + it('ignores a done for an opening', () => { + const { state, effects } = run(openBox(), reply({ t: 'done', req: 1 })); + expect(state.line).toEqual({ kind: 'opening', name: 'box' }); + expect(effects).toEqual([null, null]); + }); + + it.each([ + ['hub-failed', 'ssh: Could not resolve hostname box'], + ['unknown-host', 'no such host'], + ] as const)('shows a %s error with its reason until dismissed', (code, message) => { + const { state } = run(openBox(), failed(1, code, message)); + expect(state.line).toEqual({ kind: 'failed', name: 'box', message }); + expect(state.pending).toBeNull(); + expect(hubStep(state, { t: 'dismiss' }).state.line).toBeNull(); + }); + + it('shows an unknown hub name', () => { + const { state, effects } = run({ t: 'unknown', name: 'nope' }); + expect(state.line).toEqual({ kind: 'unknown', name: 'nope' }); + expect(effects).toEqual([null]); + }); + + it('drops a pending opening when an unknown name follows it', () => { + const { state, effects } = run(openBox(), { t: 'unknown', name: 'nope' }, reply({ t: 'hubOpened', req: 1, url: BOX_URL })); + expect(state.line).toEqual({ kind: 'unknown', name: 'nope' }); + expect(effects.at(-1)).toBeNull(); + }); +}); diff --git a/test/e2e/contract.ts b/test/e2e/contract.ts index 830ca1b..b5f2bf7 100644 --- a/test/e2e/contract.ts +++ b/test/e2e/contract.ts @@ -130,6 +130,18 @@ export const TID = { notice: 'notice', /** Terminal container, `data-host` and `data-term`; holds the `.xterm` element; hidden ones are not visible. */ terminal: 'terminal', + /** Header button "Hubs", present only while there is a remote host; opens the list of `hubsEntry`. */ + hubsMenu: 'hubs-menu', + /** One per hub in the open Hubs menu, in order: text = "This machine" or the host name; `aria-current="true"` on the page's own hub. */ + hubsEntry: 'hubs-entry', + /** Status line beside the Hubs menu for the latest hub opening: `Opening …`, ` needs Tailscale login`, `: `, `Installing on …` or `no host named `. */ + hubStatus: 'hub-status', + /** "Open login link" anchor inside the status line: `href` = the login URL, `target="_blank"`, `rel` with `noopener` and `noreferrer`. */ + hubLogin: 'hub-login', + /** "Reinstall on " button inside the status line of an outdated remote; sends `installHub`, then opens the hub again. */ + hubReinstall: 'hub-reinstall', + /** × inside the status line of a failed opening; clears the line. */ + hubDismiss: 'hub-dismiss', } as const; /** Mark names in rank order, as `data-mark` values. */ @@ -157,5 +169,7 @@ export const termTab = (termId: number): string => `${byTestId(TID.termTab)}[dat export const pane = (termId: number): string => `${byTestId(TID.pane)}[data-term="${String(termId)}"]`; +export const hubsEntry = (label: string): string => `${byTestId(TID.hubsEntry)}:text-is("${label}")`; + export const terminalBox = (host: number, termId: number): string => `${byTestId(TID.terminal)}[data-host="${String(host)}"][data-term="${String(termId)}"]`; diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts index 5199fc6..01e46d9 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/fixture.ts @@ -60,10 +60,11 @@ export const test = base.extend({ }, }); -/** A configured remote host: its `name`, SSH alias, absolute repos and discovery roots. */ +/** A configured remote host: its `name`, SSH alias, hub tunnel port, absolute repos and discovery roots. */ export interface HostConfig { name: string; ssh: string; + port?: number; repos?: readonly string[]; roots?: readonly string[]; } @@ -129,10 +130,10 @@ export const refuseBrowserDialogs = (page: Page): string[] => { return seen; }; -/** Runs `wtd ui` with the stub browser and returns the URL it opened. */ -export const runUi = async (hub: HubHost): Promise => { +/** Runs `wtd ui [args]` with the stub browser and returns the URL it opened. */ +export const runUi = async (hub: HubHost, args: readonly string[] = []): Promise => { const before = hub.browserCalls().length; - const ui = hub.wtd(['ui']); + const ui = hub.wtd(['ui', ...args]); const exit = await ui.exited; if (exit.code !== 0) throw new Error(`wtd ui exited ${JSON.stringify(exit)}: ${ui.stderr}`); // The browser runs detached; wait until it has recorded its call. diff --git a/test/e2e/hosts.spec.ts b/test/e2e/hosts.spec.ts index 1ba81c9..c1f9f74 100644 --- a/test/e2e/hosts.spec.ts +++ b/test/e2e/hosts.spec.ts @@ -1,9 +1,9 @@ -import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { makeRepo, waitUntil } from '../support/daemon-host.js'; import { FakeDaemon } from '../support/fake-daemon.js'; -import { installRemote, type FakeRemote } from '../support/fake-ssh.js'; +import { installRemote } from '../support/fake-ssh.js'; import { byTestId, hostBanner, repoTab, terminalBox, TID, worktreeEntry } from './contract.js'; import { confirmAction, @@ -34,18 +34,11 @@ import { const RESOLVE_ERROR = 'ssh: Could not resolve hostname box'; -/** Replaces the remote's `wtd` with a shell script. */ -const plantWtd = (remote: FakeRemote, script: string): void => { - mkdirSync(dirname(remote.shim), { recursive: true }); - writeFileSync(remote.shim, `#!/bin/sh\n${script}\n`); - chmodSync(remote.shim, 0o755); -}; - test.describe('host status on repo tabs', () => { test('a remote tab is labelled with its host and shows a reconnecting host status, without a banner', async ({ hub, page, ssh }) => { const remote = ssh.addHost('box'); // The first connect fails; the retry never answers, so the host stays reconnecting. - plantWtd(remote, 'if [ -e "$HOME/once" ]; then exec sleep 600; fi; : > "$HOME/once"; exit 1'); + remote.plantWtd('if [ -e "$HOME/once" ]; then exec sleep 600; fi; : > "$HOME/once"; exit 1'); const app = join(remote.home, 'srv', 'app'); writeHubConfig(hub, { hosts: [{ name: 'box', ssh: 'box', repos: [app] }] }); await openUi(hub, page); diff --git a/test/e2e/hubs.spec.ts b/test/e2e/hubs.spec.ts new file mode 100644 index 0000000..b11aa15 --- /dev/null +++ b/test/e2e/hubs.spec.ts @@ -0,0 +1,245 @@ +import type { Page } from '@playwright/test'; +import { join } from 'node:path'; +import { makeRepo } from '../support/daemon-host.js'; +import { freePort, type HubHost } from '../support/hub-host.js'; +import { remoteHub } from '../support/remote-hub.js'; +import { byTestId, hubsEntry, repoTab, TID } from './contract.js'; +import { expect, hubRequests, makeRepoWith, nodeRemote, openUi, runUi, test, writeHubConfig, type HostConfig } from './fixture.js'; + +const RESOLVE_ERROR = 'ssh: Could not resolve hostname box: Name or service not known'; + +/** A free port none of the remote hubs of `taken` listens on. */ +const tunnelPort = async (...taken: readonly { port: number }[]): Promise => { + for (;;) { + const port = await freePort(); // NOSONAR(S9382) — retries until a port outside `taken` is free + if (!taken.some((t) => t.port === port)) return port; + } +}; + +const originOf = (port: number): string => `http://127.0.0.1:${String(port)}`; + +/** Waits until the tab shows `origin` with its fragment removed. */ +const landedOn = async (page: Page, origin: string): Promise => { + await expect.poll(() => page.url(), { timeout: 30_000 }).toBe(`${origin}/`); +}; + +/** Opens the Hubs menu and picks the entry labelled `label`. */ +const pick = async (page: Page, label: string): Promise => { + await page.locator(byTestId(TID.hubsMenu)).click(); + await page.locator(hubsEntry(label)).click(); +}; + +/** The labels of the open Hubs menu, in order. */ +const menuLabels = async (page: Page): Promise => { + await page.locator(byTestId(TID.hubsMenu)).click(); + const labels = await page.locator(byTestId(TID.hubsEntry)).allTextContents(); + await page.keyboard.press('Escape'); + return labels.map((label) => label.trim()); +}; + +/** Configures a local repo and `hosts`, opens the home page, and returns the local repo. */ +const home = async (hub: HubHost, page: Page, hosts: readonly HostConfig[]): Promise => { + const { repo } = makeRepoWith(hub, 'local-app', []); + writeHubConfig(hub, { repos: [repo], hosts }); + await openUi(hub, page); + await expect(page.locator(repoTab(repo))).toBeVisible(); + return repo; +}; + +test.describe('Hubs menu', () => { + test('lists This machine, marked, and every remote host', async ({ hub, page, ssh }) => { + ssh.failHost('box', RESOLVE_ERROR); + ssh.failHost('gpu', RESOLVE_ERROR); + await home(hub, page, [ + { name: 'box', ssh: 'box' }, + { name: 'gpu', ssh: 'gpu' }, + ]); + expect(await menuLabels(page)).toEqual(['This machine', 'box', 'gpu']); + await page.locator(byTestId(TID.hubsMenu)).click(); + await expect(page.locator(hubsEntry('This machine'))).toHaveAttribute('aria-current', 'true'); + await expect(page.locator(hubsEntry('box'))).not.toHaveAttribute('aria-current', 'true'); + }); + + test('is absent without remote hosts', async ({ hub, page }) => { + await home(hub, page, []); + await expect(page.locator(byTestId(TID.hubsMenu))).toHaveCount(0); + }); + + test('picking a host shows its own hub in the same tab, titled by the host', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box'); + const port = await tunnelPort(box); + await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + await expect(page).toHaveTitle('worktree-term'); + let pages = 0; + page.context().on('page', () => pages++); + + await pick(page, 'box'); + await landedOn(page, originOf(port)); + await expect(page).toHaveTitle('worktree-term — box'); + await expect(page.locator(repoTab(box.repo))).toBeVisible(); + await expect(page.locator(byTestId(TID.authMessage))).toHaveCount(0); + expect(pages).toBe(0); + }); + + test('a remote page keeps its title and menu across a reload', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box'); + ssh.failHost('gpu', RESOLVE_ERROR); + const port = await tunnelPort(box); + await home(hub, page, [ + { name: 'box', ssh: 'box', port }, + { name: 'gpu', ssh: 'gpu' }, + ]); + await pick(page, 'box'); + await landedOn(page, originOf(port)); + + await page.reload(); + await expect(page).toHaveTitle('worktree-term — box'); + expect(await menuLabels(page)).toEqual(['This machine', 'box', 'gpu']); + await page.locator(byTestId(TID.hubsMenu)).click(); + await expect(page.locator(hubsEntry('box'))).toHaveAttribute('aria-current', 'true'); + }); + + test('This machine on a remote page goes back home, connected with the stored token', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box'); + const port = await tunnelPort(box); + const repo = await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + await pick(page, 'box'); + await landedOn(page, originOf(port)); + + await pick(page, 'This machine'); + await landedOn(page, hub.origin); + await expect(page.locator(repoTab(repo))).toBeVisible(); + await expect(page.locator(byTestId(TID.authMessage))).toHaveCount(0); + await expect(page).toHaveTitle('worktree-term'); + }); + + test('switching from one remote to another goes through the home page', async ({ hub, page, ssh }) => { + test.setTimeout(180_000); + const box = await remoteHub(hub, ssh, 'box'); + const gpu = await remoteHub(hub, ssh, 'gpu', [box.port]); + const boxPort = await tunnelPort(box, gpu); + const gpuPort = await tunnelPort(box, gpu, { port: boxPort }); + await home(hub, page, [ + { name: 'box', ssh: 'box', port: boxPort }, + { name: 'gpu', ssh: 'gpu', port: gpuPort }, + ]); + await pick(page, 'box'); + await landedOn(page, originOf(boxPort)); + const visited: string[] = []; + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) visited.push(frame.url()); + }); + + await pick(page, 'gpu'); + await landedOn(page, originOf(gpuPort)); + await expect(page).toHaveTitle('worktree-term — gpu'); + await expect(page.locator(repoTab(gpu.repo))).toBeVisible(); + expect(visited).toContain(`${hub.origin}/#hub=gpu`); + }); +}); + +test.describe('hub opening status', () => { + test('shows the opening while the host has not answered', async ({ hub, page, ssh }) => { + const remote = ssh.addHost('box'); + remote.plantWtd('exec sleep 600'); + await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await pick(page, 'box'); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveText('Opening box…'); + expect(page.url()).toBe(`${hub.origin}/`); + }); + + test('shows the Tailscale login link while waiting, then navigates once approved', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box'); + const port = await tunnelPort(box); + await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + ssh.loginHost('box'); + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toContainText('box needs Tailscale login', { timeout: 15_000 }); + const link = status.locator(byTestId(TID.hubLogin)); + await expect(link).toHaveText('Open login link'); + await expect(link).toHaveAttribute('href', /^https:\/\/login\.tailscale\.com\/a\/wtd\d+$/); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', /\bnoopener\b/); + await expect(link).toHaveAttribute('rel', /\bnoreferrer\b/); + + const url = (await link.getAttribute('href')) ?? ''; + ssh.skipLogin('box'); + ssh.approveLogin(url); + await landedOn(page, originOf(port)); + await expect(page).toHaveTitle('worktree-term — box'); + }); + + test('an outdated remote is reinstalled from the status line, then opened', async ({ hub, page, ssh, wire }) => { + test.setTimeout(180_000); + const remote = nodeRemote(ssh, 'box'); + remote.plantWtd('exit 2'); + const remotePort = await freePort(); + remote.writeHubConfig({ port: remotePort, repos: [makeRepo(join(remote.home, 'app'))] }); + const port = await tunnelPort({ port: remotePort }); + await home(hub, page, [{ name: 'box', ssh: 'box', port }]); + + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toContainText(/^box: .*missing or too old/, { timeout: 15_000 }); + const reinstall = status.locator(byTestId(TID.hubReinstall)); + await expect(reinstall).toHaveText('Reinstall on box'); + const mark = wire.markSent(); + await reinstall.click(); + await expect(status).toHaveText('Installing on box…'); + await landedOn(page, originOf(port)); + expect(hubRequests(wire, mark).map((m) => m.t)).toEqual(['installHub', 'openHub']); + }); + + test('a failure shows its reason with a × that clears it, leaving the tab home', async ({ hub, page, ssh }) => { + ssh.failHost('box', RESOLVE_ERROR); + await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await pick(page, 'box'); + const status = page.locator(byTestId(TID.hubStatus)); + await expect(status).toContainText(`box: ${RESOLVE_ERROR}`, { timeout: 15_000 }); + await status.locator(byTestId(TID.hubDismiss)).click(); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveCount(0); + expect(page.url()).toBe(`${hub.origin}/`); + }); +}); + +test.describe('hub opening from the address', () => { + test('wtd ui opens that host’s hub without a click', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const box = await remoteHub(hub, ssh, 'box'); + const port = await tunnelPort(box); + const { repo } = makeRepoWith(hub, 'local-app', []); + writeHubConfig(hub, { repos: [repo], hosts: [{ name: 'box', ssh: 'box', port }] }); + await page.goto(await runUi(hub, ['box'])); + await landedOn(page, originOf(port)); + await expect(page.locator(repoTab(box.repo))).toBeVisible(); + }); + + test('a hub no remote host carries is reported and the page stays', async ({ hub, page, ssh, wire }) => { + ssh.failHost('box', RESOLVE_ERROR); + const { repo } = makeRepoWith(hub, 'local-app', []); + writeHubConfig(hub, { repos: [repo], hosts: [{ name: 'box', ssh: 'box' }] }); + await page.goto(`${await runUi(hub)}&hub=nope`); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveText('no host named nope'); + await expect(page.locator(repoTab(repo))).toBeVisible(); + expect(page.url()).toBe(`${hub.origin}/`); + expect(hubRequests(wire).filter((m) => m.t === 'openHub')).toEqual([]); + }); + + test('a malformed fragment is removed and ignored, the stored token connecting', async ({ hub, page, ssh, wire }) => { + ssh.failHost('box', RESOLVE_ERROR); + const repo = await home(hub, page, [{ name: 'box', ssh: 'box' }]); + await page.goto('about:blank'); + await page.goto(`${hub.origin}/#code=x&hub=a b`); + await expect(page.locator(repoTab(repo))).toBeVisible(); + await expect(page.locator(byTestId(TID.authMessage))).toHaveCount(0); + expect(await page.evaluate(() => location.hash)).toBe(''); + await page.waitForTimeout(1000); // NOSONAR(S2925) — absence check: nothing to synchronise on + expect(hubRequests(wire).filter((m) => m.t === 'openHub')).toEqual([]); + await expect(page.locator(byTestId(TID.hubStatus))).toHaveCount(0); + }); +}); diff --git a/test/integration/ssh-localhost.test.ts b/test/integration/ssh-localhost.test.ts index 76309d6..91f75d9 100644 --- a/test/integration/ssh-localhost.test.ts +++ b/test/integration/ssh-localhost.test.ts @@ -1,15 +1,18 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { connect, createServer, type Server, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { forwardToHub, type HubForward } from '../../src/hub/links/index.js'; +import { hostPaths } from '../../src/platform/files/index.js'; +import { DAEMON_PROTOCOL_VERSION, decodePrintCode, decodeRemoteFragment } from '../../src/protocol/index.js'; import { alive, makeRepo, waitUntil } from '../support/daemon-host.js'; import { exec, REPO_ROOT } from '../support/exec.js'; import { FakeDaemon } from '../support/fake-daemon.js'; import { FakeRemote, shellQuote } from '../support/fake-ssh.js'; -import type { HubClient } from '../support/hub-client.js'; -import { HubHost } from '../support/hub-host.js'; +import { HubClient } from '../support/hub-client.js'; +import { freePort, HubHost, rawHttp } from '../support/hub-host.js'; // Opt-in: needs sshd on localhost accepting this user's key with BatchMode. vi.setConfig({ testTimeout: 180_000, hookTimeout: 300_000 }); @@ -19,13 +22,16 @@ const REMOTE = 1; /** * `WTD_SSH` wrapper: the real `ssh` with every option the caller gave, the remote command run under - * `home` so the test never touches this user's own installation. + * `home` so the test never touches this user's own installation; a `-W` forward is passed through as given. */ const sshWrapper = (dir: string, home: string): string => { const path = join(dir, 'ssh'); writeFileSync( path, `#!/bin/sh +for a do + if [ "$a" = -W ]; then exec ssh "$@"; fi +done n=$# for a do shift @@ -78,6 +84,7 @@ const procs = (): { pid: number; ppid: number; argv: string[] }[] => { let host: HubHost; let remote: FakeRemote; +const servers: Server[] = []; let remoteHome: string; beforeAll(() => { @@ -95,6 +102,8 @@ beforeEach(async () => { }); afterEach(async () => { + vi.unstubAllEnvs(); + for (const server of servers.splice(0)) server.close(); for (const pid of host.hubPids()) process.kill(pid, 'SIGKILL'); for (const pid of mastersIn(host.runDir)) process.kill(pid, 'SIGTERM'); await remote.cleanup(); @@ -196,3 +205,149 @@ describe('remote host over real ssh', () => { expect(sshChildrenOf(hub).filter((pid) => !firstClients.includes(pid))).toHaveLength(1); }); }); + +/** Pids of `ssh -W` forwards whose parent is `parent`. */ +const forwardsOf = (parent: number): number[] => + procs() + .filter((p) => p.ppid === parent && p.argv.includes('-W')) + .map((p) => p.pid); + +/** Starts localhost's hub under the throwaway home with `wtd ui --print-code`, without a control master; returns its port and code. */ +const printCode = (): { port: number; code: string } => { + const run = spawnSync(host.sshProgram ?? 'ssh', ['-o', 'BatchMode=yes', ALIAS, '"$HOME/.local/bin/wtd" ui --print-code'], { + encoding: 'utf8', + }); + if (run.status !== 0) throw new Error(`wtd ui --print-code failed: ${run.stderr}`); + return decodePrintCode(run.stdout); +}; + +/** A local listener on a free port relaying each connection to `remotePort` on localhost with `forwardToHub`. */ +const tunnel = async (remotePort: number): Promise<{ port: number; forwards: HubForward[] }> => { + vi.stubEnv('WTD_SSH', host.sshProgram ?? 'ssh'); + const paths = hostPaths({ env: { XDG_STATE_HOME: host.stateHome }, home: host.home, host: 'local' }); + const forwards: HubForward[] = []; + const server = createServer((socket) => { + forwards.push(forwardToHub(paths, ALIAS, remotePort, socket)); + }); + servers.push(server); + const port = await freePort(); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { port, forwards }; +}; + +/** An open connection to 127.0.0.1:`port` and a promise of its close. */ +const held = async (port: number): Promise<{ socket: Socket; closed: Promise }> => { + const socket = connect(port, '127.0.0.1'); + socket.on('error', () => undefined); + const closed = new Promise((resolve) => { + socket.once('close', () => { + resolve(); + }); + }); + await new Promise((resolve) => { + socket.once('connect', () => { + resolve(); + }); + }); + return { socket, closed }; +}; + +/** Configures localhost's hub with a repo and a free port, and the local hub with `lh` on the tunnel port `tunnelPort`. */ +const configureHubs = async (): Promise<{ repo: string; remotePort: number; tunnelPort: number }> => { + await install(); + const repo = makeRepo(join(remoteHome, 'repo')); + const remotePort = await freePort(); + let tunnelPort = await freePort(); + while (tunnelPort === remotePort) tunnelPort = await freePort(); // NOSONAR(S9382) — retries until the ports differ + remote.writeHubConfig({ port: remotePort, repos: [repo] }); + host.writeConfig({ port: host.port, hosts: [{ name: 'lh', ssh: ALIAS, port: tunnelPort }] }); + return { repo, remotePort, tunnelPort }; +}; + +/** Sends `openHub` for `lh` and returns the opened page's port and code. */ +const openLh = async (client: HubClient): Promise<{ port: number; code: string }> => { + const from = client.mark(); + client.send({ t: 'openHub', req: 1000, host: REMOTE }); + const reply = await client.waitFor(['hubOpened', 'error'], (m) => m.req === 1000, { from, timeout: 60_000 }); + if (reply.t !== 'hubOpened') throw new Error(`openHub failed: ${JSON.stringify(reply)}`); + const url = new URL(reply.url); + return { port: Number(url.port), code: decodeRemoteFragment(url.hash.slice(1)).code }; +}; + +describe('remote hub over real ssh', () => { + it('forwards to the remote hub with ssh -W when no control master exists', async () => { + const { remotePort } = await configureHubs(); + expect(printCode().port).toBe(remotePort); + expect(mastersIn(host.runDir)).toEqual([]); + const { port, forwards } = await tunnel(remotePort); + const response = await rawHttp(port, 'GET', '/'); + expect(response.status).toBe(200); + expect(forwards).toHaveLength(1); + await expect(forwards[0]?.ended).resolves.not.toMatch(/open failed|forwarding failed/); + }); + + it('opens the remote hub through the local hub over the existing control master', async () => { + const { repo, tunnelPort } = await configureHubs(); + await host.startHub(); + const client = await host.session(); + await connectedInstance(client); + const masters = mastersIn(host.runDir); + expect(masters).toHaveLength(1); + + const { port, code } = await openLh(client); + expect(port).toBe(tunnelPort); + expect((await rawHttp(port, 'GET', '/')).status).toBe(200); + const page = await HubClient.open(`ws://127.0.0.1:${String(port)}/ws`, { + protocols: ['wtd', `wtd.code.${code}`], + origin: `http://127.0.0.1:${String(port)}`, + }); + const { hosts } = await page.handshake(); + expect(hosts.hosts[0]?.repos).toEqual([repo]); + expect(mastersIn(host.runDir)).toEqual(masters); + page.close(); + }); + + it('reports a forward to a port nothing listens on with the line ssh wrote', async () => { + await install(); + const { port, forwards } = await tunnel(await freePort()); + const { closed } = await held(port); + await closed; + await expect(forwards[0]?.ended).resolves.toMatch(/open failed|forwarding failed/); + }); + + it('ends the forwards when the control master is lost', async () => { + await configureHubs(); + await host.startHub(); + const client = await host.session(); + await connectedInstance(client); + const { port } = await openLh(client); + const { closed } = await held(port); + const hub = host.record()?.pid ?? 0; + const forwards = await waitUntil(() => { + const found = forwardsOf(hub); + return found.length > 0 ? found : undefined; + }, 'a forward of the held connection'); + + for (const pid of mastersIn(host.runDir)) process.kill(pid, 'SIGTERM'); + await closed; + await waitUntil(() => forwards.every((pid) => !alive(pid)), 'the forwards to end', 10_000); + }); + + it('leaves no ssh process behind once the hub stops', async () => { + await configureHubs(); + await host.startHub(); + const client = await host.session(); + await connectedInstance(client); + const { port } = await openLh(client); + const { closed } = await held(port); + const hub = host.record()?.pid ?? 0; + await waitUntil(() => forwardsOf(hub).length > 0, 'a forward of the held connection'); + const children = sshChildrenOf(hub); + + process.kill(hub, 'SIGTERM'); + await closed; + await waitUntil(() => host.hubPids().length === 0, 'the hub to exit', 10_000); + await waitUntil(() => children.every((pid) => !alive(pid)), 'the hub’s ssh processes to end', 10_000); + await expect(rawHttp(port, 'GET', '/')).rejects.toThrow(); + }); +}); diff --git a/test/process/fake-ssh.test.ts b/test/process/fake-ssh.test.ts index 3dbfb99..e304629 100644 --- a/test/process/fake-ssh.test.ts +++ b/test/process/fake-ssh.test.ts @@ -1,10 +1,13 @@ -import { spawn } from 'node:child_process'; -import { copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { expect, it } from 'vitest'; -import { waitUntil } from '../support/daemon-host.js'; +import type { Readable, Writable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { alive, waitUntil } from '../support/daemon-host.js'; import { FakeSsh } from '../support/fake-ssh.js'; +import { freePort } from '../support/hub-host.js'; it('cleanup removes its directory while runs it cannot see keep recording their calls for a while', async () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-fake-ssh-'))); @@ -28,3 +31,149 @@ it('cleanup removes its directory while runs it cannot see keep recording their rmSync(elsewhere, { recursive: true, force: true }); } }); + +type Run = ChildProcessByStdio; + +interface Finished { + code: number | null; + stdout: string; + stderr: string; +} + +describe('FakeSsh forwards and planted programs', () => { + let dir: string; + let ssh: FakeSsh; + const servers: Server[] = []; + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-fake-ssh-'))); + ssh = new FakeSsh(join(dir, 'ssh')); + }); + + afterEach(async () => { + for (const server of servers.splice(0)) server.close(); + await ssh.cleanup(); + rmSync(dir, { recursive: true, force: true }); + }); + + const run = (args: readonly string[]): Run => spawn(ssh.program, [...args], { stdio: ['pipe', 'pipe', 'pipe'] }); + + const finished = (child: Run): Promise => + new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + child.once('close', (code) => { + resolve({ code, stdout, stderr }); + }); + }); + + const forward = (alias: string, port: number): Run => + run(['-o', 'BatchMode=yes', '-o', 'ControlMaster=auto', '-W', `127.0.0.1:${String(port)}`, '--', alias]); + + /** A TCP server on a free port answering each line with `echo:` and the line. */ + const echoServer = async (): Promise => { + const port = await freePort(); + const server = createServer((socket) => { + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => socket.write(`echo:${chunk}`)); + socket.on('end', () => socket.end()); + }); + servers.push(server); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return port; + }; + + it('relays a -W run’s standard input and output to the TCP port, both ways, and exits when its input ends', async () => { + ssh.addHost('box'); + const port = await echoServer(); + const child = forward('box', port); + const done = finished(child); + child.stdin.write('ping\n'); + await waitUntil(() => ssh.forwardPids('box').length === 1, 'the forward to run'); + child.stdin.end(); + expect(await done).toEqual({ code: 0, stdout: 'echo:ping\n', stderr: '' }); + }); + + it('records a forward’s target and no command', async () => { + ssh.addHost('box'); + const port = await echoServer(); + const child = forward('box', port); + const done = finished(child); + child.stdin.end(); + await done; + expect(ssh.calls()).toEqual([expect.objectContaining({ alias: 'box', command: '', forward: `127.0.0.1:${String(port)}` })]); + }); + + it('records no forward for a command run', async () => { + ssh.addHost('box'); + expect((await finished(run(['-o', 'BatchMode=yes', '--', 'box', 'echo hi']))).stdout).toBe('hi\n'); + expect(ssh.calls().map((c) => ({ command: c.command, forward: c.forward }))).toEqual([{ command: 'echo hi', forward: null }]); + }); + + it('reports a forward to a closed port as OpenSSH does', async () => { + ssh.addHost('box'); + const result = await finished(forward('box', await freePort())); + expect(result.code).toBe(255); + expect(result.stderr).toBe('channel 0: open failed: connect failed: Connection refused\nstdio forwarding failed\n'); + }); + + it('refuses forwards for a host after refuseForwards, leaving its command runs alone', async () => { + ssh.addHost('box'); + ssh.refuseForwards('box'); + const refused = await finished(forward('box', await echoServer())); + expect(refused).toEqual({ code: 255, stdout: '', stderr: 'channel 0: open failed: administratively prohibited\n' }); + expect(await finished(run(['--', 'box', 'echo hi']))).toEqual({ code: 0, stdout: 'hi\n', stderr: '' }); + }); + + it('lists only live forward runs in forwardPids', async () => { + ssh.addHost('box'); + const port = await echoServer(); + const child = forward('box', port); + const command = run(['--', 'box', 'sleep 30']); + await waitUntil(() => ssh.forwardPids('box').length === 1 && ssh.livePids('box').length === 2, 'both runs'); + expect(ssh.forwardPids('box')).toEqual([child.pid]); + const done = finished(child); + child.stdin.end(); + await done; + expect(ssh.forwardPids('box')).toEqual([]); + command.kill('SIGKILL'); + }); + + it('runs a planted wtd as the remote’s ~/.local/bin/wtd', async () => { + const remote = ssh.addHost('box'); + remote.plantWtd('echo "planted $*"; exit 2'); + expect(statSync(remote.shim).mode & 0o111).not.toBe(0); + expect(await finished(run(['--', 'box', '"$HOME/.local/bin/wtd" ui --print-code']))).toEqual({ + code: 2, + stdout: 'planted ui --print-code\n', + stderr: '', + }); + }); + + it('writes the remote hub’s configuration under the remote’s home', () => { + const remote = ssh.addHost('box'); + remote.writeHubConfig({ port: 9000 }); + expect(JSON.parse(readFileSync(join(remote.home, '.config', 'worktree-term', 'config.json'), 'utf8'))).toEqual({ port: 9000 }); + }); + + it('kills the remote’s hubs on cleanup', async () => { + const remote = ssh.addHost('box'); + const script = join(dir, 'idle.cjs'); + writeFileSync(script, 'setInterval(() => {}, 1000);\n'); + // Looks like a hub of the remote: node