From 13375e7934ec6b26a409081f0f8254057d412fa2 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 17 Oct 2026 17:03:27 +0200 Subject: [PATCH 1/7] Plan DEV-2106: surface the Tailscale SSH check login link OpenSpec change for a needs-login host status carrying the Tailscale check URL, live stderr scanning on remote links, sibling-session redial, and the page banner with the login link. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../.openspec.yaml | 2 + .../design.md | 30 ++++++++ .../proposal.md | 30 ++++++++ .../specs/hub/spec.md | 32 ++++++++ .../specs/protocol/spec.md | 77 +++++++++++++++++++ .../specs/web/spec.md | 20 +++++ .../tasks.md | 32 ++++++++ 7 files changed, 223 insertions(+) create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md create mode 100644 openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml new file mode 100644 index 0000000..b699b88 --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md new file mode 100644 index 0000000..83a88bc --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md @@ -0,0 +1,30 @@ +## Context + +See proposal.md – Why. Today `ProcessLink` (`src/hub/links`) only consults `StderrTail.reason()` once the SSH process ends, and `HostState` (`src/hub/router/hosts.ts`) can express only failure, not "blocked, here is how to unblock". The symptom is one instance of that structural gap: a pending remote link has no channel for live reports, so the fix adds one channel (one parser, one link event, one host event) rather than a UI special case. + +Applicable principles: system 1 (no new model arrows — `hub.links → platform.dialer` exists), 5 (the URL is parsed and prefix-checked where it enters, and the wire decoder rejects anything else), 6, 8; hub 2 (only `links` touches the process), 4 and 5 (state stays per session, no buffering); protocol 2–4 (schema-only type, decoder rejects, browser version bump); web 2 and 7. No arc42 or model edits. + +## Goals / Non-Goals + +**Goals:** surface the check URL within 1 s; connect on approval over the same SSH process; one approval connects every open session. + +**Non-Goals:** the install/reinstall SSH path (it may still hang until its timeout when no control master is authenticated); a handshake timeout (a `needs-login` link waits until Tailscale gives up, then the normal retry runs); opening the URL automatically. + +## Decisions + +- **Wire shape: new status `needs-login` plus a dedicated `login` field**, not the URL in `reason`. `reason` keeps meaning "cause of the latest failure"; the field's schema (`https://login.tailscale.com/` prefix, ≤ 1024 chars, non-null iff `needs-login`) lets the page put it in an `href` safely, since the decoder rejects anything else. +- **Parser in `platform/dialer`, next to `StderrTail`.** It is SSH-standard-error knowledge, pure, and reusable by the install path later. A line scanner on the same stream holds a partial line across chunks, capped at 4 KiB, inside the existing 1 MiB read limit. +- **Optional `login(url)` on `LinkEvents`.** Only `ProcessLink` calls it, only before the first frame, deduplicated per URL. Delivery is guarded exactly like frames: nothing after `closed` or `finished`, and `HostLink` drops callbacks from a link it no longer holds (Codex review finding 1). +- **Host events `login` and `redial`.** `login` → `needs-login`, keeps `failures` (so an expired check that then fails continues the down-count); `redial` → `connecting`, keeps `failures`; `hello` and `failed` clear `login`. +- **Sibling redial through a per-host-key notifier in the router**, beside the existing `HostCoordinator` map. On a greet, it tells every other session's `HostLink` for that key; only one in `needs-login` closes its link, cancels any retry timer, emits `redial` and connects at once. The redial reuses the now-authenticated control master (`ControlMaster=auto`, `ControlPersist=10m`). + +## Risks / Trade-offs + +- [Tailscale changes the banner wording] → the parser keys on `To authenticate, visit:` plus the URL prefix; a change degrades to today's behaviour (host stays `connecting`), never to a wrong link. +- [A dead link's late banner reinstating a stale URL] → lifetime guards above, with tests for banners after close, finish and replacement. +- [The two-session process test passing without redial, since the fake SSH gate is shared per alias] → the fixture gains per-invocation release: release only session A's original process, keep B's blocked, let later dials through, and assert B's redial and its original process's termination (Codex review finding 2). +- [Each extra page shows a different URL] → accepted; approving any one connects all via redial. + +## Migration Plan + +Browser protocol 8 → 9 only: the hub serves the page, so an old page reloads once per the existing stale-bundle rule. No daemon restart. diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md new file mode 100644 index 0000000..c296dc9 --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md @@ -0,0 +1,30 @@ +## Why + +A remote host reached over Tailscale SSH whose tailnet rule uses `"action": "check"` blocks on an expired check: sshd prints a `https://login.tailscale.com/…` link on standard error and holds the connection until it is approved in a browser. The hub reads a remote link's standard error only after SSH ends, so the host sits in `connecting` forever with no reason shown and no way to find the link from the page (DEV-2106). + +## What Changes + +- A remote link reads SSH's standard error live while it waits for the daemon's first frame, and reports a Tailscale check login URL (`https://login.tailscale.com/` only) the moment SSH prints it. +- New host status `needs-login`, carrying that URL in a new host-entry field `login`; approving the check lets the waiting SSH proceed, so the host connects with no retry or user action in worktree-term. +- When one session's link to a host greets, every other session's link to that host that is `needs-login` is redialed at once, so one approval connects every open page. +- The page shows a `needs-login` host with a banner and an "Open login link" button opening the URL in a new tab; its repo tabs show `needs login`. +- **BREAKING** (browser link only): `BROWSER_PROTOCOL_VERSION` 8 → 9 for the new status and field; the daemon link is unchanged. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `protocol`: host entry gains status `needs-login` and the `login` field with its URL invariant; browser protocol version 9. +- `hub`: remote links report the Tailscale check URL live and the host becomes `needs-login`; sibling sessions redial when one link greets. +- `web`: banner and repo-tab status for a `needs-login` host, with the login link. + +## Impact + +- Code: `src/platform/dialer` (login-line parser and line scanner), `src/hub/links` (live stderr scan, `login` link event), `src/hub/router` (host state, session redial across sessions), `src/protocol` (host entry schema, version, goldens), `src/web/ui` (banner, tab status). +- Tests: unit (parser, scanner, host transitions, remote link, protocol, web hosts), process (`test/process/hub-router.test.ts` with a fake `WTD_SSH` gated per invocation), e2e (banner and link). +- Docs: README remote-host paragraph. +- Unchanged: the install/reinstall SSH path; no handshake timeout. diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md new file mode 100644 index 0000000..cab6b28 --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Tailscale SSH check login +While a remote link has received no frame from its daemon, the hub SHALL read its SSH process's standard error as it is written, line by line. A line containing `To authenticate, visit:` followed by a URL that, with control characters removed, starts with `https://login.tailscale.com/` and has at most 1024 characters SHALL make the host `needs-login` with that URL as `login`, within 1 s of being written, with `reason`, `daemonVersion` and `instance` null, keeping the link open and counting no failure. A line naming any other URL SHALL change nothing, as SHALL a repeat of the URL the host already shows. Standard error read after the link's first daemon frame, after the link was closed or ended, or after the link was replaced SHALL change no host. A `needs-login` host SHALL become `connected` or `outdated` when its link exchanges `hello`, and `reconnecting` or `down` when its link is lost, as for any other link, counting the consecutive failures from before it was `needs-login`; `login` SHALL be null in every status other than `needs-login`. A request for a `needs-login` host SHALL fail with `host-unavailable`. When a link of one session to a host exchanges `hello`, every other session's link to the same host that is `needs-login` SHALL be closed and at once replaced by a new link, that session's host becoming `connecting` with `login` null; links of other sessions in any other status SHALL be left alone. + +#### Scenario: Login link surfaced +- **WHEN** the SSH process for host `box` writes `# Tailscale SSH requires an additional check.` and `# To authenticate, visit: https://login.tailscale.com/a/abc123` to standard error and then waits +- **THEN** within 1 s the session receives `box` as `needs-login` with `login` `https://login.tailscale.com/a/abc123` and a null `reason`, and no second SSH process is started + +#### Scenario: Approval connects without retry +- **WHEN** a `needs-login` host's waiting SSH process proceeds and the daemon greets +- **THEN** the session receives the host as `connected` with a null `login`, over the same SSH process + +#### Scenario: Other URL ignored +- **WHEN** the SSH process for a host writes `To authenticate, visit: https://login.tailscale.com.evil/a/x` to standard error and waits +- **THEN** the host stays `connecting` + +#### Scenario: Expired check falls back to retrying +- **WHEN** a `needs-login` host's SSH process exits writing `Tailscale SSH check timed out` to standard error, and the next SSH process writes a new login URL +- **THEN** the session receives the host as `reconnecting` with that reason and a null `login`, then as `needs-login` with the new URL + +#### Scenario: Banner from a closed link ignored +- **WHEN** a session's link is closed and its SSH process writes a login banner while it is being terminated +- **THEN** no host entry changes + +#### Scenario: Request to a needs-login host +- **WHEN** a browser sends `host{host: 1, m: watchRepo}` while host 1 is `needs-login` +- **THEN** the session receives an `error` with code `host-unavailable` + +#### Scenario: One approval connects every session +- **WHEN** sessions A and B each hold a `needs-login` link to `box` over their own SSH processes, a session C is `connected` to `box`, and only A's SSH process proceeds and greets +- **THEN** B's original SSH process is terminated, B receives `box` as `connecting` and then `connected` over a new SSH process, and C receives no change to `box` diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md new file mode 100644 index 0000000..b831918 --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md @@ -0,0 +1,77 @@ +## 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 9, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. + +#### 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 + +### Requirement: Browser-link message catalogue +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. +Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, login, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, `needs-login`, and `reason`, `daemonVersion`, `instance` and `login` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. `login` SHALL be a string of at most 1024 characters starting with `https://login.tailscale.com/`, non-null exactly when `status` is `needs-login`; decoding SHALL reject any other `login`. A preset SHALL be `{name, command}` with `command` nullable. + +#### 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 diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md new file mode 100644 index 0000000..5e958b2 --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Host status display +For each host that is `down` or `outdated` the page SHALL show a banner naming the host, its status and its `reason` when present, with the action the outdated host requirement states. For each host that is `needs-login` the page SHALL show a banner naming the host and saying it needs Tailscale login, with an "Open login link" button that opens the host's `login` URL in a new tab without giving that tab access to the page, and no other action; its repo tabs SHALL show the status `needs login`. A `connecting` or `reconnecting` host SHALL show its status only on its repo tabs. + +#### Scenario: Down host banner +- **WHEN** a remote host `box` is `down` with reason `ssh: Could not resolve hostname box` +- **THEN** the page shows a banner naming `box`, `down` and that reason, with an "Install" action + +#### Scenario: Recovered host clears the banner +- **WHEN** a `down` host becomes `connected` +- **THEN** its banner disappears + +#### Scenario: Needs-login banner with link +- **WHEN** a remote host `box` with repo `/srv/app` is `needs-login` with `login` `https://login.tailscale.com/a/abc123` +- **THEN** the page shows a banner naming `box` with an "Open login link" button linking to that URL in a new tab with no opener, and the tab labelled `box:app` shows `needs login` + +#### Scenario: Approved login clears the banner +- **WHEN** a `needs-login` host becomes `connected` +- **THEN** its banner disappears and its repo tabs show no status diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md new file mode 100644 index 0000000..6760928 --- /dev/null +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md @@ -0,0 +1,32 @@ +## 1. Tests — unit + +- [ ] 1.1 `src/platform/dialer`: `tailscaleLoginUrl(line)` accepts the real banner line (with and without `# ` prefix, with CRLF and control characters) and rejects a non-Tailscale host, `http:`, `https://login.tailscale.com.evil/…`, `javascript:`, a URL over 1024 chars, and a line with no URL; fails before the parser exists +- [ ] 1.2 `src/platform/dialer`: the line scanner yields complete lines across chunk boundaries and caps a newline-free partial line at 4 KiB +- [ ] 1.3 `src/hub/router/hosts.test.ts`: `hostTransition` covers connecting→needs-login→connected; reconnecting/down→needs-login keeping `failures`; needs-login→failed continuing the count (reaching `down` at 3); `redial`→connecting with `failures` kept; `login` null after `hello`, `failed` and `redial`; `reason`/`daemonVersion`/`instance` null in needs-login +- [ ] 1.4 `src/hub/links/remote.test.ts` (fake SSH that prints the two-line banner then waits): `login` fires within 1 s and `ended` does not; a repeated URL is reported once; a banner after the first frame, after `close()`, or after the link finished produces no `login` call +- [ ] 1.5 `src/hub/router` (session/HostLink level): a `login` callback from a link the `HostLink` no longer holds (replaced by a redial or retry) changes no host state +- [ ] 1.6 `src/protocol/messages.test.ts`: needs-login entry with a valid `login` decodes; bad prefix, lookalike host, `javascript:`, 1025 chars, null `login` with `needs-login`, non-null `login` with any other status all reject; `src/protocol/wire.golden.*` re-blessed for `BROWSER_PROTOCOL_VERSION` 9 with `DAEMON_PROTOCOL_VERSION` unchanged; `test-samples.ts` gains a needs-login entry +- [ ] 1.7 `src/web/ui/hosts.test.ts`: `hasBanner` true for `needs-login`; `hostAction` null for it + +## 2. Tests — process and e2e + +- [ ] 2.1 `test/support/fake-ssh.ts`: add a per-invocation gate — a fake remote can print the Tailscale banner and block each SSH invocation until that specific invocation is released, while later invocations can be configured to proceed; existing per-alias behaviour unchanged for current tests +- [ ] 2.2 `test/process/hub-router.test.ts`: banner then release → the session receives `connecting`, `needs-login` with the URL (within 1 s of the banner), then `connected` with no second SSH invocation and no browser action +- [ ] 2.3 `test/process/hub-router.test.ts`: needs-login SSH exits with a reason → `reconnecting` with that reason and null `login`, then `needs-login` with the next invocation's new URL +- [ ] 2.4 `test/process/hub-router.test.ts`: three sessions — A and B `needs-login` on their own invocations, C already `connected`; release only A's original invocation (B's original stays blocked, later dials proceed) → B receives `connecting` then `connected` over a new invocation, B's original process is terminated, C receives no change to that host +- [ ] 2.5 `test/process/hub-router.test.ts`: `host{watchRepo}` to a `needs-login` host → `error` `host-unavailable` +- [ ] 2.6 `test/e2e`: a fake remote blocked on the banner shows `host-banner` naming the host with `host-login` whose `href` is the URL, `target=_blank`, `rel` containing `noopener` and `noreferrer`; its repo tab shows `needs login`; after release the banner disappears and the tab shows no status + +## 3. Implementation + +- [ ] 3.1 `src/protocol`: `needs-login` status, `login` field with its prefix/length refine and the iff-`needs-login` check on the host entry; `BROWSER_PROTOCOL_VERSION` 9; tests 1.6 pass +- [ ] 3.2 `src/platform/dialer`: `tailscaleLoginUrl` and the line scanner, exported from `index.ts`; tests 1.1–1.2 pass +- [ ] 3.3 `src/hub/links`: optional `LinkEvents.login`; `ProcessLink` scans stderr live before the first frame, dedups the URL, and delivers only while neither `closed` nor `finished`; test 1.4 passes +- [ ] 3.4 `src/hub/router/hosts.ts`: `HostState.login`, `login` and `redial` events; `session.ts` forwards `login` only from the link it holds, sends `login` in `hosts` entries, and rejects requests to `needs-login` with `host-unavailable`; tests 1.3, 1.5, 2.2, 2.3, 2.5 pass +- [ ] 3.5 `src/hub/router/index.ts` + `session.ts`: per-host-key greet notifier beside the coordinators; a sibling `HostLink` in `needs-login` closes its link, cancels its retry timer, emits `redial` and connects at once; test 2.4 passes +- [ ] 3.6 `src/web/ui`: `hasBanner` includes `needs-login`; `HostBanner` renders ": needs Tailscale login" with an "Open login link" anchor (`data-testid="host-login"`, `target="_blank"`, `rel="noopener noreferrer"`) and no action; repo tabs show `needs login`; tests 1.7 and 2.6 pass +- [ ] 3.7 README remote-host paragraph: one sentence on the "needs Tailscale login" banner and link + +## 4. Final gates + +- [ ] 4.1 `pnpm test` green (incl. Playwright); `pnpm lint` exits 0; `tsc -b` clean; `la-arch-check` passes with no new arrows; `openspec validate dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on --strict` passes From f3f0045b1af06b008a8353da4633affdd7bfa25a Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 17 Oct 2026 18:52:10 +0200 Subject: [PATCH 2/7] Add failing tests for DEV-2106: surface the Tailscale SSH check login link Unit, process and e2e tests for the needs-login host status: the login line parser and line scanner, live stderr scanning on a remote link, host transitions, stale login callbacks, the login field's wire rules (browser protocol 9), the web banner and tab status, and sibling-session redial. The fake SSH gains a per-invocation Tailscale login gate. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/hub/links/remote.test.ts | 87 ++++++++++++++++++- src/hub/router/hosts.test.ts | 92 +++++++++++++++++++- src/hub/router/session.test.ts | 134 ++++++++++++++++++++++++++++++ src/platform/dialer/login.test.ts | 78 +++++++++++++++++ src/protocol/messages.test.ts | 40 +++++++++ src/protocol/test-samples.ts | 39 ++++++++- src/protocol/wire.golden.test.ts | 36 +++++++- src/web/ui/hosts.test.ts | 2 + test/e2e/contract.ts | 4 +- test/e2e/hosts.spec.ts | 28 +++++++ test/process/hub-router.test.ts | 102 ++++++++++++++++++++++- test/support/fake-ssh.ts | 46 ++++++++++ 12 files changed, 677 insertions(+), 11 deletions(-) create mode 100644 src/hub/router/session.test.ts create mode 100644 src/platform/dialer/login.test.ts diff --git a/src/hub/links/remote.test.ts b/src/hub/links/remote.test.ts index efa0184..167a3cc 100644 --- a/src/hub/links/remote.test.ts +++ b/src/hub/links/remote.test.ts @@ -47,12 +47,16 @@ const fileText = (name: string): string => (existsSync(join(dir, name)) ? readFi interface Opened { link: Link; frames: Frame[]; + logins: string[]; + loginAt: () => number | null; ended: Promise; endedAt: () => number | null; } const open = (alias = 'box'): Opened => { const frames: Frame[] = []; + const logins: string[] = []; + let loginAt: number | null = null; let at: number | null = null; let resolveEnded: (error: Error | null) => void = () => undefined; const ended = new Promise((resolve) => { @@ -60,12 +64,16 @@ const open = (alias = 'box'): Opened => { }); const link = new RemoteDaemon(paths, alias).open({ frame: (frame) => frames.push(frame), + login: (url) => { + loginAt ??= Date.now(); + logins.push(url); + }, ended: (error) => { at = Date.now(); resolveEnded(error); }, }); - return { link, frames, ended, endedAt: () => at }; + return { link, frames, logins, loginAt: () => loginAt, ended, endedAt: () => at }; }; const controlFrame = (text: string): Uint8Array => encodeFrame({ kind: FrameKind.control, payload: new TextEncoder().encode(text) }); @@ -208,3 +216,80 @@ describe('RemoteDaemon', () => { expect(endedAt()).toBeNull(); }); }); + +const LOGIN_URL = 'https://login.tailscale.com/a/abc123'; +/** Shell lines writing Tailscale's check banner for `url` to standard error. */ +const banner = (url = LOGIN_URL): string => + `printf '%s\\n' '# Tailscale SSH requires an additional check.' '# To authenticate, visit: ${url}' >&2`; + +describe('RemoteDaemon Tailscale login', () => { + it('reports the login URL within 1 s of SSH writing the banner, keeping the link open', async () => { + fakeSsh(`${banner()}\ndate +%s%3N >> "$D/banner"\nexec sleep 30`); + const { link, logins, loginAt, endedAt } = open(); + await waitFor(() => logins.length > 0, 'the login URL', 3000); + expect(logins).toEqual([LOGIN_URL]); + await waitFor(() => fileText('banner') !== '', 'the banner time'); + expect((loginAt() ?? Infinity) - Number(fileText('banner').trim())).toBeLessThanOrEqual(1000); + await sleep(300); + expect(endedAt()).toBeNull(); + link.close(); + }); + + it('reports a banner whose line arrives in several writes', async () => { + fakeSsh(`printf '# To authenticate, vi' >&2; sleep 0.1; printf 'sit: ${LOGIN_URL}\\n' >&2\nexec sleep 30`); + const { link, logins } = open(); + await waitFor(() => logins.length > 0, 'the login URL', 2000); + expect(logins).toEqual([LOGIN_URL]); + link.close(); + }); + + it('reports a repeated URL once', async () => { + fakeSsh(`${banner()}; sleep 0.1; ${banner()}; echo written >> "$D/banner"\nexec sleep 30`); + const { link, logins } = open(); + await waitFor(() => fileText('banner') !== '', 'both banners to be written'); + await sleep(300); + expect(logins).toEqual([LOGIN_URL]); + link.close(); + }); + + it('reports no URL outside login.tailscale.com', async () => { + fakeSsh(`${banner('https://login.tailscale.com.evil/a/x')}; echo written >> "$D/banner"\nexec sleep 30`); + const { link, logins } = open(); + await waitFor(() => fileText('banner') !== '', 'the banner to be written'); + await sleep(300); + expect(logins).toEqual([]); + link.close(); + }); + + it('reports no banner written after the first frame', async () => { + writeFileSync(join(dir, 'frame'), controlFrame('{"t":"shutdown"}')); + fakeSsh(`cat "$D/frame"; while [ ! -e "$D/go" ]; do sleep 0.02; done; ${banner()}; echo written >> "$D/banner"\nexec sleep 30`); + const { link, frames, logins } = open(); + await waitFor(() => frames.length === 1, 'the first frame'); + writeFileSync(join(dir, 'go'), ''); + await waitFor(() => fileText('banner') !== '', 'the banner to be written'); + await sleep(300); + expect(frames).toHaveLength(1); + expect(logins).toEqual([]); + link.close(); + }); + + it('reports no banner written after close()', async () => { + fakeSsh(`cat >/dev/null; ${banner()}; echo written >> "$D/banner"\nexec sleep 30`); + const { link, logins } = open(); + await waitFor(() => fileText('pids') !== '', 'the SSH program to run'); + link.close(); + await waitFor(() => fileText('banner') !== '', 'the banner to be written'); + await sleep(300); + expect(logins).toEqual([]); + }); + + it('reports no banner written after the link ended', async () => { + fakeSsh(`exec >&-; sleep 0.6; ${banner()}; echo written >> "$D/banner"\nexec sleep 30`); + const { logins, ended } = open(); + await ended; + await waitFor(() => fileText('banner') !== '', 'the banner to be written'); + await sleep(300); + expect(logins).toEqual([]); + }); +}); diff --git a/src/hub/router/hosts.test.ts b/src/hub/router/hosts.test.ts index 10587d0..6c20d72 100644 --- a/src/hub/router/hosts.test.ts +++ b/src/hub/router/hosts.test.ts @@ -5,12 +5,15 @@ import { hostTransition, initialHostState, localHostName, retryDelay, type HostE const hello = (protocol: number, instance = 'd_1'): HostEvent => ({ kind: 'hello', protocol, version: '9.9.9', instance }); const failed: HostEvent = { kind: 'failed', reason: 'ssh: connect to host box port 22: Connection refused' }; const failedWith = (reason: string): HostEvent => ({ kind: 'failed', reason }); +const LOGIN_URL = 'https://login.tailscale.com/a/abc123'; +const login = (url = LOGIN_URL): HostEvent => ({ kind: 'login', url }); +const redial: HostEvent = { kind: 'redial' }; const run = (...events: HostEvent[]): HostState => events.reduce(hostTransition, initialHostState); describe('host status', () => { it('starts connecting with no daemon', () => { - expect(initialHostState).toEqual({ status: 'connecting', reason: null, daemonVersion: null, instance: null, failures: 0 }); + expect(initialHostState).toEqual({ status: 'connecting', reason: null, daemonVersion: null, instance: null, login: null, failures: 0 }); }); it('is connected to a daemon speaking our protocol', () => { @@ -19,6 +22,7 @@ describe('host status', () => { reason: null, daemonVersion: '9.9.9', instance: 'd_1', + login: null, failures: 0, }); }); @@ -29,6 +33,7 @@ describe('host status', () => { reason: null, daemonVersion: '9.9.9', instance: 'd_1', + login: null, failures: 0, }); expect(run(hello(1, 'old_7'))).toMatchObject({ status: 'outdated', instance: 'old_7' }); @@ -74,7 +79,7 @@ describe('host status', () => { it('resets the failure count on a hello', () => { const recovered = run(failed, failed, failed, hello(DAEMON_PROTOCOL_VERSION, 'd_2')); - expect(recovered).toEqual({ status: 'connected', reason: null, daemonVersion: '9.9.9', instance: 'd_2', failures: 0 }); + expect(recovered).toEqual({ status: 'connected', reason: null, daemonVersion: '9.9.9', instance: 'd_2', login: null, failures: 0 }); expect(hostTransition(recovered, failed)).toMatchObject({ status: 'reconnecting', failures: 1 }); }); @@ -85,6 +90,89 @@ describe('host status', () => { }); }); +describe('Tailscale login', () => { + it('needs login while connecting, with the URL and no reason, daemon or instance', () => { + expect(run(login())).toEqual({ + status: 'needs-login', + reason: null, + daemonVersion: null, + instance: null, + login: LOGIN_URL, + failures: 0, + }); + }); + + it('goes from connecting through needs-login to connected', () => { + expect(run(login(), hello(DAEMON_PROTOCOL_VERSION))).toEqual({ + status: 'connected', + reason: null, + daemonVersion: '9.9.9', + instance: 'd_1', + login: null, + failures: 0, + }); + }); + + it('becomes outdated with a null login when the daemon speaks another protocol', () => { + expect(run(login(), hello(DAEMON_PROTOCOL_VERSION + 1))).toMatchObject({ status: 'outdated', login: null }); + }); + + it.each([ + ['reconnecting', [failed], 1], + ['down', [failed, failed, failed], 3], + ] as const)('needs login from %s, keeping the failure count', (_name, before, failures) => { + expect(run(...before, login())).toEqual({ + status: 'needs-login', + reason: null, + daemonVersion: null, + instance: null, + login: LOGIN_URL, + failures, + }); + }); + + it('continues the failure count when a needs-login link is lost', () => { + expect(run(login(), failedWith('Tailscale SSH check timed out'))).toEqual({ + status: 'reconnecting', + reason: 'Tailscale SSH check timed out', + daemonVersion: null, + instance: null, + login: null, + failures: 1, + }); + expect(run(failed, failed, login(), failed)).toMatchObject({ status: 'down', failures: 3, login: null }); + }); + + it('takes a new URL while needing login', () => { + expect(run(login(), login('https://login.tailscale.com/a/next')).login).toBe('https://login.tailscale.com/a/next'); + }); + + it('is connecting with a null login after a redial, keeping the failure count', () => { + expect(run(failed, login(), redial)).toEqual({ + status: 'connecting', + reason: null, + daemonVersion: null, + instance: null, + login: null, + failures: 1, + }); + }); + + it('has a null login in every status but needs-login', () => { + const states = [ + run(login(), hello(DAEMON_PROTOCOL_VERSION)), + run(login(), hello(1)), + run(login(), failed), + run(failed, failed, login(), failed), + run(login(), redial), + ]; + for (const state of states) { + expect(state.status).not.toBe('needs-login'); + expect(state.login).toBeNull(); + } + }); +}); + describe('retry delay', () => { it.each([ [1, 250], diff --git a/src/hub/router/session.test.ts b/src/hub/router/session.test.ts new file mode 100644 index 0000000..82a2433 --- /dev/null +++ b/src/hub/router/session.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { decodeMessage, DAEMON_PROTOCOL_VERSION, type HostEntry, type MessageOf } from '../../protocol/index.js'; +import { parseConfig } from '../config/index.js'; +import type { DaemonEndpoint, Link, LinkEvents } from '../links/index.js'; +import { HostCoordinator } from './coordinator.js'; +import { Session, type BrowserChannel, type SessionContext, type SessionHost } from './session.js'; + +const LOGIN_URL = 'https://login.tailscale.com/a/abc123'; + +/** A link the test drives through the events it was opened with. */ +class FakeLink implements Link { + closed = false; + + constructor(readonly events: LinkEvents) {} + + send(): void { + // Nothing reads what the session sends. + } + + pause(): void { + // Nothing to read. + } + + resume(): void { + // Nothing to read. + } + + close(): void { + this.closed = true; + } +} + +class FakeEndpoint implements DaemonEndpoint { + readonly links: FakeLink[] = []; + + open(events: LinkEvents): Link { + const link = new FakeLink(events); + this.links.push(link); + return link; + } +} + +/** The `login` callback the session gave `link`; fails the test when there is none. */ +const loginOf = (link: FakeLink | undefined): ((url: string) => void) => { + const login = link?.events.login; + if (login === undefined) throw new Error('the session gave its link no login callback'); + return login; +}; + +const unused = (): never => { + throw new Error('not used by this test'); +}; + +const box: SessionHost = { idx: 0, name: 'box', ssh: 'box', repos: [], roots: [] }; + +interface Started { + session: Session; + endpoint: FakeEndpoint; + sent: MessageOf<'hubToBrowser'>[]; + host: () => HostEntry | undefined; +} + +const start = (): Started => { + const sent: MessageOf<'hubToBrowser'>[] = []; + const channel: BrowserChannel = { + send: (data) => { + if (typeof data === 'string') sent.push(decodeMessage('hubToBrowser', data)); + }, + unsent: () => 0, + }; + const endpoint = new FakeEndpoint(); + const context: SessionContext = { + hello: { t: 'hello', protocol: DAEMON_PROTOCOL_VERSION, version: '0.1.0', instance: 'hub_1' }, + endpoint: () => endpoint, + coordinator: () => new HostCoordinator({ restart: unused, reinstall: unused }), + listsRepo: unused, + editRepos: unused, + reposEdited: unused, + editPresets: unused, + presetsEdited: unused, + }; + const session = new Session(channel, context, { config: parseConfig(null, '/home/u'), problem: null }, [{ ...box }]); + session.start(); + const host = (): HostEntry | undefined => sent.findLast((m) => m.t === 'hosts')?.hosts[0]; + return { session, endpoint, sent, host }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Session Tailscale login', () => { + it('shows the host as needs-login with the URL its link reports', () => { + const { session, endpoint, host } = start(); + loginOf(endpoint.links[0])(LOGIN_URL); + expect(host()).toMatchObject({ status: 'needs-login', login: LOGIN_URL, reason: null, instance: null }); + session.close(); + }); + + it('ignores a login reported by a link it replaced after a retry', () => { + vi.useFakeTimers(); + const { session, endpoint, sent, host } = start(); + const first = endpoint.links[0]; + loginOf(first)(LOGIN_URL); + first?.events.ended(new Error('Tailscale SSH check timed out')); + expect(host()).toMatchObject({ status: 'reconnecting', login: null }); + vi.advanceTimersByTime(1000); + expect(endpoint.links).toHaveLength(2); + const before = sent.length; + loginOf(first)('https://login.tailscale.com/a/stale'); + expect(sent.slice(before).filter((m) => m.t === 'hosts')).toEqual([]); + expect(host()).toMatchObject({ status: 'reconnecting', login: null }); + session.close(); + }); + + it('ignores a login reported after the session closed', () => { + const { session, endpoint, sent } = start(); + const login = loginOf(endpoint.links[0]); + session.close(); + const before = sent.length; + login(LOGIN_URL); + expect(sent.slice(before)).toEqual([]); + expect(endpoint.links).toHaveLength(1); + }); + + it('answers a request to a needs-login host with host-unavailable', () => { + const { session, endpoint, sent, host } = start(); + loginOf(endpoint.links[0])(LOGIN_URL); + expect(host()?.status).toBe('needs-login'); + session.receive({ t: 'host', host: 0, m: { t: 'watchRepo', req: 7, repo: '/srv/app' } }); + expect(sent.at(-1)).toMatchObject({ t: 'error', req: 7, host: 0, code: 'host-unavailable' }); + session.close(); + }); +}); diff --git a/src/platform/dialer/login.test.ts b/src/platform/dialer/login.test.ts new file mode 100644 index 0000000..4a289c6 --- /dev/null +++ b/src/platform/dialer/login.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { LineScanner, tailscaleLoginUrl } from './index.js'; + +const URL = 'https://login.tailscale.com/a/abc123'; +const encoder = new TextEncoder(); +const bytes = (text: string): Uint8Array => encoder.encode(text); + +describe('tailscaleLoginUrl', () => { + it.each([ + ['the banner line', `# To authenticate, visit: ${URL}`], + ['the line without its # prefix', `To authenticate, visit: ${URL}`], + ['a CRLF-terminated line', `# To authenticate, visit: ${URL}\r`], + ['a line with surrounding blanks', ` # To authenticate, visit: ${URL} `], + ['a line with control characters', `\u0007# To authenticate, visit: https://login.tailscale.com/a/abc\u001b123\r`], + ])('gives the URL of %s', (_name, line) => { + expect(tailscaleLoginUrl(line)).toBe(URL); + }); + + it('accepts a URL of exactly 1024 characters', () => { + const long = 'https://login.tailscale.com/a/' + 'x'.repeat(1024 - 'https://login.tailscale.com/a/'.length); + expect(tailscaleLoginUrl(`# To authenticate, visit: ${long}`)).toBe(long); + }); + + it.each([ + ['another host', '# To authenticate, visit: https://example.com/a/abc123'], + ['plain http', '# To authenticate, visit: http://login.tailscale.com/a/abc123'], + ['a lookalike host', '# To authenticate, visit: https://login.tailscale.com.evil/a/x'], + ['a host without the trailing slash', '# To authenticate, visit: https://login.tailscale.com'], + ['a javascript: URL', '# To authenticate, visit: javascript:alert(1)'], + ['an upper-case scheme', '# To authenticate, visit: HTTPS://login.tailscale.com/a/abc123'], + ['no URL', '# To authenticate, visit:'], + ['the first banner line', '# Tailscale SSH requires an additional check.'], + ['a Tailscale URL without the phrase', `# See ${URL}`], + ['an empty line', ''], + ])('gives null for %s', (_name, line) => { + expect(tailscaleLoginUrl(line)).toBeNull(); + }); + + it('gives null for a URL over 1024 characters', () => { + const long = 'https://login.tailscale.com/a/' + 'x'.repeat(1025 - 'https://login.tailscale.com/a/'.length); + expect(tailscaleLoginUrl(`# To authenticate, visit: ${long}`)).toBeNull(); + }); +}); + +describe('LineScanner', () => { + it('yields each complete line without its newline', () => { + expect(new LineScanner().push(bytes('one\ntwo\n'))).toEqual(['one', 'two']); + }); + + it('holds a partial line until its newline arrives', () => { + const scanner = new LineScanner(); + expect(scanner.push(bytes('# To authenticate, vi'))).toEqual([]); + expect(scanner.push(bytes(`sit: ${URL}\nnext`))).toEqual([`# To authenticate, visit: ${URL}`]); + expect(scanner.push(bytes('\n'))).toEqual(['next']); + }); + + it('joins a line split inside a multi-byte character', () => { + const line = bytes('résumé\n'); + const split = line.indexOf(0xc3) + 1; + const scanner = new LineScanner(); + expect(scanner.push(line.subarray(0, split))).toEqual([]); + expect(scanner.push(line.subarray(split))).toEqual(['résumé']); + }); + + it('holds at most 4 KiB of a line without a newline', () => { + const scanner = new LineScanner(); + for (let i = 0; i < 64; i++) expect(scanner.push(bytes('x'.repeat(1024)))).toEqual([]); + expect(scanner.size).toBeLessThanOrEqual(4096); + const [line] = scanner.push(bytes('\n')); + expect(line?.length ?? 0).toBeLessThanOrEqual(4096); + }); + + it('yields a whole line again after a capped one', () => { + const scanner = new LineScanner(); + scanner.push(bytes('y'.repeat(10_000))); + expect(scanner.push(bytes(`\n# To authenticate, visit: ${URL}\n`)).at(-1)).toBe(`# To authenticate, visit: ${URL}`); + }); +}); diff --git a/src/protocol/messages.test.ts b/src/protocol/messages.test.ts index 0167066..b7c3fb7 100644 --- a/src/protocol/messages.test.ts +++ b/src/protocol/messages.test.ts @@ -630,6 +630,46 @@ describe('host entries and tokens', () => { expect(decodes('hubToBrowser', withEntry({ instance }))).toBe(ok); }); + describe('login', () => { + const LOGIN = 'https://login.tailscale.com/a/abc123'; + const needsLogin = (login: unknown): unknown => withEntry({ status: 'needs-login', login }); + + it('decodes a needs-login host entry carrying its login URL', () => { + const entry = { ...hosts[0], status: 'needs-login', daemonVersion: null, instance: null, login: LOGIN }; + expect(decodeMessage('hubToBrowser', raw({ t: 'hosts', hosts: [entry] }))).toEqual({ t: 'hosts', hosts: [entry] }); + }); + + it('accepts a login URL of 1024 characters', () => { + expect(decodes('hubToBrowser', needsLogin(LOGIN + 'x'.repeat(1024 - LOGIN.length)))).toBe(true); + }); + + it.each([ + ['plain http', 'http://login.tailscale.com/a/x'], + ['a lookalike host', 'https://login.tailscale.com.evil/a/x'], + ['another host', 'https://example.com/a/x'], + ['a javascript: URL', 'javascript:alert(1)'], + ['1025 characters', LOGIN + 'x'.repeat(1025 - LOGIN.length)], + ['no path', 'https://login.tailscale.com'], + ['a number', 1], + ])('rejects a login URL that is %s', (_name, login) => { + expect(decodes('hubToBrowser', needsLogin(login))).toBe(false); + }); + + it('rejects a needs-login host entry with a null login', () => { + expect(decodes('hubToBrowser', needsLogin(null))).toBe(false); + }); + + it.each(['connecting', 'connected', 'reconnecting', 'down', 'outdated'])('rejects a %s host entry with a login', (status) => { + expect(decodes('hubToBrowser', withEntry({ status, login: LOGIN }))).toBe(false); + expect(decodes('hubToBrowser', withEntry({ status, login: null }))).toBe(true); + }); + + it('rejects a host entry without login', () => { + const withoutLogin = Object.fromEntries(Object.entries(entry ?? {}).filter(([key]) => key !== 'login')); + expectRejected('hubToBrowser', raw({ t: 'hosts', hosts: [withoutLogin] })); + }); + }); + it('decodes a token message from the hub', () => { expect(decodeMessage('hubToBrowser', raw({ t: 'token', token: TOKEN }))).toEqual({ t: 'token', token: TOKEN }); }); diff --git a/src/protocol/test-samples.ts b/src/protocol/test-samples.ts index 9ff7048..19463d1 100644 --- a/src/protocol/test-samples.ts +++ b/src/protocol/test-samples.ts @@ -90,9 +90,29 @@ export const fullTabLayout: Layout = { }; export const hosts: HostEntry[] = [ - { idx: 0, name: 'local', remote: false, status: 'connected', reason: null, daemonVersion: '0.1.0', instance: 'd_1-A', repos: [REPO] }, - { idx: 1, name: 'devbox', remote: true, status: 'outdated', reason: null, daemonVersion: '0.0.9', instance: 'old_D', repos: [] }, - { idx: 2, name: 'gpu', remote: true, status: 'connecting', reason: null, daemonVersion: null, instance: null, repos: [WT] }, + { + idx: 0, + name: 'local', + remote: false, + status: 'connected', + reason: null, + daemonVersion: '0.1.0', + instance: 'd_1-A', + login: null, + repos: [REPO], + }, + { + idx: 1, + name: 'devbox', + remote: true, + status: 'outdated', + reason: null, + daemonVersion: '0.0.9', + instance: 'old_D', + login: null, + repos: [], + }, + { idx: 2, name: 'gpu', remote: true, status: 'connecting', reason: null, daemonVersion: null, instance: null, login: null, repos: [WT] }, { idx: 3, name: 'old', @@ -101,6 +121,7 @@ export const hosts: HostEntry[] = [ reason: 'ssh: connect to host old port 22: Connection refused', daemonVersion: null, instance: null, + login: null, repos: [], }, { @@ -111,6 +132,18 @@ export const hosts: HostEntry[] = [ reason: 'r'.repeat(1024), daemonVersion: null, instance: null, + login: null, + repos: [], + }, + { + idx: 4, + name: 'tail', + remote: true, + status: 'needs-login', + reason: null, + daemonVersion: null, + instance: null, + login: 'https://login.tailscale.com/a/abc123', repos: [], }, ]; diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index 371de67..74aeb6f 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -164,6 +164,38 @@ const EDGE_CASES: readonly unknown[] = [ { idx: 1, name: 'box', remote: true, status: 'down', reason: 'r'.repeat(1025), daemonVersion: null, instance: null, repos: [] }, ], }, + { + t: 'hosts', + hosts: [ + { + idx: 1, + name: 'box', + remote: true, + status: 'needs-login', + reason: null, + daemonVersion: null, + instance: null, + login: 'https://login.tailscale.com.evil/a/x', + repos: [], + }, + ], + }, + { + t: 'hosts', + hosts: [ + { + idx: 1, + name: 'box', + remote: true, + status: 'connecting', + reason: null, + daemonVersion: null, + instance: null, + login: 'https://login.tailscale.com/a/x', + repos: [], + }, + ], + }, ]; const allJson = [...new Set([...DIRECTIONS.flatMap((dir) => samples[dir].map((m) => raw(m))), ...EDGE_CASES.map((m) => raw(m))])]; @@ -271,8 +303,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 8', () => { - expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 8 }); + it('is blessed for daemon protocol 6 and browser protocol 9', () => { + expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 9 }); }); it('records every sample as valid in its own direction', () => { diff --git a/src/web/ui/hosts.test.ts b/src/web/ui/hosts.test.ts index 2610809..601d5fa 100644 --- a/src/web/ui/hosts.test.ts +++ b/src/web/ui/hosts.test.ts @@ -10,6 +10,7 @@ describe('hostAction', () => { [true, 'connected', null], [true, 'reconnecting', null], [false, 'connecting', null], + [true, 'needs-login', null], ] as const)('gives a %s host that is %s the action %j', (remote, status, action) => { expect(hostAction({ remote, status })).toEqual(action); }); @@ -22,6 +23,7 @@ describe('hasBanner', () => { ['connected', false], ['connecting', false], ['reconnecting', false], + ['needs-login', true], ] as const)('is %s for a %s host', (status, banner) => { expect(hasBanner({ status })).toBe(banner); }); diff --git a/test/e2e/contract.ts b/test/e2e/contract.ts index 3e24510..830ca1b 100644 --- a/test/e2e/contract.ts +++ b/test/e2e/contract.ts @@ -28,8 +28,10 @@ export const TID = { removeRepo: 'remove-repo', /** In the selected repo's area when its watch failed; text holds the error code and the path. */ repoError: 'repo-error', - /** One per `down` or `outdated` host, none for other statuses; `data-host` = host index; text names the host, its status and its reason when present. */ + /** One per `down`, `outdated` or `needs-login` host, none for other statuses; `data-host` = host index; text names the host, its status and its reason when present. */ hostBanner: 'host-banner', + /** "Open login link" inside a `needs-login` host's banner: `href` = the host's login URL, `target="_blank"`, `rel` with `noopener` and `noreferrer`. */ + hostLogin: 'host-login', /** Button inside a host banner: "Restart daemon" (outdated local), "Reinstall & restart" (outdated remote) or "Install" (down remote); opens the confirmation dialog, then sends `restartDaemon` or `reinstallDaemon`. */ hostAction: 'host-action', /** In-page confirmation of a host action or a repo removal; nothing is sent before it is confirmed; text says every running terminal on the host will be killed when it lists none. */ diff --git a/test/e2e/hosts.spec.ts b/test/e2e/hosts.spec.ts index d67a53b..1ba81c9 100644 --- a/test/e2e/hosts.spec.ts +++ b/test/e2e/hosts.spec.ts @@ -91,6 +91,34 @@ test.describe('host banners', () => { }); }); +test.describe('Tailscale login', () => { + test('a host blocked on a Tailscale check shows a banner with its login link, cleared once approved', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + nodeRemote(ssh, 'box'); + await installRemote(hub, ssh, 'box'); + ssh.loginHost('box'); + writeHubConfig(hub, { hosts: [{ name: 'box', ssh: 'box', repos: ['/srv/app'] }] }); + await openUi(hub, page); + const banner = page.locator(hostBanner(REMOTE)); + await expect(banner).toContainText('needs Tailscale login', { timeout: 15_000 }); + await expect(banner).toContainText('box'); + await expect(banner.locator(byTestId(TID.hostAction))).toHaveCount(0); + const link = banner.locator(byTestId(TID.hostLogin)); + 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 tab = page.locator(repoTab('/srv/app')); + await expect(tab).toContainText('box:app'); + await expect(tab.locator(byTestId(TID.hostStatus))).toHaveText('needs login'); + + ssh.approveLogin((await link.getAttribute('href')) ?? ''); + await expect(page.locator(hostBanner(REMOTE))).toHaveCount(0, { timeout: 30_000 }); + await expect(tab.locator(byTestId(TID.hostStatus))).toHaveCount(0); + }); +}); + test.describe('host actions', () => { test('cancelling the confirmation sends nothing', async ({ hub, page, wire }) => { const { repo } = makeRepoWith(hub, 'app', []); diff --git a/test/process/hub-router.test.ts b/test/process/hub-router.test.ts index a60cc94..a89bb93 100644 --- a/test/process/hub-router.test.ts +++ b/test/process/hub-router.test.ts @@ -5,8 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FLOW_HIGH, FrameKind, DAEMON_PROTOCOL_VERSION, type Terminal } from '../../src/protocol/index.js'; import { addWorktree, alive, DaemonHost, makeRepo, residentBytes, sleep, waitUntil, type WtdProcess } from '../support/daemon-host.js'; import { FakeDaemon, SocketProxy } from '../support/fake-daemon.js'; -import { currentNodeDir, FakeSsh, installRemote, type FakeRemote } from '../support/fake-ssh.js'; -import type { HubClient, HubMessageOf } from '../support/hub-client.js'; +import { currentNodeDir, FakeSsh, installRemote, loginRun, type FakeRemote } from '../support/fake-ssh.js'; +import type { HostEntry, HubClient, HubMessageOf } from '../support/hub-client.js'; import { HubHost } from '../support/hub-host.js'; import { REPO_ROOT } from '../support/exec.js'; @@ -165,6 +165,7 @@ describe('daemon links and host status', () => { reason: null, daemonVersion: packageVersion(), instance: ANY_INSTANCE, + login: null, repos: [repo], }); expect(host.daemonPids()).toHaveLength(1); @@ -566,6 +567,7 @@ describe('remote hosts', () => { reason: null, daemonVersion: packageVersion(), instance: ANY_INSTANCE, + login: null, repos: [remoteRepo], }); expect(remote.daemonPids()).toHaveLength(1); @@ -692,6 +694,102 @@ describe('remote hosts', () => { }); }); +const LOGIN_URL: unknown = expect.stringMatching(/^https:\/\/login\.tailscale\.com\/a\/wtd\d+$/); + +/** Host `idx`'s entries in every `hosts` message from `from` on. */ +const entriesOf = (client: HubClient, idx: number, from = 0): HostEntry[] => + client.messages.slice(from).flatMap((m) => (m.t === 'hosts' ? m.hosts.filter((h) => h.idx === idx) : [])); + +/** Host `idx`'s statuses from `from` on, without consecutive repeats. */ +const statusesOf = (client: HubClient, idx: number, from = 0): string[] => + entriesOf(client, idx, from) + .map((h) => h.status) + .filter((status, i, all) => i === 0 || all[i - 1] !== status); + +/** The SSH runs of `box` that ran the connect command. */ +const connectRuns = (): number[] => + ssh + .calls() + .filter((call) => call.alias === 'box' && call.command.includes('connect')) + .map((call) => call.pid); + +/** Host 1's entry once it needs login, and the login URL. */ +const needsLogin = async (client: HubClient, from = 0): Promise<{ entry: HostEntry; url: string }> => { + const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { from, timeout: REMOTE_TIMEOUT }); + if (entry.login === null) throw new Error('needs-login without a login URL'); + return { entry, url: entry.login }; +}; + +describe('Tailscale SSH check login', () => { + it('reports needs-login with the URL within 1 s of the banner, then connects over the same SSH process once approved', async () => { + await installedBox(); + ssh.loginHost('box'); + const before = connectRuns().length; + const client = await host.session(); + await waitUntil(() => connectRuns().length > before, 'the hub’s SSH process for box'); + const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { timeout: 5000 }); + const receivedAt = Date.now(); + expect(entry).toMatchObject({ status: 'needs-login', login: LOGIN_URL, reason: null, daemonVersion: null, instance: null }); + const url = entry.login ?? ''; + expect(receivedAt - ssh.loginWrittenAt(url)).toBeLessThanOrEqual(1000); + expect(connectRuns()).toHaveLength(before + 1); + ssh.approveLogin(url); + expect(await connectedHost(client, 1)).toMatchObject({ login: null, reason: null }); + expect(connectRuns()).toHaveLength(before + 1); + expect(alive(loginRun(url))).toBe(true); + expect(statusesOf(client, 1)).toEqual(['connecting', 'needs-login', 'connected']); + }); + + it('reports reconnecting with SSH’s reason and a null login when the check expires, then the next run’s URL', async () => { + await installedBox(); + ssh.loginHost('box'); + const client = await host.session(); + const { url: first } = await needsLogin(client); + const from = client.mark(); + ssh.expireLogin(first, 'Tailscale SSH check timed out'); + expect(await client.waitHost(1, (h) => h.status === 'reconnecting', { from })).toMatchObject({ + reason: 'Tailscale SSH check timed out', + login: null, + }); + const { url: second } = await needsLogin(client, from); + expect(second).not.toBe(first); + }); + + it('answers a request to a needs-login host with host-unavailable', async () => { + await installedBox(); + ssh.loginHost('box'); + const client = await host.session(); + await needsLogin(client); + const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); + }); + + it('redials every other needs-login session once one session’s link greets, leaving connected sessions alone', async () => { + await installedBox(); + const c = await host.session(); + const cEntry = await connectedHost(c, 1); + ssh.loginHost('box'); + const a = await host.session(); + const { url: aUrl } = await needsLogin(a); + const b = await host.session(); + const { url: bUrl } = await needsLogin(b); + const bRun = loginRun(bUrl); + expect(bRun).not.toBe(loginRun(aUrl)); + const runsBefore = connectRuns().length; + const fromB = b.mark(); + const fromC = c.mark(); + ssh.skipLogin('box'); + ssh.approveLogin(aUrl); + await connectedHost(a, 1); + expect(await connectedHost(b, 1, fromB)).toMatchObject({ login: null }); + expect(statusesOf(b, 1, fromB)).toEqual(['connecting', 'connected']); + expect(entriesOf(b, 1, fromB)[0]).toMatchObject({ status: 'connecting', login: null }); + await waitUntil(() => !alive(bRun), 'B’s original SSH process to end', 5000); + expect(connectRuns().length).toBeGreaterThan(runsBefore); + for (const entry of entriesOf(c, 1, fromC)) expect(entry).toEqual(cEntry); + }); +}); + describe('remote daemon restart and reinstall', () => { it('restarts a connected remote daemon into a new instance', async () => { const { remote } = await installedBox(); diff --git a/test/support/fake-ssh.ts b/test/support/fake-ssh.ts index 437c56c..d87a890 100644 --- a/test/support/fake-ssh.ts +++ b/test/support/fake-ssh.ts @@ -85,6 +85,14 @@ if [ ! -f "$h" ]; then echo "ssh: Could not resolve hostname $alias: Name or ser . "$h" if [ -n "$R_HANG" ]; then trap '' TERM; exec sleep 600; fi if [ -n "$R_GATE" ]; then while [ ! -e "$R_GATE" ]; do sleep 0.05; done; fi +if [ -n "$R_LOGIN" ] && [ ! -e "$d/login/open-$alias" ]; then + printf '%s\n' '# Tailscale SSH requires an additional check.' "# To authenticate, visit: https://login.tailscale.com/a/wtd$$" >&2 + date +%s%3N > "$d/login/$$.at" + while [ ! -e "$d/login/$$.ok" ]; do + if [ -e "$d/login/$$.fail" ]; then cat "$d/login/$$.fail" >&2; exit 255; fi + sleep 0.05 + done +fi if [ -n "$R_FAIL_LINE" ]; then printf '%s\n' "$R_FAIL_LINE" >&2; exit 255; fi if [ -n "$R_FAIL_BYTES" ]; then head -c "$R_FAIL_BYTES" /dev/zero | tr '\000' x >&2; exit 255; fi if [ -n "$R_NOISE_BYTES" ]; then head -c "$R_NOISE_BYTES" /dev/zero | tr '\000' x >&2; fi @@ -109,6 +117,13 @@ export interface RemoteOptions { shell?: string; } +/** The pid of the fake SSH run that wrote the login URL `url`. */ +export const loginRun = (url: string): number => { + const match = /^https:\/\/login\.tailscale\.com\/a\/wtd(\d+)$/.exec(url); + if (match?.[1] === undefined) throw new Error(`not a fake SSH login URL: ${url}`); + return Number(match[1]); +}; + /** A remote host reached through FakeSsh: its own temporary home, state and daemon. */ export class FakeRemote { readonly stateDir: string; @@ -202,6 +217,7 @@ export class FakeSsh { readonly program: string; private readonly hostsDir: string; private readonly callsDir: string; + private readonly loginDir: string; private readonly remotes: FakeRemote[] = []; private noNode: string | null = null; @@ -209,8 +225,10 @@ export class FakeSsh { mkdirSync(dir, { recursive: true }); this.hostsDir = join(dir, 'hosts'); this.callsDir = join(dir, 'calls'); + this.loginDir = join(dir, 'login'); mkdirSync(this.hostsDir, { recursive: true }); mkdirSync(this.callsDir, { recursive: true }); + mkdirSync(this.loginDir, { recursive: true }); this.program = join(dir, 'ssh'); writeFileSync(this.program, SSH_SCRIPT(dir)); chmodSync(this.program, 0o755); @@ -261,6 +279,34 @@ export class FakeSsh { }; } + /** + * Makes every later SSH run for `alias` write Tailscale's check banner, with a login URL naming the run + * (see `loginRun`), and wait until that run is approved or expired, or `skipLogin` was called before it started. + */ + loginHost(alias: string): void { + appendFileSync(join(this.hostsDir, alias), 'R_LOGIN=1\n'); + } + + /** When, in ms since the epoch, the run behind `url` wrote its banner. */ + loginWrittenAt(url: string): number { + return Number(readFileSync(join(this.loginDir, `${String(loginRun(url))}.at`), 'utf8').trim()); + } + + /** Lets the run behind `url` proceed. */ + approveLogin(url: string): void { + writeFileSync(join(this.loginDir, `${String(loginRun(url))}.ok`), ''); + } + + /** Makes the run behind `url` write `line` to standard error and exit 255. */ + expireLogin(url: string, line: string): void { + writeFileSync(join(this.loginDir, `${String(loginRun(url))}.fail`), `${line}\n`); + } + + /** Lets SSH runs for `alias` that start from now on proceed without a banner. */ + skipLogin(alias: string): void { + writeFileSync(join(this.loginDir, `open-${alias}`), ''); + } + /** Every run so far, in no particular order. */ calls(): SshCall[] { return readdirSync(this.callsDir) From ea5d28a7855a40f5bbd7edefae6834bd980a0522 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 17 Oct 2026 19:12:42 +0200 Subject: [PATCH 3/7] Implement DEV-2106: surface the Tailscale SSH check login link A remote link scans SSH's standard error live until the daemon's first frame and reports a login.tailscale.com check URL; the host becomes needs-login with the URL in the new host-entry field `login` (browser protocol 9). When one session's link to a host greets, other sessions' needs-login links to it redial at once. The page shows a banner with an "Open login link" button and "needs login" on the host's repo tabs. Also keeps the protocol 8 hosts sample in the wire corpus, updates the version strings tests expect, and makes the WebGL LRU property test's per-step check one assertion so it no longer times out under load. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- .../tasks.md | 42 ++++----- src/cli/run.test.ts | 2 +- src/hub/links/index.ts | 22 ++++- src/hub/router/hosts.ts | 47 ++++++++-- src/hub/router/index.ts | 7 +- src/hub/router/session.ts | 31 ++++++- src/platform/dialer/index.ts | 1 + src/platform/dialer/login.ts | 52 +++++++++++ src/platform/dialer/tail.ts | 2 +- src/protocol/constants.test.ts | 2 +- src/protocol/constants.ts | 2 +- src/protocol/messages.ts | 27 +++--- src/protocol/values.ts | 2 + src/protocol/wire.golden.json | 87 +++++++++++++++++-- src/protocol/wire.golden.test.ts | 8 +- src/web/main/styles.css | 16 +++- src/web/terminals/webgl-lru.test.ts | 2 +- src/web/ui/app.tsx | 17 +++- src/web/ui/hosts.test.ts | 13 ++- src/web/ui/hosts.ts | 6 +- test/process/cli.test.ts | 2 +- test/process/install-local.test.ts | 8 +- 23 files changed, 333 insertions(+), 67 deletions(-) create mode 100644 src/platform/dialer/login.ts diff --git a/README.md b/README.md index 55d6445..9156939 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ wtd install-remote devbox --node /opt/node22/bin/node or with "Install" on the page while the host is down. The remote gets the same layout as `wtd install-local`, without launcher or unit. Its shim runs the Node found at install time, so `"$HOME/.local/bin/wtd" connect` works over plain `ssh` with no dotfile changes. -Each browser session holds one `ssh "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's running daemon speaks an older daemon protocol than its installed `wtd` (whose protocol `wtd --version` prints; it does not ask the running daemon), the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program. +Each browser session holds one `ssh "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. A host reached over Tailscale SSH whose check has expired shows "needs Tailscale login" with an "Open login link" button; approving the check there connects every open page. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's running daemon speaks an older daemon protocol than its installed `wtd` (whose protocol `wtd --version` prints; it does not ask the running daemon), the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program. ## Keyboard diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md index 6760928..3fd6edb 100644 --- a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md +++ b/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md @@ -1,32 +1,32 @@ ## 1. Tests — unit -- [ ] 1.1 `src/platform/dialer`: `tailscaleLoginUrl(line)` accepts the real banner line (with and without `# ` prefix, with CRLF and control characters) and rejects a non-Tailscale host, `http:`, `https://login.tailscale.com.evil/…`, `javascript:`, a URL over 1024 chars, and a line with no URL; fails before the parser exists -- [ ] 1.2 `src/platform/dialer`: the line scanner yields complete lines across chunk boundaries and caps a newline-free partial line at 4 KiB -- [ ] 1.3 `src/hub/router/hosts.test.ts`: `hostTransition` covers connecting→needs-login→connected; reconnecting/down→needs-login keeping `failures`; needs-login→failed continuing the count (reaching `down` at 3); `redial`→connecting with `failures` kept; `login` null after `hello`, `failed` and `redial`; `reason`/`daemonVersion`/`instance` null in needs-login -- [ ] 1.4 `src/hub/links/remote.test.ts` (fake SSH that prints the two-line banner then waits): `login` fires within 1 s and `ended` does not; a repeated URL is reported once; a banner after the first frame, after `close()`, or after the link finished produces no `login` call -- [ ] 1.5 `src/hub/router` (session/HostLink level): a `login` callback from a link the `HostLink` no longer holds (replaced by a redial or retry) changes no host state -- [ ] 1.6 `src/protocol/messages.test.ts`: needs-login entry with a valid `login` decodes; bad prefix, lookalike host, `javascript:`, 1025 chars, null `login` with `needs-login`, non-null `login` with any other status all reject; `src/protocol/wire.golden.*` re-blessed for `BROWSER_PROTOCOL_VERSION` 9 with `DAEMON_PROTOCOL_VERSION` unchanged; `test-samples.ts` gains a needs-login entry -- [ ] 1.7 `src/web/ui/hosts.test.ts`: `hasBanner` true for `needs-login`; `hostAction` null for it +- [x] 1.1 `src/platform/dialer`: `tailscaleLoginUrl(line)` accepts the real banner line (with and without `# ` prefix, with CRLF and control characters) and rejects a non-Tailscale host, `http:`, `https://login.tailscale.com.evil/…`, `javascript:`, a URL over 1024 chars, and a line with no URL; fails before the parser exists +- [x] 1.2 `src/platform/dialer`: the line scanner yields complete lines across chunk boundaries and caps a newline-free partial line at 4 KiB +- [x] 1.3 `src/hub/router/hosts.test.ts`: `hostTransition` covers connecting→needs-login→connected; reconnecting/down→needs-login keeping `failures`; needs-login→failed continuing the count (reaching `down` at 3); `redial`→connecting with `failures` kept; `login` null after `hello`, `failed` and `redial`; `reason`/`daemonVersion`/`instance` null in needs-login +- [x] 1.4 `src/hub/links/remote.test.ts` (fake SSH that prints the two-line banner then waits): `login` fires within 1 s and `ended` does not; a repeated URL is reported once; a banner after the first frame, after `close()`, or after the link finished produces no `login` call +- [x] 1.5 `src/hub/router` (session/HostLink level): a `login` callback from a link the `HostLink` no longer holds (replaced by a redial or retry) changes no host state +- [x] 1.6 `src/protocol/messages.test.ts`: needs-login entry with a valid `login` decodes; bad prefix, lookalike host, `javascript:`, 1025 chars, null `login` with `needs-login`, non-null `login` with any other status all reject; `src/protocol/wire.golden.*` re-blessed for `BROWSER_PROTOCOL_VERSION` 9 with `DAEMON_PROTOCOL_VERSION` unchanged; `test-samples.ts` gains a needs-login entry +- [x] 1.7 `src/web/ui/hosts.test.ts`: `hasBanner` true for `needs-login`; `hostAction` null for it ## 2. Tests — process and e2e -- [ ] 2.1 `test/support/fake-ssh.ts`: add a per-invocation gate — a fake remote can print the Tailscale banner and block each SSH invocation until that specific invocation is released, while later invocations can be configured to proceed; existing per-alias behaviour unchanged for current tests -- [ ] 2.2 `test/process/hub-router.test.ts`: banner then release → the session receives `connecting`, `needs-login` with the URL (within 1 s of the banner), then `connected` with no second SSH invocation and no browser action -- [ ] 2.3 `test/process/hub-router.test.ts`: needs-login SSH exits with a reason → `reconnecting` with that reason and null `login`, then `needs-login` with the next invocation's new URL -- [ ] 2.4 `test/process/hub-router.test.ts`: three sessions — A and B `needs-login` on their own invocations, C already `connected`; release only A's original invocation (B's original stays blocked, later dials proceed) → B receives `connecting` then `connected` over a new invocation, B's original process is terminated, C receives no change to that host -- [ ] 2.5 `test/process/hub-router.test.ts`: `host{watchRepo}` to a `needs-login` host → `error` `host-unavailable` -- [ ] 2.6 `test/e2e`: a fake remote blocked on the banner shows `host-banner` naming the host with `host-login` whose `href` is the URL, `target=_blank`, `rel` containing `noopener` and `noreferrer`; its repo tab shows `needs login`; after release the banner disappears and the tab shows no status +- [x] 2.1 `test/support/fake-ssh.ts`: add a per-invocation gate — a fake remote can print the Tailscale banner and block each SSH invocation until that specific invocation is released, while later invocations can be configured to proceed; existing per-alias behaviour unchanged for current tests +- [x] 2.2 `test/process/hub-router.test.ts`: banner then release → the session receives `connecting`, `needs-login` with the URL (within 1 s of the banner), then `connected` with no second SSH invocation and no browser action +- [x] 2.3 `test/process/hub-router.test.ts`: needs-login SSH exits with a reason → `reconnecting` with that reason and null `login`, then `needs-login` with the next invocation's new URL +- [x] 2.4 `test/process/hub-router.test.ts`: three sessions — A and B `needs-login` on their own invocations, C already `connected`; release only A's original invocation (B's original stays blocked, later dials proceed) → B receives `connecting` then `connected` over a new invocation, B's original process is terminated, C receives no change to that host +- [x] 2.5 `test/process/hub-router.test.ts`: `host{watchRepo}` to a `needs-login` host → `error` `host-unavailable` +- [x] 2.6 `test/e2e`: a fake remote blocked on the banner shows `host-banner` naming the host with `host-login` whose `href` is the URL, `target=_blank`, `rel` containing `noopener` and `noreferrer`; its repo tab shows `needs login`; after release the banner disappears and the tab shows no status ## 3. Implementation -- [ ] 3.1 `src/protocol`: `needs-login` status, `login` field with its prefix/length refine and the iff-`needs-login` check on the host entry; `BROWSER_PROTOCOL_VERSION` 9; tests 1.6 pass -- [ ] 3.2 `src/platform/dialer`: `tailscaleLoginUrl` and the line scanner, exported from `index.ts`; tests 1.1–1.2 pass -- [ ] 3.3 `src/hub/links`: optional `LinkEvents.login`; `ProcessLink` scans stderr live before the first frame, dedups the URL, and delivers only while neither `closed` nor `finished`; test 1.4 passes -- [ ] 3.4 `src/hub/router/hosts.ts`: `HostState.login`, `login` and `redial` events; `session.ts` forwards `login` only from the link it holds, sends `login` in `hosts` entries, and rejects requests to `needs-login` with `host-unavailable`; tests 1.3, 1.5, 2.2, 2.3, 2.5 pass -- [ ] 3.5 `src/hub/router/index.ts` + `session.ts`: per-host-key greet notifier beside the coordinators; a sibling `HostLink` in `needs-login` closes its link, cancels its retry timer, emits `redial` and connects at once; test 2.4 passes -- [ ] 3.6 `src/web/ui`: `hasBanner` includes `needs-login`; `HostBanner` renders ": needs Tailscale login" with an "Open login link" anchor (`data-testid="host-login"`, `target="_blank"`, `rel="noopener noreferrer"`) and no action; repo tabs show `needs login`; tests 1.7 and 2.6 pass -- [ ] 3.7 README remote-host paragraph: one sentence on the "needs Tailscale login" banner and link +- [x] 3.1 `src/protocol`: `needs-login` status, `login` field with its prefix/length refine and the iff-`needs-login` check on the host entry; `BROWSER_PROTOCOL_VERSION` 9; tests 1.6 pass +- [x] 3.2 `src/platform/dialer`: `tailscaleLoginUrl` and the line scanner, exported from `index.ts`; tests 1.1–1.2 pass +- [x] 3.3 `src/hub/links`: optional `LinkEvents.login`; `ProcessLink` scans stderr live before the first frame, dedups the URL, and delivers only while neither `closed` nor `finished`; test 1.4 passes +- [x] 3.4 `src/hub/router/hosts.ts`: `HostState.login`, `login` and `redial` events; `session.ts` forwards `login` only from the link it holds, sends `login` in `hosts` entries, and rejects requests to `needs-login` with `host-unavailable`; tests 1.3, 1.5, 2.2, 2.3, 2.5 pass +- [x] 3.5 `src/hub/router/index.ts` + `session.ts`: per-host-key greet notifier beside the coordinators; a sibling `HostLink` in `needs-login` closes its link, cancels its retry timer, emits `redial` and connects at once; test 2.4 passes +- [x] 3.6 `src/web/ui`: `hasBanner` includes `needs-login`; `HostBanner` renders ": needs Tailscale login" with an "Open login link" anchor (`data-testid="host-login"`, `target="_blank"`, `rel="noopener noreferrer"`) and no action; repo tabs show `needs login`; tests 1.7 and 2.6 pass +- [x] 3.7 README remote-host paragraph: one sentence on the "needs Tailscale login" banner and link ## 4. Final gates -- [ ] 4.1 `pnpm test` green (incl. Playwright); `pnpm lint` exits 0; `tsc -b` clean; `la-arch-check` passes with no new arrows; `openspec validate dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on --strict` passes +- [x] 4.1 `pnpm test` green (incl. Playwright); `pnpm lint` exits 0; `tsc -b` clean; `la-arch-check` passes with no new arrows; `openspec validate dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on --strict` passes diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index b933828..229d70d 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -48,7 +48,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 8)\n`, + stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`, stderr: '', }); }); diff --git a/src/hub/links/index.ts b/src/hub/links/index.ts index 0600e12..5c77e24 100644 --- a/src/hub/links/index.ts +++ b/src/hub/links/index.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcessByStdio } from 'node:child_process'; import { connect, type Socket } from 'node:net'; import type { Readable, Writable } from 'node:stream'; -import { dial, spawnDetached, sshCommand, StderrTail } from '../../platform/dialer/index.js'; +import { dial, LineScanner, spawnDetached, sshCommand, StderrTail, tailscaleLoginUrl } from '../../platform/dialer/index.js'; import { installRemote, type InstalledRemote } from '../../platform/install/index.js'; import { ProtocolError, StreamDecoder, type Frame } from '../../protocol/index.js'; @@ -14,6 +14,8 @@ export const CONNECT_COMMAND = '"$HOME/.local/bin/wtd" connect'; export interface LinkEvents { /** A frame from the daemon. */ frame: (frame: Frame) => void; + /** Before the first frame: SSH waits for its Tailscale check to be approved at `url`. Once per URL. */ + login?: (url: string) => void; /** The link ended: the dial failed, the connection closed, or the daemon sent a malformed stream. Called once. */ ended: (error: Error | null) => void; } @@ -151,6 +153,9 @@ class ProcessLink implements Link { private readonly child: SshProcess; private readonly decoder = new StreamDecoder(); private readonly tail = new StderrTail(); + private readonly lines = new LineScanner(); + private framed = false; + private login: string | null = null; private exit: string | null = null; private stderrDone = false; private grace: ReturnType | null = null; @@ -170,6 +175,9 @@ class ProcessLink implements Link { this.child.stdout.once('end', () => { this.lost(); }); + this.child.stderr.on('data', (chunk: Buffer) => { + this.scan(chunk); + }); this.tail.follow(this.child.stderr, () => { this.child.kill('SIGTERM'); this.signalLater('SIGKILL', CLOSE_GRACE_MS); @@ -234,12 +242,24 @@ class ProcessLink implements Link { this.finish(error); return; } + if (frames.length > 0) this.framed = true; for (const frame of frames) { if (this.closed || this.finished) return; this.events.frame(frame); } } + /** Reports a Tailscale check login URL on standard error while no frame has arrived. */ + private scan(chunk: Uint8Array): void { + if (this.framed || this.closed || this.finished) return; + for (const line of this.lines.push(chunk)) { + const url = tailscaleLoginUrl(line); + if (url === null || url === this.login) continue; + this.login = url; + this.events.login?.(url); + } + } + /** The process exited or its output ended: finish once its standard error is read, or after a grace period. */ private lost(): void { this.grace ??= setTimeout(() => { diff --git a/src/hub/router/hosts.ts b/src/hub/router/hosts.ts index a56b934..f104377 100644 --- a/src/hub/router/hosts.ts +++ b/src/hub/router/hosts.ts @@ -7,11 +7,19 @@ export interface HostState { reason: string | null; daemonVersion: string | null; instance: string | null; + /** The Tailscale check login URL while needs-login. */ + login: string | null; /** Consecutive failed dials or lost links. */ failures: number; } -export type HostEvent = { kind: 'hello'; protocol: number; version: string; instance: string } | { kind: 'failed'; reason: string }; +export type HostEvent = + | { kind: 'hello'; protocol: number; version: string; instance: string } + | { kind: 'failed'; reason: string } + /** The link waits for a Tailscale check to be approved at `url`. */ + | { kind: 'login'; url: string } + /** The link was replaced by a new one without a failure. */ + | { kind: 'redial' }; const DOWN_AFTER = 3; const FIRST_RETRY_MS = 250; @@ -19,23 +27,46 @@ const MAX_RETRY_MS = 5000; const MAX_NAME = 64; const MAX_REASON = 1024; -export const initialHostState: HostState = { status: 'connecting', reason: null, daemonVersion: null, instance: null, failures: 0 }; +export const initialHostState: HostState = { + status: 'connecting', + reason: null, + daemonVersion: null, + instance: null, + login: null, + failures: 0, +}; -export const hostTransition = (state: HostState, event: HostEvent): HostState => { - if (event.kind === 'hello') { - const status = event.protocol === DAEMON_PROTOCOL_VERSION ? 'connected' : 'outdated'; - return { status, reason: null, daemonVersion: event.version, instance: event.instance, failures: 0 }; - } +const failedState = (state: HostState, reason: string): HostState => { const failures = state.failures + 1; return { status: failures >= DOWN_AFTER ? 'down' : 'reconnecting', - reason: event.reason.slice(0, MAX_REASON), + reason: reason.slice(0, MAX_REASON), daemonVersion: null, instance: null, + login: null, failures, }; }; +export const hostTransition = (state: HostState, event: HostEvent): HostState => { + switch (event.kind) { + case 'hello': { + const status = event.protocol === DAEMON_PROTOCOL_VERSION ? 'connected' : 'outdated'; + return { status, reason: null, daemonVersion: event.version, instance: event.instance, login: null, failures: 0 }; + } + case 'login': + return { ...initialHostState, status: 'needs-login', login: event.url, failures: state.failures }; + case 'redial': + return { ...initialHostState, failures: state.failures }; + case 'failed': + return failedState(state, event.reason); + } +}; + +/** The key of a host shared by every session configuring it. */ +export const hostKey = (host: { name: string; ssh: string | null }): string => + host.ssh === null ? 'local' : `remote:${host.name}:${host.ssh}`; + /** Delay before the next dial after `failures` consecutive failures. */ export const retryDelay = (failures: number): number => Math.min(MAX_RETRY_MS, FIRST_RETRY_MS * 2 ** Math.max(0, failures - 1)); diff --git a/src/hub/router/index.ts b/src/hub/router/index.ts index f9a3b89..5707d0d 100644 --- a/src/hub/router/index.ts +++ b/src/hub/router/index.ts @@ -2,7 +2,7 @@ import { DAEMON_PROTOCOL_VERSION, type DataFrame, type MessageOf } from '../../p import { ConfigEditor, ConfigSource, type ConfigSnapshot, type HubConfig } from '../config/index.js'; import { installOnRemote, LocalDaemon, RemoteDaemon, type DaemonEndpoint, type DaemonPaths } from '../links/index.js'; import { HostCoordinator } from './coordinator.js'; -import { localHostName } from './hosts.js'; +import { hostKey, localHostName } from './hosts.js'; import { DaemonRestarter } from './restart.js'; import { Session, type BrowserChannel, type SessionContext, type SessionHost } from './session.js'; @@ -79,6 +79,9 @@ export class Router { presetsEdited: (presets) => { for (const session of this.sessions) session.showPresets(presets); }, + greeted: (host, from) => { + for (const session of this.sessions) if (session !== from) session.redialLogin(host); + }, }; } @@ -102,7 +105,7 @@ export class Router { } private coordinator(host: SessionHost): HostCoordinator { - const key = host.ssh === null ? 'local' : `remote:${host.name}:${host.ssh}`; + const key = hostKey(host); let coordinator = this.coordinators.get(key); if (coordinator === undefined) { const restarter = new DaemonRestarter(this.endpoint(host), this.hello); diff --git a/src/hub/router/session.ts b/src/hub/router/session.ts index 3a27254..589ef0f 100644 --- a/src/hub/router/session.ts +++ b/src/hub/router/session.ts @@ -14,7 +14,7 @@ import { import { ConfigError, type ConfigSnapshot, type PresetEdit, type RepoEdit } from '../config/index.js'; import type { DaemonEndpoint, Link } from '../links/index.js'; import type { HostCoordinator } from './coordinator.js'; -import { hostTransition, initialHostState, retryDelay, type HostEvent, type HostState } from './hosts.js'; +import { hostKey, hostTransition, initialHostState, retryDelay, type HostEvent, type HostState } from './hosts.js'; import { OneShotError, requestOnce } from './oneshot.js'; import { controlFrame } from './restart.js'; @@ -59,6 +59,8 @@ export interface SessionContext { ) => Promise; /** Shows the presets after an edit to every session. */ presetsEdited: (presets: Preset[]) => void; + /** A link of session `from` to `host` exchanged hello: redials the other sessions' links to it that need login. */ + greeted?: (host: SessionHost, from: Session) => void; } /** Depth of hub-level repo discovery. */ @@ -116,6 +118,9 @@ class HostLink { this.greet(frame); } else if (this.state.status === 'connected') this.relay(frame); }, + login: (url) => { + if (this.link === link) this.transition({ kind: 'login', url }); + }, ended: (error) => { if (this.link === link) this.lost(error?.message ?? 'the link to the daemon ended'); }, @@ -145,6 +150,16 @@ class HostLink { this.link = null; } + /** Replaces a link that needs login by a new one at once; links in other statuses are left alone. */ + redial(): void { + if (this.closed || this.state.status !== 'needs-login') return; + if (this.retry !== null) clearTimeout(this.retry); + this.link?.close(); + this.link = null; + this.transition({ kind: 'redial' }); + this.connect(); + } + private greet(frame: Frame): void { const hello = this.decode(frame); if (hello?.t !== 'hello') { @@ -153,6 +168,7 @@ class HostLink { } this.link?.send(controlFrame(this.hello)); this.transition({ kind: 'hello', protocol: hello.protocol, version: hello.version, instance: hello.instance }); + this.session.greeted(this.host); } private relay(frame: Frame): void { @@ -311,6 +327,18 @@ export class Session { for (const host of this.hosts) host.close(); } + /** One of this session's links to `host` exchanged hello. */ + greeted(host: SessionHost): void { + this.context.greeted?.(host, this); + } + + /** Redials this session's link to `host` if it needs login. */ + redialLogin(host: SessionHost): void { + if (this.closed) return; + const key = hostKey(host); + for (const target of this.hosts) if (hostKey(target.host) === key) target.redial(); + } + /** Sends a daemon message, given as its JSON text, inside a `host` envelope. */ forwardText(host: number, text: string): void { this.send(`{"t":"host","host":${String(host)},"m":${text}}`); @@ -330,6 +358,7 @@ export class Session { reason: state.reason, daemonVersion: state.daemonVersion, instance: state.instance, + login: state.login, repos: host.repos, })); this.message({ t: 'hosts', hosts }); diff --git a/src/platform/dialer/index.ts b/src/platform/dialer/index.ts index 7bcd13e..fc09f11 100644 --- a/src/platform/dialer/index.ts +++ b/src/platform/dialer/index.ts @@ -137,4 +137,5 @@ export const stopProcess = (child: ChildProcess, graceMs: number): void => { }, graceMs).unref(); }; +export { LineScanner, tailscaleLoginUrl } from './login.js'; export { StderrTail } from './tail.js'; diff --git a/src/platform/dialer/login.ts b/src/platform/dialer/login.ts new file mode 100644 index 0000000..a262dc4 --- /dev/null +++ b/src/platform/dialer/login.ts @@ -0,0 +1,52 @@ +import { CONTROL } from './tail.js'; + +const PHRASE = 'To authenticate, visit:'; +const LOGIN_PREFIX = 'https://login.tailscale.com/'; +const MAX_URL = 1024; +const MAX_LINE = 4096; +const NEWLINE = 0x0a; + +/** The Tailscale SSH check login URL a standard error line gives, or null when it gives none. */ +export const tailscaleLoginUrl = (line: string): string | null => { + const text = line.replace(CONTROL, ''); + const at = text.indexOf(PHRASE); + if (at < 0) return null; + const [url = ''] = text + .slice(at + PHRASE.length) + .trim() + .split(/\s/, 1); + return url.startsWith(LOGIN_PREFIX) && url.length <= MAX_URL ? url : null; +}; + +/** Splits a byte stream into lines, holding at most the first 4 KiB of a line until its newline arrives. */ +export class LineScanner { + private held = new Uint8Array(0); + private readonly decoder = new TextDecoder(); + + get size(): number { + return this.held.length; + } + + /** The lines `chunk` completes, without their newlines. */ + push(chunk: Uint8Array): string[] { + const lines: string[] = []; + let start = 0; + for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) { + this.hold(chunk.subarray(start, end)); + lines.push(this.decoder.decode(this.held)); + this.held = new Uint8Array(0); + start = end + 1; + } + this.hold(chunk.subarray(start)); + return lines; + } + + private hold(bytes: Uint8Array): void { + const take = Math.min(bytes.length, MAX_LINE - this.held.length); + if (take <= 0) return; + const next = new Uint8Array(this.held.length + take); + next.set(this.held, 0); + next.set(bytes.subarray(0, take), this.held.length); + this.held = next; + } +} diff --git a/src/platform/dialer/tail.ts b/src/platform/dialer/tail.ts index 216a9d9..2979ca0 100644 --- a/src/platform/dialer/tail.ts +++ b/src/platform/dialer/tail.ts @@ -6,7 +6,7 @@ const MAX_REASON = 1024; const MAX_READ = 1024 * 1024; // eslint-disable-next-line no-control-regex -- control characters are what a reason must not contain -const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f]/g; +export const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f]/g; /** The last 4 KiB a process wrote to standard error, giving its last non-empty line. */ export class StderrTail { diff --git a/src/protocol/constants.test.ts b/src/protocol/constants.test.ts index 6500b32..8afc33b 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: 8, + BROWSER_PROTOCOL_VERSION: 9, MAX_FRAME: 16 * 1024 * 1024, MAX_INPUT: 64 * 1024, FLOW_HIGH: 512 * 1024, diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts index bbd327f..c3804a9 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -4,7 +4,7 @@ const MiB = 1024 * KiB; /** Version of the daemon link (client ↔ daemon), whose two ends can run different releases. */ export const DAEMON_PROTOCOL_VERSION = 6; /** Version of the browser link (browser ↔ hub); the hub serves the page, so a bump needs no daemon restart. */ -export const BROWSER_PROTOCOL_VERSION = 8; +export const BROWSER_PROTOCOL_VERSION = 9; /** Largest stream frame payload. */ export const MAX_FRAME = 16 * MiB; diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index 5210f07..56eb380 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -9,6 +9,7 @@ import { head, hostIdx, instance, + loginUrl, longText, MAX_DISCOVERED, MAX_ENTRIES, @@ -111,16 +112,22 @@ const daemonEvents = [ }), ] as const; -const hostEntry = z.strictObject({ - idx: hostIdx, - name, - remote: z.boolean(), - status: z.enum(['connecting', 'connected', 'reconnecting', 'down', 'outdated']), - reason, - daemonVersion: version.nullable(), - instance: instance.nullable(), - repos: z.array(path).max(MAX_HOST_REPOS), -}); +const hostEntry = z + .strictObject({ + idx: hostIdx, + name, + remote: z.boolean(), + status: z.enum(['connecting', 'connected', 'reconnecting', 'down', 'outdated', 'needs-login']), + reason, + daemonVersion: version.nullable(), + instance: instance.nullable(), + login: loginUrl.nullable(), + repos: z.array(path).max(MAX_HOST_REPOS), + }) + .refine((entry) => (entry.login !== null) === (entry.status === 'needs-login'), { + message: 'login is set exactly while needs-login', + path: ['login'], + }); export const messageSchemas = { clientToDaemon: z.discriminatedUnion('t', [hello, shutdown, ...daemonRequests]), diff --git a/src/protocol/values.ts b/src/protocol/values.ts index 018a0eb..835b0e8 100644 --- a/src/protocol/values.ts +++ b/src/protocol/values.ts @@ -33,6 +33,8 @@ export const instance = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); export const secret = z.string().regex(/^[0-9a-f]{64}$/); export const errorMessage = z.string().max(1024); export const reason = errorMessage.nullable(); +/** A Tailscale SSH check login URL. */ +export const loginUrl = z.string().max(1024).startsWith('https://login.tailscale.com/'); export const errorCode = z.enum([ 'bad-message', 'unknown-host', diff --git a/src/protocol/wire.golden.json b/src/protocol/wire.golden.json index c96c870..829415f 100644 --- a/src/protocol/wire.golden.json +++ b/src/protocol/wire.golden.json @@ -1,7 +1,7 @@ { "protocolVersions": { "daemon": 6, - "browser": 8 + "browser": 9 }, "constants": { "MAX_FRAME": 16777216, @@ -3061,7 +3061,8 @@ "connected", "reconnecting", "down", - "outdated" + "outdated", + "needs-login" ] }, "reason": { @@ -3098,6 +3099,19 @@ } ] }, + "login": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024, + "format": "starts_with", + "pattern": "^https:\\/\\/login\\.tailscale\\.com\\/.*" + }, + { + "type": "null" + } + ] + }, "repos": { "maxItems": 256, "type": "array", @@ -3116,6 +3130,7 @@ "reason", "daemonVersion", "instance", + "login", "repos" ], "additionalProperties": false @@ -3785,7 +3800,7 @@ }, { "dir": "clientToDaemon", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"login\":null,\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"login\":null,\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":4,\"name\":\"tail\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/abc123\",\"repos\":[]}]}", "valid": false }, { @@ -3818,6 +3833,11 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"attach\",\"req\":1,\"termId\":1,\"x\":1}", @@ -4093,6 +4113,16 @@ "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com.evil/a/x\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -4535,7 +4565,7 @@ }, { "dir": "daemonToClient", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"login\":null,\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"login\":null,\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":4,\"name\":\"tail\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/abc123\",\"repos\":[]}]}", "valid": false }, { @@ -4568,6 +4598,11 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"attach\",\"req\":1,\"termId\":1,\"x\":1}", @@ -4843,6 +4878,16 @@ "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com.evil/a/x\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -5285,7 +5330,7 @@ }, { "dir": "browserToHub", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"login\":null,\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"login\":null,\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":4,\"name\":\"tail\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/abc123\",\"repos\":[]}]}", "valid": false }, { @@ -5318,6 +5363,11 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"attach\",\"req\":1,\"termId\":1,\"x\":1}", @@ -5593,6 +5643,16 @@ "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com.evil/a/x\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -6035,7 +6095,7 @@ }, { "dir": "hubToBrowser", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"login\":null,\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"login\":null,\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"login\":null,\"repos\":[]},{\"idx\":4,\"name\":\"tail\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/abc123\",\"repos\":[]}]}", "valid": true }, { @@ -6068,6 +6128,11 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":3,\"host\":0,\"repos\":[\"/home/u/repo\"]}", "valid": true }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"attach\",\"req\":1,\"termId\":1,\"x\":1}", @@ -6342,6 +6407,16 @@ "dir": "hubToBrowser", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"needs-login\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com.evil/a/x\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"login\":\"https://login.tailscale.com/a/x\",\"repos\":[]}]}", + "valid": false } ] } diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index 74aeb6f..3eb4e05 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -19,7 +19,7 @@ import { type Direction, type Frame, } from './index.js'; -import { DIRECTIONS, featWorktree, hello, layout, liveTerminal, raw, REPO, samples, WT } from './test-samples.js'; +import { DIRECTIONS, featWorktree, hello, hosts, layout, liveTerminal, raw, REPO, samples, WT } from './test-samples.js'; const directionSchema = z.enum(['clientToDaemon', 'daemonToClient', 'browserToHub', 'hubToBrowser']); @@ -82,6 +82,11 @@ const decodes = (dir: Direction, json: string): boolean => { } }; +/** The `hosts` sample of browser protocol 8, before host entries carried `login`. */ +const hostsWithoutLogin = hosts + .filter((h) => h.status !== 'needs-login') + .map((h) => Object.fromEntries(Object.entries(h).filter(([field]) => field !== 'login'))); + const termWithBell = { ...Object.fromEntries(Object.entries(liveTerminal).filter(([field]) => field !== 'state')), bell: false }; /** A chain of 9 terminal panes in one tab. */ @@ -91,6 +96,7 @@ const ninePanes = Array.from({ length: 8 }, (_, i) => i + 2).reduce( ); const EDGE_CASES: readonly unknown[] = [ + { t: 'hosts', hosts: hostsWithoutLogin }, { t: 'attach', req: 1, termId: 1, x: 1 }, { t: 'attach', req: 1 }, { t: 'resize', termId: 1, cols: '80', rows: 24 }, diff --git a/src/web/main/styles.css b/src/web/main/styles.css index 39883e3..f9e0b50 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -103,11 +103,25 @@ input:focus-visible, border-bottom: 1px solid var(--border); } -.banner button { +.banner button, +.banner a.button { border-color: var(--text); margin-left: 6px; } +.banner a.button { + display: inline-block; + color: inherit; + text-decoration: none; + border: 1px solid var(--text); + padding: 5px 9px; + border-radius: 5px; +} + +.banner a.button:hover { + background: var(--hover); +} + .workspace { flex: 1; display: flex; diff --git a/src/web/terminals/webgl-lru.test.ts b/src/web/terminals/webgl-lru.test.ts index a649f17..2674828 100644 --- a/src/web/terminals/webgl-lru.test.ts +++ b/src/web/terminals/webgl-lru.test.ts @@ -28,7 +28,7 @@ const expectConsistent = (lru: WebglLru, created: readonly Fa const live = created.filter((a) => a.disposed === 0); expect(lru.webglKeys().length).toBeLessThanOrEqual(WEBGL_LIMIT); expect(live.map((a) => a.key).sort((a, b) => a - b)).toEqual([...lru.webglKeys()].sort((a, b) => a - b)); - for (const addon of created) expect(addon.disposed).toBeLessThanOrEqual(1); + expect(created.filter((a) => a.disposed > 1)).toEqual([]); }; describe('WebglLru', () => { diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index d201494..660ccd2 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -4,7 +4,7 @@ import type { HostEntry, Preset, Terminal } from '../../protocol/index.js'; import { repoKey, type HubClient } from '../client/index.js'; import { termsOf, type Divider } from '../layout/index.js'; import { followArea, type TerminalManager } from '../terminals/index.js'; -import { hasBanner, hostAction } from './hosts.js'; +import { hasBanner, hostAction, statusText } from './hosts.js'; import { aggregate, markTitle, worktreeTitle, type Aggregate } from './marks.js'; import { dropPosition, pickerKey, presetFromForm } from './picker.js'; import { SIDEBAR_WIDTH, type SidebarEntry } from './sidebar.js'; @@ -47,8 +47,19 @@ const OutdatedUi = () => ( ); -/** The banner of a `down` or `outdated` host, with its action. */ +/** The banner of a `needs-login` host, with its login link. */ +const LoginBanner = ({ host, login }: { host: HostEntry; login: string }) => ( + +); + +/** The banner of a `down`, `outdated` or `needs-login` host, with its action or login link. */ const HostBanner = ({ host, view }: { host: HostEntry; view: View }) => { + if (host.status === 'needs-login' && host.login !== null) return ; const action = hostAction(host); const what = host.status === 'outdated' ? `outdated daemon ${host.daemonVersion ?? '(unknown version)'}` : 'down'; return ( @@ -279,7 +290,7 @@ const RepoTabButton = ({ client, view, tab, selected }: { client: HubClient; vie {tab.label} {status !== null && status !== 'connected' && ( - {status} + {statusText(status)} )} diff --git a/src/web/ui/hosts.test.ts b/src/web/ui/hosts.test.ts index 601d5fa..567992c 100644 --- a/src/web/ui/hosts.test.ts +++ b/src/web/ui/hosts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { hasBanner, hostAction, Latest, offeredRepos } from './hosts.js'; +import { hasBanner, hostAction, Latest, offeredRepos, statusText } from './hosts.js'; describe('hostAction', () => { it.each([ @@ -16,6 +16,17 @@ describe('hostAction', () => { }); }); +describe('statusText', () => { + it.each([ + ['needs-login', 'needs login'], + ['reconnecting', 'reconnecting'], + ['connecting', 'connecting'], + ['down', 'down'], + ] as const)('shows %s as %j', (status, text) => { + expect(statusText(status)).toBe(text); + }); +}); + describe('hasBanner', () => { it.each([ ['down', true], diff --git a/src/web/ui/hosts.ts b/src/web/ui/hosts.ts index 9349809..d9e6cca 100644 --- a/src/web/ui/hosts.ts +++ b/src/web/ui/hosts.ts @@ -16,7 +16,11 @@ export const hostAction = (host: Pick): HostActi }; /** Whether `host` gets a banner. */ -export const hasBanner = (host: Pick): boolean => host.status === 'down' || host.status === 'outdated'; +export const hasBanner = (host: Pick): boolean => + host.status === 'down' || host.status === 'outdated' || host.status === 'needs-login'; + +/** A host status as its repo tabs show it. */ +export const statusText = (status: HostEntry['status']): string => (status === 'needs-login' ? 'needs login' : status); /** Discovered repos the host does not list yet, narrowed to those whose path contains `filter`. */ export const offeredRepos = (discovered: readonly string[], listed: readonly string[], filter: string): string[] => diff --git a/test/process/cli.test.ts b/test/process/cli.test.ts index 6396b08..8dee168 100644 --- a/test/process/cli.test.ts +++ b/test/process/cli.test.ts @@ -38,7 +38,7 @@ describe('dist/wtd.mjs', () => { it('prints its version', () => { expect(wtd('--version')).toEqual({ status: 0, - stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`, + stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`, stderr: '', }); }); diff --git a/test/process/install-local.test.ts b/test/process/install-local.test.ts index 9180ded..84be34d 100644 --- a/test/process/install-local.test.ts +++ b/test/process/install-local.test.ts @@ -103,7 +103,7 @@ describe('wtd install-local', () => { expect(readdirSync(join(dir, PTY_PACKAGE, 'prebuilds')).length).toBeGreaterThan(0); expect(shim(host.home, ['--version'])).toEqual({ status: 0, - stdout: `wtd ${version} (daemon protocol 6, browser protocol 8)\n`, + stdout: `wtd ${version} (daemon protocol 6, browser protocol 9)\n`, stderr: '', }); }); @@ -150,7 +150,7 @@ describe('wtd install-local', () => { await shimDaemon(host.home); const pid = await waitUntil(() => daemonPidsOf(host.stateHome)[0], 'the shim daemon'); expect(readlinkSync(`/proc/${String(pid)}/exe`)).toBe(node); - expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`); + expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`); }); }); @@ -179,7 +179,7 @@ describe('launcher', () => { expectInstalled(await installLocal([], { HOME: home })); const [program, ...args] = desktopExec(iniGroup(readFileSync(installPaths(home).launcher, 'utf8'), 'Desktop Entry').get('Exec') ?? ''); const result = spawnSync(program ?? '', [...args.slice(0, -1), '--version'], { env: host.env({ HOME: home }), encoding: 'utf8' }); - expect(result.stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`); + expect(result.stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`); }); it('writes no unit file without --systemd', async () => { @@ -314,7 +314,7 @@ describe('reinstalling', () => { const term = await after.create(repo, { command: 'echo after-reinstall; exec sleep 60' }); await after.attach(term.termId); await after.waitOutput(term.termId, 'after-reinstall'); - expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`); + expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`); }); it('runs the newest release through the shim after a reinstall', async () => { From 7c238dab9df570de24a17fcfe4c4e32621c9ace7 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 11:30:30 +0200 Subject: [PATCH 4/7] Add architecture section with single- and two-machine diagrams to README Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index 9156939..d76cd35 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,62 @@ A fast, worktree-centric terminal manager for coding agents. One tab per repo (l The CLI is `wtd`: a daemon per host owns the PTYs and git watches, and a local hub serves the browser UI and reaches every daemon over one protocol. See `architecture/` for the structure and its principles. +## Architecture + +Three running components, all started from the one `wtd` binary: + +| Component | Runs | Owns | +| --- | --- | --- | +| Daemon (`wtd daemon`) | One per host, independent of any page or hub | The PTYs and their screen mirrors, git worktree watches, and `~/.local/state/worktree-term/state.json`: each worktree's terminal tabs, splits, active tab and checkbox | +| Hub (`wtd hub`, started by `wtd ui`) | One on the machine you browse from | `~/.config/worktree-term/config.json` (repos, roots, presets, hosts); nothing else | +| Page | The browser | Rendering (xterm.js) and view preferences in `localStorage`: selected repo, selected worktree per repo, "checked only" filter, name search, sidebar width | + +- **The browser talks only to the hub**: the hub serves the page and one WebSocket per page, on `127.0.0.1` only. +- **The daemon manages the terminals.** A terminal lives until it is closed or its worktree removed; closing the page or restarting the hub does not end it. +- **Tabs and splits are persisted by the daemon** of the host the worktree is on, so every page sees the same layout. View preferences stay in each browser. +- For each page, the hub holds one link per host: the daemon's owner-only unix socket locally, `ssh "$HOME/.local/bin/wtd" connect` remotely. `wtd connect` only bridges SSH's stdio to the remote daemon's socket, starting the daemon if needed. + +### One machine + +```mermaid +flowchart LR + subgraph machine["Your machine"] + page["Page (browser)
xterm.js
localStorage: selection, filters, sidebar width"] + hub["Hub
config.json: repos, roots, presets, hosts"] + daemon["Daemon
PTYs + screen mirrors, git watches
state.json: tabs, splits, checkboxes"] + ptys["Shells and agents in PTYs"] + repos[("Repos and worktrees")] + page -- "WebSocket, 127.0.0.1:7417" --> hub + hub -- "unix socket run/HOST.sock" --> daemon + daemon --> ptys + daemon -. "watches" .-> repos + end +``` + +### Two machines over SSH + +The browser and hub run on `calamity`; `confustication` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`, so a hub on `confustication` plays no part. + +```mermaid +flowchart LR + subgraph calamity["calamity (where you browse)"] + page["Page (browser)
localStorage: selection, filters, sidebar width"] + hub["Hub
config.json: hosts: confustication + its repos"] + localDaemon["Daemon
calamity's terminals
state.json"] + page -- "WebSocket, 127.0.0.1:7417" --> hub + hub -- "unix socket" --> localDaemon + end + subgraph confustication["confustication"] + connect["wtd connect
stdio bridge"] + remoteDaemon["Daemon
confustication's terminals, git watches
state.json: tabs, splits, checkboxes"] + otherHub["Its own hub and config.json
(not used by calamity)"] + connect -- "unix socket" --> remoteDaemon + end + hub -- "ssh confustication wtd connect
one control master per host;
a Tailscale check's login link is shown on the page" --> connect + classDef unused stroke-dasharray: 4 4,color:#888; + class otherHub unused; +``` + ## Installation Requires Linux and Node 20 or later. From a checkout, `pnpm build`, then: From 6553c7b7efcc4173fca61733c74b6f2266d424be Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 11:52:45 +0200 Subject: [PATCH 5/7] Use generic host names in the README architecture diagrams Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d76cd35..d818a51 100644 --- a/README.md +++ b/README.md @@ -38,24 +38,24 @@ flowchart LR ### Two machines over SSH -The browser and hub run on `calamity`; `confustication` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`, so a hub on `confustication` plays no part. +The browser and hub run on your laptop; the remote host `devbox` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`, so a hub on `devbox` plays no part. ```mermaid flowchart LR - subgraph calamity["calamity (where you browse)"] + subgraph laptop["laptop (where you browse)"] page["Page (browser)
localStorage: selection, filters, sidebar width"] - hub["Hub
config.json: hosts: confustication + its repos"] - localDaemon["Daemon
calamity's terminals
state.json"] + hub["Hub
config.json: hosts: devbox + its repos"] + localDaemon["Daemon
the laptop's terminals
state.json"] page -- "WebSocket, 127.0.0.1:7417" --> hub hub -- "unix socket" --> localDaemon end - subgraph confustication["confustication"] + subgraph devbox["devbox"] connect["wtd connect
stdio bridge"] - remoteDaemon["Daemon
confustication's terminals, git watches
state.json: tabs, splits, checkboxes"] - otherHub["Its own hub and config.json
(not used by calamity)"] + remoteDaemon["Daemon
devbox's terminals, git watches
state.json: tabs, splits, checkboxes"] + otherHub["Its own hub and config.json
(not used by the laptop)"] connect -- "unix socket" --> remoteDaemon end - hub -- "ssh confustication wtd connect
one control master per host;
a Tailscale check's login link is shown on the page" --> connect + hub -- "ssh devbox wtd connect
one control master per host;
a Tailscale check's login link is shown on the page" --> connect classDef unused stroke-dasharray: 4 4,color:#888; class otherHub unused; ``` From b9382959b011b3c39fcd4fe4c4197fe1c8c0ac32 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 13:17:22 +0200 Subject: [PATCH 6/7] Fix the fake SSH cleanup flake: retry the whole removal, killing late runs rmSync empties a directory once and then retries only removing it, so a call recorded by an SSH run that started before the hub was killed made cleanup fail with ENOTEMPTY after 5.5 s. Cleanup now also kills runs that have not recorded their call yet and retries the whole removal. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/process/fake-ssh.test.ts | 30 +++++++++++++++++++++++++++++ test/support/daemon-host.ts | 2 +- test/support/fake-ssh.ts | 36 ++++++++++++++++++++++++++++++----- 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 test/process/fake-ssh.test.ts diff --git a/test/process/fake-ssh.test.ts b/test/process/fake-ssh.test.ts new file mode 100644 index 0000000..3dbfb99 --- /dev/null +++ b/test/process/fake-ssh.test.ts @@ -0,0 +1,30 @@ +import { spawn } from 'node:child_process'; +import { copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { waitUntil } from '../support/daemon-host.js'; +import { FakeSsh } from '../support/fake-ssh.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-'))); + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-fake-ssh-copy-'))); + const ssh = new FakeSsh(dir); + // Like a hub killed mid-reconnect whose last runs start late: four loops of a copy of the program, whose runs + // outlive the program's removal, keep recording calls for a second. + const program = join(elsewhere, 'ssh'); + copyFileSync(ssh.program, program); + const loop = 'end=$(($(date +%s%3N)+1000)); while [ "$(date +%s%3N)" -lt "$end" ]; do "$0" -- nowhere true 2>/dev/null; done'; + const stopped = Array.from({ length: 4 }, () => { + const runs = spawn('/bin/sh', ['-c', loop, program], { stdio: 'ignore' }); + return new Promise((resolve) => runs.once('exit', resolve)); + }); + try { + await waitUntil(() => ssh.calls().length > 0, 'a first run'); + await ssh.cleanup(); + expect(existsSync(dir)).toBe(false); + } finally { + await Promise.all(stopped); + rmSync(elsewhere, { recursive: true, force: true }); + } +}); diff --git a/test/support/daemon-host.ts b/test/support/daemon-host.ts index 26a30ac..e902ddc 100644 --- a/test/support/daemon-host.ts +++ b/test/support/daemon-host.ts @@ -34,7 +34,7 @@ export const alive = (pid: number): boolean => { }; /** Live pids whose argv and environment satisfy `matches`. */ -const livePidsWhere = (matches: (cmdline: string[], environ: string[]) => boolean): number[] => { +export const livePidsWhere = (matches: (cmdline: string[], environ: string[]) => boolean): number[] => { const pids: number[] = []; for (const name of readdirSync('/proc')) { if (!/^\d+$/.test(name)) continue; diff --git a/test/support/fake-ssh.ts b/test/support/fake-ssh.ts index d87a890..30013ba 100644 --- a/test/support/fake-ssh.ts +++ b/test/support/fake-ssh.ts @@ -13,7 +13,7 @@ import { } from 'node:fs'; import { delimiter, join } from 'node:path'; import { DaemonClient, type EventOf } from './daemon-client.js'; -import { alive, hostFileName, waitUntil, type DaemonHost, type Exit } from './daemon-host.js'; +import { alive, hostFileName, livePidsWhere, waitUntil, type DaemonHost, type Exit } from './daemon-host.js'; const QUOTED_QUOTE = String.raw`'\''`; @@ -326,15 +326,41 @@ export class FakeSsh { } async cleanup(): Promise { - for (const call of this.calls()) { + this.killRuns(); + await Promise.all(this.remotes.map((remote) => remote.cleanup())); + // rmSync empties a directory once and then retries only removing it, so a call recorded meanwhile fails it + // for good: retry the whole removal, killing runs that started since. + await waitUntil( + () => { + this.killRuns(); + try { + rmSync(this.dir, { recursive: true, force: true }); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOTEMPTY') return false; + throw error; + } + }, + 'the fake SSH directory to go', + 10_000, + ); + } + + /** Kills every live run, including those that have not recorded their call yet. */ + private killRuns(): void { + let recorded: number[] = []; + try { + recorded = this.calls().map((call) => call.pid); + } catch { + // Removed while we read it. + } + for (const pid of [...recorded, ...livePidsWhere((cmdline) => cmdline[1] === this.program)]) { try { - if (alive(call.pid)) process.kill(call.pid, 'SIGKILL'); + if (alive(pid)) process.kill(pid, 'SIGKILL'); } catch { // Already gone. } } - await Promise.all(this.remotes.map((remote) => remote.cleanup())); - rmSync(this.dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } private writeHost(alias: string, values: Record): void { From 129652afbcbda7802a9b808f857cb7166a504c38 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 13:33:26 +0200 Subject: [PATCH 7/7] Archive the DEV-2106 OpenSpec change into the specs Co-Authored-By: Claude Opus 5.5 (1M context) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/hub/spec.md | 0 .../specs/protocol/spec.md | 0 .../specs/web/spec.md | 0 .../tasks.md | 0 openspec/specs/hub/spec.md | 31 +++++++++++++++++++ openspec/specs/protocol/spec.md | 16 ++++++++-- openspec/specs/web/spec.md | 10 +++++- 10 files changed, 54 insertions(+), 3 deletions(-) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/.openspec.yaml (100%) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/design.md (100%) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/proposal.md (100%) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/specs/hub/spec.md (100%) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/specs/protocol/spec.md (100%) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/specs/web/spec.md (100%) rename openspec/changes/{dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on => archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on}/tasks.md (100%) diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/.openspec.yaml diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/design.md diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/proposal.md diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/hub/spec.md diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/protocol/spec.md diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/specs/web/spec.md diff --git a/openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md b/openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md similarity index 100% rename from openspec/changes/dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md rename to openspec/changes/archive/2026-10-18-dev-2106-worktree-term-surface-the-tailscale-ssh-check-login-link-on/tasks.md diff --git a/openspec/specs/hub/spec.md b/openspec/specs/hub/spec.md index 3f11a53..b489bfd 100644 --- a/openspec/specs/hub/spec.md +++ b/openspec/specs/hub/spec.md @@ -403,3 +403,34 @@ A configuration edit — a repo edit or a preset edit — SHALL read the current #### Scenario: Move beyond the end - **WHEN** a session moves the first of three presets to position 63 - **THEN** it becomes the last preset + +### Requirement: Tailscale SSH check login +While a remote link has received no frame from its daemon, the hub SHALL read its SSH process's standard error as it is written, line by line. A line containing `To authenticate, visit:` followed by a URL that, with control characters removed, starts with `https://login.tailscale.com/` and has at most 1024 characters SHALL make the host `needs-login` with that URL as `login`, within 1 s of being written, with `reason`, `daemonVersion` and `instance` null, keeping the link open and counting no failure. A line naming any other URL SHALL change nothing, as SHALL a repeat of the URL the host already shows. Standard error read after the link's first daemon frame, after the link was closed or ended, or after the link was replaced SHALL change no host. A `needs-login` host SHALL become `connected` or `outdated` when its link exchanges `hello`, and `reconnecting` or `down` when its link is lost, as for any other link, counting the consecutive failures from before it was `needs-login`; `login` SHALL be null in every status other than `needs-login`. A request for a `needs-login` host SHALL fail with `host-unavailable`. When a link of one session to a host exchanges `hello`, every other session's link to the same host that is `needs-login` SHALL be closed and at once replaced by a new link, that session's host becoming `connecting` with `login` null; links of other sessions in any other status SHALL be left alone. + +#### Scenario: Login link surfaced +- **WHEN** the SSH process for host `box` writes `# Tailscale SSH requires an additional check.` and `# To authenticate, visit: https://login.tailscale.com/a/abc123` to standard error and then waits +- **THEN** within 1 s the session receives `box` as `needs-login` with `login` `https://login.tailscale.com/a/abc123` and a null `reason`, and no second SSH process is started + +#### Scenario: Approval connects without retry +- **WHEN** a `needs-login` host's waiting SSH process proceeds and the daemon greets +- **THEN** the session receives the host as `connected` with a null `login`, over the same SSH process + +#### Scenario: Other URL ignored +- **WHEN** the SSH process for a host writes `To authenticate, visit: https://login.tailscale.com.evil/a/x` to standard error and waits +- **THEN** the host stays `connecting` + +#### Scenario: Expired check falls back to retrying +- **WHEN** a `needs-login` host's SSH process exits writing `Tailscale SSH check timed out` to standard error, and the next SSH process writes a new login URL +- **THEN** the session receives the host as `reconnecting` with that reason and a null `login`, then as `needs-login` with the new URL + +#### Scenario: Banner from a closed link ignored +- **WHEN** a session's link is closed and its SSH process writes a login banner while it is being terminated +- **THEN** no host entry changes + +#### Scenario: Request to a needs-login host +- **WHEN** a browser sends `host{host: 1, m: watchRepo}` while host 1 is `needs-login` +- **THEN** the session receives an `error` with code `host-unavailable` + +#### Scenario: One approval connects every session +- **WHEN** sessions A and B each hold a `needs-login` link to `box` over their own SSH processes, a session C is `connected` to `box`, and only A's SSH process proceeds and greets +- **THEN** B's original SSH process is terminated, B receives `box` as `connecting` and then `connected` over a new SSH process, and C receives no change to `box` diff --git a/openspec/specs/protocol/spec.md b/openspec/specs/protocol/spec.md index 4ceaba8..83c1d45 100644 --- a/openspec/specs/protocol/spec.md +++ b/openspec/specs/protocol/spec.md @@ -6,7 +6,7 @@ The wire contract between the browser UI, the hub and every daemon: which messag ## Requirements ### Requirement: Protocol version and frozen handshake messages -The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 8, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 9, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. #### Scenario: Hello round-trips - **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded @@ -91,7 +91,7 @@ A worktree SHALL be `{path, head, branch, detached, locked, prunable, bare, main ### Requirement: Browser-link message catalogue Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. -A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `reason`, `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. +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. #### Scenario: Envelope cannot carry a handshake message - **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded @@ -129,6 +129,18 @@ A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instan - **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 + ### Requirement: Request correlation Every message that asks a peer to act and can fail SHALL carry `req`, an integer from 1 to 2^32−1, and its outcome SHALL be correlated by that `req`: `termCreated` for `createTerm`, `reposDiscovered` for `discoverRepos`, `worktreeAtRisk` or `done` for `removeWorktree`, `done` for every other request, `error` for any failed request. A `termCreated` or `error` with `req` null SHALL correlate with no request. `resize`, `ack`, `setVisible`, input frames, `hello` and `shutdown` SHALL carry no `req`. diff --git a/openspec/specs/web/spec.md b/openspec/specs/web/spec.md index 04fa59e..9cafeda 100644 --- a/openspec/specs/web/spec.md +++ b/openspec/specs/web/spec.md @@ -372,7 +372,7 @@ The page SHALL tell each host which of its terminals the page shows: every pane - **THEN** after the page reconnects the daemon counts both terminals as visible ### Requirement: Host status display -For each host that is `down` or `outdated` the page SHALL show a banner naming the host, its status and its `reason` when present, with the action the outdated host requirement states. A `connecting` or `reconnecting` host SHALL show its status only on its repo tabs. +For each host that is `down` or `outdated` the page SHALL show a banner naming the host, its status and its `reason` when present, with the action the outdated host requirement states. For each host that is `needs-login` the page SHALL show a banner naming the host and saying it needs Tailscale login, with an "Open login link" button that opens the host's `login` URL in a new tab without giving that tab access to the page, and no other action; its repo tabs SHALL show the status `needs login`. A `connecting` or `reconnecting` host SHALL show its status only on its repo tabs. #### Scenario: Down host banner - **WHEN** a remote host `box` is `down` with reason `ssh: Could not resolve hostname box` @@ -382,6 +382,14 @@ For each host that is `down` or `outdated` the page SHALL show a banner naming t - **WHEN** a `down` host becomes `connected` - **THEN** its banner disappears +#### Scenario: Needs-login banner with link +- **WHEN** a remote host `box` with repo `/srv/app` is `needs-login` with `login` `https://login.tailscale.com/a/abc123` +- **THEN** the page shows a banner naming `box` with an "Open login link" button linking to that URL in a new tab with no opener, and the tab labelled `box:app` shows `needs login` + +#### Scenario: Approved login clears the banner +- **WHEN** a `needs-login` host becomes `connected` +- **THEN** its banner disappears and its repo tabs show no status + ### Requirement: Add repo The repo tab bar SHALL end with an "Add repo" control opening a dialog with a host selector — listing every host with its status, hosts that are not `connected` disabled, preselecting the host of the selected tab — a list of the repos discovered on the selected host minus those it lists, a filter narrowing that list by substring, and a field for typing an absolute path. Choosing a repo or submitting a path SHALL send `addRepo`; on `done` the dialog SHALL close and the added repo's tab SHALL be selected once the hub lists it; an error SHALL be shown in the dialog. Escape SHALL close the dialog without sending anything.