diff --git a/README.md b/README.md index 984164f..570505c 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,10 @@ The useful boundary is the layer: sharing. So Miranda does not replace your multiplexer. It makes the sessions you already -run follow you. tmux is the engine it drives today; carrying other engines is -the direction, tracked in [#107](https://github.com/srcfl/miranda/issues/107), -not something that works yet. +run follow you. tmux is the default engine. On a herdr machine, +`mir up --engine herdr` attaches the phone to that herdr session. Deeper herdr +integration (inbox, pane streams) is tracked in +[#107](https://github.com/srcfl/miranda/issues/107). | Tool | Its job | What Miranda adds | |---|---|---| @@ -65,6 +66,12 @@ mir pair # shows a QR/code and a six-group safety number mir up # keeps the machine's tmux session reachable ``` +On a machine that already runs [herdr](https://herdr.dev): + +```bash +mir up --engine herdr # phone lands in this herdr, same session as the desktop TUI +``` + Then, from another terminal: ```bash @@ -151,8 +158,8 @@ Read the exact guarantees, non-goals, and residual risks in ## Install -Miranda currently targets macOS and Linux. `tmux` is required on machines serving -persistent sessions. Release installation requires `cosign` and fails +Miranda currently targets macOS and Linux. Persistent sessions need `tmux` +(the default) or herdr (`mir up --engine herdr`). Release installation requires `cosign` and fails closed unless the checksum manifest has a valid keyless signature from this repository's tagged release workflow. Native owner clients store their root in macOS Keychain or Linux Secret Service; on Linux, install the package that provides diff --git a/docs/status.md b/docs/status.md index ebcea00..68904f8 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,12 +1,13 @@ # Status — where Miranda stands -Written 2026-08-31. Read this first if you are picking the project up cold, then -`CLAUDE.md`, `docs/product.md`, and the spec for whatever you are about to touch. -This file records state and decisions; the specs remain the source of truth for -design. +Written 2026-08-31, updated 2026-09-11. Read this first if you are picking the +project up cold, then `CLAUDE.md`, `docs/product.md`, and the spec for whatever +you are about to touch. This file records state and decisions; the specs remain +the source of truth for design. -**Next goal: carry herdr as a second engine.** Jump to -[What comes next](#what-comes-next). +**Next goal: run every herdr from the phone.** `mir up --engine herdr` on +each box; the existing PWA attaches to that machine's herdr TUI. The agent +inbox is later. Jump to [What comes next](#what-comes-next). ## Where things stand @@ -17,8 +18,9 @@ live: every importmap entry resolves, the beta's modules are served, Miranda is positioned as **the reach layer for persistent terminals**: a multiplexer keeps the session alive on the machine, Miranda gets you to that -machine. The engine is tmux today, and carrying a second one is the next goal. -The name's story now ships too — "a relay that cannot testify." +machine. The engine is tmux today; herdr is the second engine, and the +product-facing goal is a herdr-aware mobile webapp on the PWA we already +ship. The name's story now ships too — "a relay that cannot testify." Shipped over 2026-08-29/30, all merged and released: @@ -37,51 +39,66 @@ Shipped over 2026-08-29/30, all merged and released: ## What comes next -**Carry herdr as a second engine.** Spec: -[`docs/superpowers/specs/2026-08-31-engine-seam-herdr-design.md`](superpowers/specs/2026-08-31-engine-seam-herdr-design.md), -issue [#107](https://github.com/srcfl/miranda/issues/107). Its slice table is -authoritative; this is the orientation. - -Why: [herdr](https://github.com/herdrdev/herdr) is an agent-aware tmux -replacement whose remote answer is SSH into your own box. It is not a -competitor — it sits one layer below Miranda, and its audience is Miranda's -ideal user, already assembled. Every engine Miranda can carry widens the market -instead of splitting it. - -Everything below was **measured against herdr 0.8.2**, not assumed: - -- **Better than tmux:** `terminal session observe` is a genuinely confined - read-only stream — injected input, resize and scroll all failed to reach the - pane, and a focus change elsewhere never leaked into the stream. That is a - cleaner primitive than G1's `capture-pane` + `pipe-pane`. Its event socket - also beats R3's 22 hooks, and it exposes agent state (working / blocked / - idle) that tmux never had — a new capability, not just parity. -- **Worse than tmux:** there is **no per-attach view isolation** — two clients - share focus, measured both at tab and workspace level. That is exactly the bug - D4's grouped sessions were built to kill. A writable guest is one pane, - exclusive, and any client can `--takeover` and evict the incumbent. -- **Watch out:** herdr phones home by default (version checks and the agent - detection manifests), and its socket lives under `$HOME`, so a long `HOME` - overflows `sun_path`. +**Ship phone attach to the herdr you already run.** Spec: +[`docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md`](superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md) +(v1 is TUI-in-PWA; inbox is the following product). The engine seam +([#107](https://github.com/srcfl/miranda/issues/107), +[`2026-08-31-engine-seam-herdr-design.md`](superpowers/specs/2026-08-31-engine-seam-herdr-design.md)) +still pays for itself, but it does not gate this slice: `mir up --engine herdr` +launches `herdr` in the PTY, HELLO carries `engine`, the PWA hides the tmux +strip. Do not isolate `HERDR_SOCKET_PATH` — that would be a second, empty +session. + +Why: herdr 0.9.0 (2026-09-07) can orchestrate several machines in one TUI — +via SSH (`herdr machine add`). It still has no browser, no passkey, no +phone. Community fills that with Moshi (SSH), `herdr-web` (private APIs), +and a Tailscale relay plugin. Miranda's PWA already is that client at the +reach layer. Herdr Cloud is waitlisted as an E2E "middleman" — the sentence +that is already the README. Move while that door is open. + +Everything below about streams was **re-measured against herdr 0.9.0** +on linux/x86_64 (protocol 22). TUI-view independence (#3526) is herdr's +published contract; a clean A-follows-B replay was not completed in the +sandbox (first-run overlay). Details live in the 2026-09-11 spec §2. + +- **Better than tmux, still:** `terminal session observe` is confined + (a marker printed in another pane did not appear). `events.subscribe` + still beats R3's hooks. Agent state still rolls up pane → tab → + workspace. `report-agent` to `blocked` was visible on all three. +- **Worse than tmux, still, for the socket:** `tab.focus` is + server-global. Control of one pane is exclusive; `--takeover` evicts + the incumbent. A headless server starts with an empty snapshot — there + is no `tmux new -A -s main` analogue until `workspace create`. +- **Changed since 0.8.2:** herdr TUI clients are documented as able to view + different tabs independently, with last-interactor size on a shared tab. + Multi-machine lives in the *client* over SSH; the socket has no + `machine.*` methods. Do not implement `herdr machine add` inside Miranda. +- **Watch out, unchanged:** phone-home on by default; `sun_path` overflow + on a long `HOME`; a small viewport is a crop when a full TUI owns + geometry (0.8.2 measurement, not re-run on 0.9). ### Decisions still open (Fredrik's call) -Claude's recommendations, argued in the spec and in the session that produced -it; none is ruled on yet. - -1. **A second interactive attach on herdr** — allow it with a loud warning - (recommended: refusing would make a herdr box *worse* with Miranda than - without, which kills the distribution play), or refuse it. -2. **Two window strips** — herdr draws its own tab bar inside the pane. Hide - ours, hide nothing, or keep ours only where it adds tap targets. -3. **Writable guests on herdr** — recommended: **refuse `--write` in v1**, since - any client can evict the owner. Read-only sharing there is better than on - tmux, so the sharing story survives. -4. **herdr's phone-home** — recommended: ship Miranda's herdr config with it - off, and document that agent detection goes staler. -5. **Sequencing** — recommended: land the seam for tmux only (a pure refactor - that pays off regardless), and hold the herdr engine itself until the beta - gaps below are closed. +The engine spec's five, plus two from the webapp spec. Recommendations +below; none is ruled on yet. + +1. **A second interactive attach on herdr** — for pane-streams: many + observers, refuse a second controller without takeover. For TUI launch + (`mir attach` on a laptop): allow with a loud warning until #3526 is + re-measured on Linux. +2. **Two window strips** — on the phone, draw neither. Inbox + one pane. + Desktop TUI launch may still double-chrome; that path is not web v1. +3. **Writable guests on herdr** — still **refuse `--write` in v1**. + Observe-based sharing is the better primitive. +4. **herdr's phone-home** — ship Miranda's herdr config with it off. +5. **Sequencing** — land E1+E2 (tmux seam) regardless. Do **not** hold the + herdr engine behind the rest of the beta; the webapp spec's M2–M4 are + the product. Herdr Cloud is why. +6. **Inbox vs machine grid as home.** Recommended: keep the grid; a + "needs you" chip opens the inbox when any herdr machine is `blocked`. +7. **Phone typing at a pane a desktop TUI is watching.** Recommended: + observe by default; `--takeover` only behind a confirm that names the + engine. The rule the spec sets, and the one not to bend: **no feature may silently degrade.** If an engine cannot do per-attach isolation or a confined read-only @@ -96,7 +113,7 @@ view, Miranda refuses or says so plainly. - **P3: commission the external audit.** Scope is ready in `docs/audit-scope.md`; `BETA.md` states plainly that no audit has been commissioned. -- The five herdr decisions above. +- The herdr decisions in [What comes next](#what-comes-next) (now seven). ## Open backlog diff --git a/docs/superpowers/specs/2026-08-31-engine-seam-herdr-design.md b/docs/superpowers/specs/2026-08-31-engine-seam-herdr-design.md index 90c6781..73a35c5 100644 --- a/docs/superpowers/specs/2026-08-31-engine-seam-herdr-design.md +++ b/docs/superpowers/specs/2026-08-31-engine-seam-herdr-design.md @@ -4,6 +4,16 @@ > claim about herdr below was produced by running herdr 0.8.2 in a contained > sandbox and reading what came back. Where a thing was not measured, it says > so. +> +> **0.9.0 addendum (2026-09-11).** herdr shipped multi-machine SSH orchestration +> and documented per-client TUI views. Observe/control exclusivity and +> `tab.focus` being server-global were re-measured on 0.9.0 and still hold. +> The phone-facing product is specified separately: +> [`2026-09-11-herdr-mobile-webapp-design.md`](2026-09-11-herdr-mobile-webapp-design.md). +> Phone v1 is `mir up --engine herdr` attaching the existing PWA to the +> user's existing herdr TUI (no private `HERDR_SOCKET_PATH`). Inbox and pane +> observe/control remain the following product. This document remains the +> `Engine` interface and the tmux coupling map. ## One sentence diff --git a/docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md b/docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md new file mode 100644 index 0000000..6907407 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md @@ -0,0 +1,401 @@ +# Herdr-aware mobile webapp — run every herdr from the phone + +> Investigation + design. Grounded in herdr **0.9.0** (protocol 22, +> endpoint generation 1) running on linux/x86_64, and in the existing Miranda +> PWA. Where a thing was not measured, it says so. The 2026-08-31 engine-seam +> spec remains the source of truth for the `Engine` interface; this spec +> decides what the **phone** should be. + +**v1 (Fredrik, 2026-09-11):** the problem to solve is running all herdrs from +the phone. That is the existing PWA attaching to the user's existing herdr +TUI via `mir up --engine herdr`, not an agent inbox. Inbox, pane +observe/control, and `HERDR_SOCKET_PATH` isolation are later. Do not mint a +second empty herdr — launching `herdr` with the user's environment attaches +the session they already have. Miranda's paired-machine list is the +multi-machine layer; do not implement `herdr machine add`. + +## Phone v1 — TUI in the PWA we already ship + +On each box: `mir up --engine herdr` (install herdr if needed, pair as +today). On the phone: open the Miranda PWA, tap the machine, land in that +machine's herdr TUI (herdr's own mobile layout at ≤64 cols). Switch machines +with the existing ⇄ / machine chips. Hide Miranda's tmux window strip — herdr +draws its own chrome, and the +/‹/› fallback is a lie. Prefix on the mobile +keybar is Ctrl-B (0x02), which herdr and tmux share. + +Honest limits, said at `mir up`: Miranda's window strip and `mir share` stay +tmux-only. Writable guests are refused (already: write sharing needs tmux). +A second attach is a second herdr TUI client; 0.9 claims independent views. + +The inbox design below stays as the *next* product, not this slice. + +## One sentence + +Miranda already ships the mobile client herdr refused to build. v1 is +**that client attached to the herdr you already run**. An **agent inbox +across paired machines** is what comes after, once the phone path exists. + +## Why this now + +herdr 0.9.0 (2026-09-07) is the first version that can put several machines in +one window. That is the orchestration people want. It is still SSH, still a +desktop TUI, still nothing you open on a phone with a passkey. Herdr Cloud +is on a waitlist as the "middleman layer" that would connect those machines +without SSH. That middleman is Miranda's whole product. The door is open +until they ship it. + +The existing SPA already does the reach half: passkey, pairing, encrypted +registry, a warm pool of three machines, a touch keybar, PWA install. What +it does not do is speak herdr — so a herdr box today is either tmux, or +`--shell herdr` painting a mouse-first desktop TUI into xterm.js. + +--- + +## Part 1 — what is actually out there + +### 1.1 Miranda's webapp, today + +A vanilla-JS PWA (`web/`). Passkey identity, in-browser pairing, a machine +grid, attach over WebRTC + Noise, xterm.js, tmux window strip, up to three +machines warm, park-on-hide, mobile keybar (Esc / Ctrl / Tab / arrows). It is +already the "from my iPhone" path from plan 6b. It knows windows. It does +not know agents. + +### 1.2 herdr 0.9.0 — orchestration, still no phone + +Two things shipped that the August spec could not see: + +1. **`herdr machine add workbox`.** One TUI client SSHes to N herdr + servers. The sidebar is a combined agent list. A stalled machine does not + take the others down. Profiles store an opaque id, a label, an SSH + target, an optional named session, and enabled/disabled — not keys. This + is client-side. The socket API on a server has **102 request methods and + zero `machine.*` methods**; `herdr machine list` against a headless + server prints `No saved SSH machines` / `[]`. Cross-machine `agent wait` + is explicitly not in 0.9: the CLI still talks to one server. +2. **Independent TUI views (#3526).** "Multiple clients can now view different + workspaces and tabs independently. Different tabs fit their viewing + clients; when clients share a tab, the last one to interact with it + controls its size." The outer UI now runs in the client; servers own + panes and feed terminal views. This is the D4-shaped bug from 0.8.2, + addressed for **herdr TUI clients**. + +What 0.9 still does not ship: a browser, a phone app, a relay, passkeys, +sharing. Remote remains SSH (or Tailscale in front of SSH). The installer +tells you Android/Termux is unsupported. + +herdr's own TUI has a mobile layout (`mobile_width_threshold = 64` columns) +and a touch-oriented switcher. That is a narrow *terminal* layout, not a +webapp. It still needs a route to the host. + +### 1.3 The community already tried to fill the hole + +| Project | What it is | What it needs | Why it is not us | +|---|---|---|---| +| [Moshi](https://getmoshi.app/guides/herdr) | Native iOS terminal with a herdr-aware picker and a Needs you / Working / Done inbox | SSH to the host, `herdr` on PATH in a non-interactive shell | SSH keys on the phone; no passkey; one vendor's app | +| [kcosr/herdr-web](https://github.com/kcosr/herdr-web) | Browser TUI + `herdr-web-bridge` | Vendored private herdr APIs; LAN/bridge URL; one attach owner per terminal, fanned out by the bridge | Private APIs; no identity model; the author asks herdr to expose a supported web surface | +| [benkraus/herdr-plugin-mobile-relay](https://github.com/benkraus/herdr-plugin-mobile-relay) | Loopback HTTP/WS relay + optional Tailscale Serve | Bun, Tailscale, a single-user host | Tailscale is the reach layer; the relay sees more than Miranda's does | + +They prove the gap is real. None of them is a blind relay plus a passkey. + +### 1.4 The layer split, drawn once + +```text + herdr 0.9 Miranda + machine-local workspaces, tabs, panes, (not our job) + agent state, observe/control + + multi-machine TUI client over SSH passkey + paired + (`machine add`); Cloud waitlist machines + registry + + phone / browser TUI-at-64-cols; Moshi/SSH; the PWA we already + community bridges ship +``` + +herdr's new orchestration is the *client* aggregating SSH servers. Miranda +already aggregates machines at the identity layer. The webapp should show +**one inbox of agents**, each row carrying the Miranda machine it lives on, +and attach through Miranda — never through `herdr machine add`. + +--- + +## Part 2 — measured on 0.9.0 + +Binary `herdr-linux-x86_64` from `herdr.dev/latest.json`, SHA-256 +`4fa1a01158dd8043da92d31b270780b0dcc10603038d9b61cac4d81ab63fb71f`. Private +`HOME=/tmp/h9`, `HERDR_SOCKET_PATH=/tmp/h9/herdr.sock`, +`version_check = false`, `manifest_check = false`. Headless `herdr server`. + +### 2.1 Headless server starts empty + +`herdr api snapshot` on a fresh server: + +```json +{"agents":[],"layouts":[],"panes":[],"tabs":[],"workspaces":[],"protocol":22,"version":"0.9.0"} +``` + +`herdr workspace create --label demo` minted `w1` / `w1:t1` / `w1:p1` and +a `terminal_id`. A Miranda engine that talks to a headless server cannot +assume a default session the way `tmux new -A -s main` does. `Launch` must +create a workspace if the snapshot is empty, or attach as the TUI binary +(which does that itself). + +### 2.2 Observe is still confined + +`herdr terminal session observe w1:p1` while `TAB_TWO_MARKER_SECRET` was +printed in `w1:p2`: **0 hits** in the observer's decoded frames. +`RO_PROBE_PANE1_AGAIN` printed in `w1:p1`: **present**. Same shape as 0.8.2. + +### 2.3 Control is still exclusive, with takeover + +With stdin held open: + +- Second `terminal session control` on the same pane, no `--takeover`: + `terminal attach failed: terminal term_… already has an attached client; + retry with --takeover`. +- With `--takeover`, the incumbent received + `terminal attach taken over` and the new controller stayed up. + +A run that did **not** keep stdin open both exited immediately with +`reason: detached`. That is a client lifecycle quirk, not a new sharing +model. Do not cite it as "control is no longer exclusive." + +### 2.4 Agent state still rolls up + +``` +herdr pane report-agent w1:p1 --source probe --agent claude --state blocked \ + --message "needs approval" +``` + +Then `agent list`, the pane, its tab, and its workspace all reported +`blocked`. Same as 0.8.2. Still a heuristic when detection is a screen +manifest; this path was the probe/report API, not a live Claude Code pane. + +### 2.5 `tab.focus` is still server-global + +`herdr tab focus w1:t2` flipped `snapshot.focused_tab_id` to `w1:t2`. +That is the server's focused tab, not a per-client cursor. A Miranda +window strip that maps `select-window` onto `tab.focus` will yank every +API consumer and, from 0.8.2, yanked TUI clients too. + +`client_shell.surface.set` exists in protocol 22. Its params are +`{ "active": bool }` — whether *this* client shell receives pane presentation. +It is not a per-tab view selector. + +### 2.6 Independent TUI views — documented, not re-measured here + +0.9's #3526 and the Connecting machines doc claim per-client workspace/tab +views for TUI clients. Two PTY `herdr` attaches in this sandbox landed in +the first-run integrations overlay (`claude not found`, `↵ continue`), so +a clean A-follows-B replay of the 0.8.2 experiment was **not** completed. +Treat per-client TUI views as herdr's published contract, and treat +`tab.focus` via the socket as still global until a later slice re-measures +both on Linux with onboarding dismissed. + +### 2.7 Geometry / crop — not re-measured + +0.8.2: a controller's `--cols/--rows` resizes the pty only when no full-UI +client owns geometry; otherwise a small viewport is a **crop**. 0.9's +shared-tab rule (last interactor wins size) is documented for TUI clients +sharing a tab. Whether a `terminal session control` stream vs a desktop TUI +still crops a phone was **not** re-run. Carry the 0.8.2 crop warning into +v1 copy until M3's acceptance re-measures it. + +### 2.8 Phone-home and sun_path — unchanged + +Defaults: `version_check = true`, `manifest_check = true`. Socket under +`$HOME/.config/herdr/herdr.sock`. `HERDR_SOCKET_PATH` still the escape +hatch; Miranda should keep setting it under its own state dir. + +--- + +## Part 3 — the product + +### 3.1 Do not paint herdr's TUI on a phone + +`--shell herdr` already "works": the browser sees herdr's sidebar, tab bar, +and mouse UI inside xterm.js. On a phone that is two chromes (theirs plus +Miranda's strip) and a 64-column mobile TUI that still wants a prefix key. +Moshi exists because that is painful. Community web bridges exist because +that is painful. + +v1 of this webapp **does not attach as the herdr TUI**. It uses the +third-party-bridge streams herdr already documents: + +- snapshot + `events.subscribe` → the inbox +- `terminal session observe ` → read-only view of one pane +- `terminal session control ` → writable view of one pane, exclusive + +Miranda's existing machine pool stays the multi-machine layer. herdr's +`machine add` is out of scope on the agent; we do not SSH from the phone. + +### 3.2 Three screens + +1. **Machines** — today's grid, plus a rollup when the engine reports agent + state: `blocked 2 · working 1` on the card. tmux cards stay as they are + (no `st`). A machine whose HELLO says `engine: herdr` is allowed to look + different; a tmux machine must not grow fake agent dots. +2. **Inbox** — one list, every paired herdr machine, sorted by attention + (`blocked`, `done`, `working`, `idle`, `unknown`). Each row: machine name, + workspace/tab label, agent name, state. Tap → that pane on that machine. + This is the thing herdr 0.9's sidebar does over SSH, and Moshi's inbox + does over SSH. We do it over the already-paired Noise channel. +3. **Pane** — one xterm, sized to the phone, with today's keybar. Chrome is + Miranda's: back, machine chip, state pill, optional Observe/Control + toggle. No herdr tab bar inside the pane. Window-strip verbs that would + call `tab.focus` / `workspace.focus` are hidden on this surface — they + are server-global and would yank a desktop herdr client. + +A tmux machine skips the inbox and opens today's terminal + window strip. +Honest degradation: no agent list, not a greyed-out fake one. + +### 3.3 Attach model on herdr + +| Role | Stream | Who resizes | Who can type | +|---|---|---|---| +| Owner on phone, no desktop TUI | `control` | the phone (pty reflows) | this client | +| Owner on phone, desktop TUI already on that pane | `observe` by default; `control --takeover` only behind a named confirm | 0.8.2: crop if you control anyway | takeover evicts the desktop | +| Guest read-only | `observe` | never | nobody | +| Second phone on the same pane | `observe` (unlimited) or refuse a second `control` with herdr's own error | — | — | + +This *is* per-attach isolation for the case Miranda actually needs on a +phone: two people (or phone + desktop) looking at the **same** running +agent without sharing a TUI focus. It is not tmux grouped sessions. It is +better than dumping the TUI and hoping #3526 holds. + +`Launch` for the herdr engine therefore has two modes, and the webapp uses +the second: + +- **TUI launch** (CLI `mir attach`, optional): argv `herdr`, user sees + herdr's own interface. Fine on a laptop terminal. Subject to 0.9's + TUI-view rules. +- **Pane launch** (webapp v1): no PTY herdr client. The agent opens + observe/control against a `pane_id` and copies ANSI frames onto + `FrameData`. Resize of a control stream maps to `terminal.resize`, not to + the PTY of a herdr TUI. + +FrameWindows v2 still carries the tree so the CLI overview and any desktop +strip keep working. The inbox is a new consumer of the same snapshot, plus +the optional `st` field the engine spec already specified. + +### 3.4 HELLO carries enough to pick a chrome + +Today HELLO is `{"name": "…"}`. Add optional fields, ignored by old clients: + +```json +{"name":"workbox","engine":"herdr","caps":{"agent_state":true,"confined_mirror":true,"per_attach_view":false}} +``` + +The SPA branches on `engine` / `caps.agent_state`, never on a user-agent +string. A missing `engine` means tmux-shaped, as today. + +No new frame type for v1. Agent rows ride on FrameWindows (`st`) plus the +existing snapshot. If a later slice needs `agent.explain` / prompt text, +that is a new frame, as the engine spec already said. + +### 3.5 What we must not promise + +- **"The same independent views as tmux."** Pane observe is independent; + `tab.focus` is not; TUI independence is herdr's contract, not ours to + restated as measured until Linux says so. +- **"We know Claude is blocked."** Still herdr's opinion. Label it. +- **"Your phone reflows the desktop pane."** Crop until re-measured; v1 + prefers observe when a TUI owns geometry. +- **Cross-machine agent orchestration.** herdr 0.9 cannot `agent wait` + across SSH machines either. Miranda's inbox is a *view* across machines, + not a scheduler. +- **A smaller trusted base if phone-home stays on.** Same open decision as + the engine spec. + +--- + +## Part 4 — how this changes the engine spec + +Keep E1 (extract `Engine` around tmux) and E2 (`--engine`, doctor, caps). +They pay for themselves with no herdr. + +Revise the herdr slices for a web-first v1: + +| Engine spec | Mobile webapp | +|---|---| +| E3 Launch = `herdr` TUI in the PTY | Keep for CLI. Web v1 uses pane observe/control instead | +| E4 Control = six window verbs via `tab.*` | Hide `tab.focus` / `workspace.focus` on the phone surface; they are server-global | +| E5 Refuse a second interactive attach | For **TUI launch**, follow 0.9's published multi-client contract (re-measure). For **pane launch**, allow many observers; refuse a second controller without takeover | +| E6 Mirror via observe | This becomes the primary phone view, not just G1 guests | +| E7 `st` on FrameWindows | The inbox's data path; do this earlier than the engine spec's order | +| E8 Bootstrap / install | Still needed for `mir up --engine herdr` | + +The rule does not bend: no silent degradation. A second `control` on a pane +is herdr's error, surfaced with the engine named. A tmux machine never +grows an inbox. + +Open questions 1 and 2 from the engine spec look different from the phone: + +1. **Second attach.** For the webapp, do not refuse the second *machine* + attach (R2 already warms three). Refuse a second *controller* of one + pane. A second TUI `mir attach` on a laptop is herdr's problem; our + recommendation is still "allow with a loud warning" until Linux + re-measures #3526. +2. **Two window strips.** On the phone, draw neither herdr's nor a tmux + strip. Draw the inbox and one pane. On a desktop browser, a herdr TUI + launch may still double-chrome; that path is not v1. + +3–5 (writable guests, phone-home, sequencing) stand. Recommendation for +sequencing **changes**: do not hold herdr behind the rest of the beta. +Herdr Cloud is the reason to move. E1 → E2 → (herdr Probe/Snapshot/Watch + +Mirror) → inbox UI is the shortest honest path to a webapp that is *about* +herdr. Full TUI launch and window-verb parity can follow. + +--- + +## Part 5 — slices + +Phone v1 (this slice) is M-phone, not M2–M4. It does not wait on the Engine +interface extract. + +| # | Slice | Acceptance | +|---|---|---| +| M-phone | `mir up --engine herdr`, persist, bootstrap, HELLO.engine, hide tmux strip, Prefix `^B`. Launch the user's herdr (no private socket). | `mir up --engine bogus` errors; `--engine`+`--shell` refused; persisted herdr survives restart; missing herdr refuses (no tmux fallback); PWA hides +/‹/› on `engine: herdr`; `go test ./...` and `npm test` green | +| M0 | This spec. | — | +| M1 | E1 + E2 as already specified (tmux-only seam). | `go test ./...` and `npm test` green; `mir doctor` prints tmux caps | +| M2 | herdr engine: Probe, Snapshot from `session.snapshot`, Watch from `events.subscribe`. Empty snapshot → `workspace create`. Socket via `HERDR_SOCKET_PATH` only if Miranda is running a *private* herdr, which phone v1 does not. | CLI overview and web strip show herdr tabs; a `tab create` appears in the strip without polling | +| M3 | herdr `Mirror` (`observe`) and pane `control`. Agent copies ANSI frames onto `FrameData`. HELLO grows `caps`. | Phone-sized xterm shows one pane; observe confinement test from the engine spec (no inject, no other-pane leak). Second control without takeover fails with a named error | +| M4 | `st` on FrameWindows v2 (engine E7). Inbox UI in the SPA: cross-machine list, tap to M3. tmux machines have no inbox. | Old client + new agent: today's UI. New client + tmux: today's UI. New client + herdr: inbox with state. One vector added, none changed | +| M5 | Desktop-TUI coexistence: if a controller cannot resize (crop), the SPA stays on observe and says so. Takeover is a confirm sheet that names the engine. | Copy is tested; no silent crop of a 113-column desktop pane onto a 40-column phone | + +Order: **M-phone now** (it is the product). M1 can land independently. M2–M4 +are the inbox, after the phone can already reach herdr. + +Out of scope for this spec: herdr plugins, graphics, worktrees, `agent prompt` +from the phone, push notifications, rewriting the PWA in a framework, +contributing a web protocol upstream. + +--- + +## Open questions for Fredrik + +1. **Inbox as the herdr home screen, or keep the machine grid first?** + Recommendation: keep the grid (it is also the tmux home, and the pairing + empty state). Inbox is a second entry from the grid and from a top-level + "needs you" chip when any machine is `blocked`. +2. **Writable phone on a pane a desktop TUI is watching.** Recommendation: + observe by default, takeover behind a confirm. Never silently `--takeover`. +3. **Phone-home.** Unchanged: Miranda's herdr config template sets + `version_check` and `manifest_check` off. +4. **Ship M1 now** (pure refactor) **even if M2 waits?** Yes. The argv-equality + gate should die regardless of herdr. + +--- + +## Competitive note + +herdr 0.9 is a TUI mesh over SSH. Herdr Cloud is a waitlist for "connect any +machine, anywhere, through one Herdr account" with E2E encryption — the +sentence that is already Miranda's README. Moshi is a good phone terminal +for people who already SSH. The distribution play is unchanged and more +urgent: `mir up --engine herdr` is one line on a setup they have, and the +PWA is the client their 0.9 sidebar cannot be. + +Move while the door is open. Keep the seam symmetrical: herdr users should +reach Miranda as easily as herdr could later replace the relay. A seam that +only works one way is a dependency, not a strategy. diff --git a/go/internal/agent/guest.go b/go/internal/agent/guest.go index 0638248..8e0466f 100644 --- a/go/internal/agent/guest.go +++ b/go/internal/agent/guest.go @@ -118,8 +118,7 @@ func (rt *Runtime) serveGuestRO(ctx context.Context, mc peer.MsgConn, sess *nois defer sendMu.Unlock() return send(mc, sess, framed) } - hello, _ := json.Marshal(map[string]string{"name": rt.machineName() + " (shared, read-only)"}) - _ = safeSend(noise.EncodeHello(hello)) + _ = safeSend(noise.EncodeHello(helloJSON(rt.machineName()+" (shared, read-only)", EngineFromLaunch(rt.launch)))) // The scope names the session; tmux resolves it to that session's active // pane. (An exact-match "=name" works for session targets but not pane @@ -221,7 +220,7 @@ func (rt *Runtime) serveGuestRO(ctx context.Context, mc peer.MsgConn, sess *nois // a guest cannot be served rw there; refuse honestly. func (rt *Runtime) serveGuestRW(ctx context.Context, mc peer.MsgConn, sess *noise.Session, grant *identity.SignedGrant) error { if !isDefaultTmuxLaunch(rt.launch) { - _ = send(mc, sess, noise.EncodeHello(mustJSON(map[string]string{"name": rt.machineName()}))) + _ = send(mc, sess, noise.EncodeHello(helloJSON(rt.machineName(), EngineFromLaunch(rt.launch)))) _ = send(mc, sess, noise.EncodeData([]byte("\r\n[write sharing needs tmux on this machine — ask the owner]\r\n"))) <-ctx.Done() return nil @@ -237,7 +236,7 @@ func (rt *Runtime) serveGuestRW(ctx context.Context, mc peer.MsgConn, sess *nois defer pty.Close() defer killGroupedSession(name) - err = RunAgentSession(ctx, mc, sess, pty, rt.machineName()+" (shared)", nil, 0, nil) + err = RunAgentSession(ctx, mc, sess, pty, rt.machineName()+" (shared)", EngineFromLaunch(rt.launch), nil, 0, nil) if ctx.Err() != nil { _ = send(mc, sess, noise.EncodeData([]byte(endedByGrant(ctx)))) } diff --git a/go/internal/agent/rename.go b/go/internal/agent/rename.go index d5a2689..11015ff 100644 --- a/go/internal/agent/rename.go +++ b/go/internal/agent/rename.go @@ -171,6 +171,6 @@ func (rt *Runtime) renameHandler(owner string) ControlHandler { if rt.Logf != nil { rt.Logf("event=renamed name=%q", c.N) } - return true, map[string]string{"name": c.N} + return true, helloFields(c.N, EngineFromLaunch(rt.launch)) } } diff --git a/go/internal/agent/rename_test.go b/go/internal/agent/rename_test.go index 60fb184..ebaf087 100644 --- a/go/internal/agent/rename_test.go +++ b/go/internal/agent/rename_test.go @@ -191,7 +191,7 @@ func TestSessionRenameControlRepliesHello(t *testing.T) { done <- err return } - done <- RunAgentSession(ctx, agentMC, s, blockingShell{stop: ctx.Done()}, "old-name", nil, 0, control) + done <- RunAgentSession(ctx, agentMC, s, blockingShell{stop: ctx.Done()}, "old-name", "", nil, 0, control) }() cs, err := peer.RunInitiator(ctx, clientMC, clientPriv, agentPub) if err != nil { diff --git a/go/internal/agent/runtime.go b/go/internal/agent/runtime.go index b0b7480..a73ebe5 100644 --- a/go/internal/agent/runtime.go +++ b/go/internal/agent/runtime.go @@ -568,7 +568,7 @@ func (rt *Runtime) serveAuthenticated(ctx context.Context, mc peer.MsgConn, owne } windows = func() []byte { return tmuxSessionsJSON(pid, collapse) } } - return RunAgentSession(ctx, mc, sess, pty, rt.machineName(), windows, pid, chainControl(rt.renameHandler(owner), rt.grantHandler(owner), rt.revokeGrantHandler(owner))) + return RunAgentSession(ctx, mc, sess, pty, rt.machineName(), EngineFromLaunch(rt.launch), windows, pid, chainControl(rt.renameHandler(owner), rt.grantHandler(owner), rt.revokeGrantHandler(owner))) } // agentSignalURL builds ws(s)://host/agent/signal?owner_id=..&machine_id=.. diff --git a/go/internal/agent/session.go b/go/internal/agent/session.go index 6cc44e0..025e194 100644 --- a/go/internal/agent/session.go +++ b/go/internal/agent/session.go @@ -4,6 +4,7 @@ package agent import ( "context" "encoding/json" + "path/filepath" "sync" "github.com/srcful/terminal-relay/go/internal/noise" @@ -30,7 +31,7 @@ type ControlHandler func(payload []byte) (handled bool, helloAck map[string]stri // RunAgentSession bridges an established Noise session to a shell using the // Plan-1 frame protocol: it sends HELLO (machine name) once, then pumps DATA in // both directions and applies RESIZE. Returns when either side ends. -func RunAgentSession(ctx context.Context, mc peer.MsgConn, sess *noise.Session, sh Shell, machineName string, windowsJSON func() []byte, tmuxPid int, control ControlHandler) error { +func RunAgentSession(ctx context.Context, mc peer.MsgConn, sess *noise.Session, sh Shell, machineName, engine string, windowsJSON func() []byte, tmuxPid int, control ControlHandler) error { // noise.Session.Encrypt is not concurrency-safe (nonce counter), and several // goroutines now send (HELLO, shell->peer, the windows poller) — serialize. var sendMu sync.Mutex @@ -40,8 +41,7 @@ func RunAgentSession(ctx context.Context, mc peer.MsgConn, sess *noise.Session, return send(mc, sess, framed) } - hello, _ := json.Marshal(map[string]string{"name": machineName}) - if err := safeSend(noise.EncodeHello(hello)); err != nil { + if err := safeSend(noise.EncodeHello(helloJSON(machineName, engine))); err != nil { return err } @@ -147,3 +147,32 @@ func send(mc peer.MsgConn, sess *noise.Session, framed []byte) error { } return mc.Send(ct) } + +// helloJSON is the HELLO payload. engine is omitted when empty so a plain +// shell attach stays byte-identical to today's frame; a herdr attach adds +// "engine":"herdr" so the web client can hide the tmux window strip. +func helloJSON(name, engine string) []byte { + b, _ := json.Marshal(helloFields(name, engine)) + return b +} + +func helloFields(name, engine string) map[string]string { + meta := map[string]string{"name": name} + if engine != "" { + meta["engine"] = engine + } + return meta +} + +// EngineFromLaunch reports the engine name the web client should treat this +// attach as. Only "herdr" is special-cased today; tmux and custom --shell +// return empty so existing chrome stays. +func EngineFromLaunch(launch []string) string { + if len(launch) == 0 { + return "" + } + if filepath.Base(launch[0]) == "herdr" { + return "herdr" + } + return "" +} diff --git a/go/internal/agent/session_test.go b/go/internal/agent/session_test.go index 47b4e90..39cd255 100644 --- a/go/internal/agent/session_test.go +++ b/go/internal/agent/session_test.go @@ -39,7 +39,7 @@ func TestSessionBridgeRunsRealShellOverNoise(t *testing.T) { return } defer p.Close() - done <- RunAgentSession(ctx, agentMC, s, p, "test-machine", nil, 0, nil) + done <- RunAgentSession(ctx, agentMC, s, p, "test-machine", "", nil, 0, nil) }() bs, err := peer.RunInitiator(ctx, browserMC, browserPriv, agentPub) if err != nil { @@ -123,7 +123,7 @@ func TestSessionBridgeNoGoroutineLeakWhenShellExitsFirst(t *testing.T) { return } // Shell errors immediately => shell->peer goroutine returns first. - done <- RunAgentSession(ctx, agentMC, s, exitingShell{}, "test-machine", nil, 0, nil) + done <- RunAgentSession(ctx, agentMC, s, exitingShell{}, "test-machine", "", nil, 0, nil) }() if _, err := peer.RunInitiator(ctx, browserMC, browserPriv, agentPub); err != nil { @@ -174,3 +174,44 @@ func recvFrame(t *testing.T, ctx context.Context, mc peer.MsgConn, sess *noise.S } return pt } + +func TestHelloJSONOmitsEmptyEngine(t *testing.T) { + var meta map[string]string + if err := json.Unmarshal(helloJSON("box", ""), &meta); err != nil { + t.Fatal(err) + } + if meta["name"] != "box" { + t.Fatalf("name=%q", meta["name"]) + } + if _, ok := meta["engine"]; ok { + t.Fatalf("empty engine must be omitted, got %v", meta) + } +} + +func TestHelloJSONIncludesHerdr(t *testing.T) { + var meta map[string]string + if err := json.Unmarshal(helloJSON("box", "herdr"), &meta); err != nil { + t.Fatal(err) + } + if meta["engine"] != "herdr" || meta["name"] != "box" { + t.Fatalf("meta=%v", meta) + } +} + +func TestEngineFromLaunch(t *testing.T) { + cases := []struct { + launch []string + want string + }{ + {[]string{"tmux", "new", "-A", "-s", "main"}, ""}, + {[]string{"herdr"}, "herdr"}, + {[]string{"/home/you/.local/bin/herdr"}, "herdr"}, + {[]string{"sh"}, ""}, + {nil, ""}, + } + for _, tc := range cases { + if got := EngineFromLaunch(tc.launch); got != tc.want { + t.Errorf("EngineFromLaunch(%v)=%q, want %q", tc.launch, got, tc.want) + } + } +} diff --git a/go/internal/agent/store.go b/go/internal/agent/store.go index ded4978..d009735 100644 --- a/go/internal/agent/store.go +++ b/go/internal/agent/store.go @@ -15,13 +15,17 @@ import ( // Config is the agent's persisted identity + settings, stored as config.json. type Config struct { - HostPrivHex string `json:"host_priv"` // X25519 host private key (hex) - HostPubHex string `json:"host_pub"` // derived; convenience - MachineID string `json:"machine_id"` // random, stable - RegistrationSecret string `json:"registration_secret,omitempty"` // relay-side registration proof - MachineName string `json:"machine_name"` // human label (travels E2E only) - SignalURL string `json:"signal_url"` // e.g. http://localhost:8443 - PairedOwners []string `json:"paired_owners"` // base58 Miranda owner ids + HostPrivHex string `json:"host_priv"` // X25519 host private key (hex) + HostPubHex string `json:"host_pub"` // derived; convenience + MachineID string `json:"machine_id"` // random, stable + RegistrationSecret string `json:"registration_secret,omitempty"` // relay-side registration proof + MachineName string `json:"machine_name"` // human label (travels E2E only) + SignalURL string `json:"signal_url"` // e.g. http://localhost:8443 + // Engine is the persisted terminal engine for this machine: "tmux" + // (default), "herdr", or empty (treated as tmux). `mir up --engine` writes + // it so a herdr box stays herdr across restarts without repeating the flag. + Engine string `json:"engine,omitempty"` + PairedOwners []string `json:"paired_owners"` // base58 Miranda owner ids // OwnerRegistry contains opaque, owner-encrypted discovery records keyed by // owner id. They are created by the passkey-holding client during pairing. // The agent can republish them but cannot decrypt them. @@ -48,6 +52,27 @@ func ReloadOwners(dir string) ([]string, error) { func configPath(dir string) string { return filepath.Join(dir, "config.json") } +// SetEngine persists the terminal engine name ("tmux" or "herdr") without +// touching identity or pairing state. Unknown values are refused so a typo +// cannot stick. +func SetEngine(dir, engine string) error { + switch engine { + case "tmux", "herdr": + default: + return fmt.Errorf("unknown engine %q (known: tmux, herdr)", engine) + } + data, err := os.ReadFile(configPath(dir)) + if err != nil { + return err + } + cfg := &Config{} + if err := json.Unmarshal(data, cfg); err != nil { + return err + } + cfg.Engine = engine + return save(dir, cfg) +} + // LoadOrInit reads config.json from dir, creating a fresh host key, machine id, // and relay registration proof on first use. machineName/signalURL update the // stored values. diff --git a/go/internal/agent/store_test.go b/go/internal/agent/store_test.go index ffacfe1..c035b72 100644 --- a/go/internal/agent/store_test.go +++ b/go/internal/agent/store_test.go @@ -101,3 +101,37 @@ func TestPinOwnerPersists(t *testing.T) { t.Fatal("pinned owner did not persist") } } + +func TestSetEnginePersists(t *testing.T) { + dir := t.TempDir() + if _, err := LoadOrInit(dir, "box", "http://localhost:8443"); err != nil { + t.Fatal(err) + } + if err := SetEngine(dir, "herdr"); err != nil { + t.Fatal(err) + } + reloaded, err := LoadOrInit(dir, "box", "http://localhost:8443") + if err != nil { + t.Fatal(err) + } + if reloaded.Engine != "herdr" { + t.Fatalf("engine = %q, want herdr", reloaded.Engine) + } + if err := SetEngine(dir, "tmux"); err != nil { + t.Fatal(err) + } + reloaded, _ = LoadOrInit(dir, "box", "http://localhost:8443") + if reloaded.Engine != "tmux" { + t.Fatalf("engine = %q, want tmux", reloaded.Engine) + } +} + +func TestSetEngineRejectsUnknown(t *testing.T) { + dir := t.TempDir() + if _, err := LoadOrInit(dir, "box", "http://localhost:8443"); err != nil { + t.Fatal(err) + } + if err := SetEngine(dir, "zellij"); err == nil { + t.Fatal("expected unknown engine error") + } +} diff --git a/go/internal/cli/agent_cmds.go b/go/internal/cli/agent_cmds.go index fb6bd61..14992db 100644 --- a/go/internal/cli/agent_cmds.go +++ b/go/internal/cli/agent_cmds.go @@ -76,7 +76,8 @@ func (a *app) cmdUp(args []string) error { name := fs.String("name", hostname(), "machine display name") signalURL := fs.String("signal", defaults.SignalURL(), "signaling server base URL") webURL := fs.String("web", defaults.WebURL(), "browser SPA base URL the first-run pairing QR opens") - shell := fs.String("shell", "tmux:new:-A:-s:main", "launch command, ':'-separated (the default gives each viewer its own tmux view; a custom command attaches as-is)") + engine := fs.String("engine", "", "pty engine: tmux (default) or herdr") + shell := fs.String("shell", defaultTmuxShell, "launch command, ':'-separated (the default gives each viewer its own tmux view; a custom command attaches as-is). Mutually exclusive with --engine") ice := iceFlags(fs) autoUpdate := fs.Bool("auto-update", os.Getenv("MIR_AUTO_UPDATE") == "1", "opt-in: automatically self-update when idle") noLAN := fs.Bool("no-lan", false, "deprecated: no effect (one connection now carries direct and relayed)") @@ -85,6 +86,15 @@ func (a *app) cmdUp(args []string) error { confirmSAS := fs.String("confirm-sas", "", "first-run pairing, non-interactive: the expected safety number") yes := fs.Bool("yes", false, "first-run pairing, non-interactive: commit without comparing the safety number") _ = fs.Parse(args) + var engineSet, shellSet bool + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "engine": + engineSet = true + case "shell": + shellSet = true + } + }) if *noLAN { fmt.Fprintln(a.errOut, "note: --no-lan no longer does anything and will go away — LAN-direct now rides the same connection (direct when possible, relayed when not)") } @@ -99,15 +109,26 @@ func (a *app) cmdUp(args []string) error { if err != nil { return err } - launch := strings.Split(*shell, ":") + launch, persistEngine, notice, err := resolveLaunch(*engine, *shell, engineSet, shellSet, cfg.Engine) + if err != nil { + return err + } isTTY := term.IsTerminal(int(os.Stdin.Fd())) - // tmux bootstrap runs BEFORE pairing: on a fresh machine both need the - // user's attention, and asking everything up front (tmux, then the pairing - // QR) reads as one flow instead of two interruptions. - launch, err = newTmuxBootstrap(isTTY, a.in).ensure(a.out, a.errOut, launch) + // Engine bootstrap runs BEFORE pairing: on a fresh machine both need the + // user's attention, and asking everything up front (install, then the + // pairing QR) reads as one flow instead of two interruptions. A herdr + // request never falls back to tmux. + if isHerdrLaunch(launch) { + launch, err = newHerdrBootstrap(isTTY, a.in).ensure(a.out, a.errOut, launch) + } else { + launch, err = newTmuxBootstrap(isTTY, a.in).ensure(a.out, a.errOut, launch) + } if err != nil { return err } + if err := persistEngineAfterUp(*dir, persistEngine); err != nil { + return err + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -141,6 +162,9 @@ func (a *app) cmdUp(args []string) error { rlog := log.New(a.errOut, a.binary+": ", log.LstdFlags|log.LUTC) rt.Logf = rlog.Printf fmt.Fprintf(a.out, "%s up: machine %s, signaling %s\n", a.binary, cfg.MachineID, cfg.SignalURL) + if notice != "" { + fmt.Fprint(a.out, notice) + } // Non-blocking update notice (cache-only display; refresh in background while serving). updateClient(a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour) if *autoUpdate { diff --git a/go/internal/cli/cli.go b/go/internal/cli/cli.go index f1d4d1c..8b27e08 100644 --- a/go/internal/cli/cli.go +++ b/go/internal/cli/cli.go @@ -151,6 +151,7 @@ func (a *app) guide() { p("") p(" Serve a machine (on the box you want to reach):") p(" " + b + " up keep its tmux sessions reachable — first run shows a pairing QR") + p(" " + b + " up --engine herdr same, but attach the phone to this machine's herdr") p(" " + b + " pair add another owner later — prints a QR + safety number") p("") p(" Reach your machines (where you are):") diff --git a/go/internal/cli/doctor.go b/go/internal/cli/doctor.go index 4931159..ad39439 100644 --- a/go/internal/cli/doctor.go +++ b/go/internal/cli/doctor.go @@ -71,6 +71,7 @@ func (a *app) cmdDoctor(args []string) error { } else { d.ok("tmux is available") } + d.checkHerdrEngine(*agentDir) if *offline { d.warn("relay health check skipped (--offline)") } else { @@ -211,6 +212,24 @@ func (d *doctorReport) checkAgent(dir string) { } } +// checkHerdrEngine warns only on machines that asked for herdr. A tmux box +// must not be nagged that herdr is missing. +func (d *doctorReport) checkHerdrEngine(dir string) { + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + return + } + var cfg agent.Config + if json.Unmarshal(data, &cfg) != nil || cfg.Engine != "herdr" { + return + } + if _, ok := herdrOnPathOrLocal(exec.LookPath, os.UserHomeDir); !ok { + d.warn("herdr is not installed; this machine is set to engine herdr — run `%s`", herdrInstallHint) + return + } + d.ok("herdr is available") +} + func checkPrivateFile(d *doctorReport, path, label string) bool { info, err := os.Stat(path) if os.IsNotExist(err) { diff --git a/go/internal/cli/doctor_test.go b/go/internal/cli/doctor_test.go index fa31221..8d3ce9f 100644 --- a/go/internal/cli/doctor_test.go +++ b/go/internal/cli/doctor_test.go @@ -57,3 +57,46 @@ func TestDoctorRejectsLegacyPlaintextOwnerRoot(t *testing.T) { t.Fatalf("unexpected output stdout=%q stderr=%q", out.String(), errOut.String()) } } + +func TestDoctorWarnsWhenHerdrEngineBinaryMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + clientDir := t.TempDir() + agentDir := t.TempDir() + if _, err := client.LoadOrCreateIdentity(clientDir); err != nil { + t.Fatal(err) + } + if _, err := agent.LoadOrInit(agentDir, "box", "http://localhost"); err != nil { + t.Fatal(err) + } + if err := agent.SetEngine(agentDir, "herdr"); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + code := Run([]string{"doctor", "--client-dir", clientDir, "--agent-dir", agentDir, "--offline"}, &out, &errOut) + if code != 0 { + // missing tmux/herdr are warnings, not failures + t.Fatalf("exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + if !strings.Contains(out.String(), "herdr is not installed") { + t.Fatalf("expected a herdr warning:\n%s", out.String()) + } +} + +func TestDoctorDoesNotWarnHerdrOnTmuxEngine(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + clientDir := t.TempDir() + agentDir := t.TempDir() + if _, err := client.LoadOrCreateIdentity(clientDir); err != nil { + t.Fatal(err) + } + if _, err := agent.LoadOrInit(agentDir, "box", "http://localhost"); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + _ = Run([]string{"doctor", "--client-dir", clientDir, "--agent-dir", agentDir, "--offline"}, &out, &errOut) + if strings.Contains(out.String(), "engine herdr") || strings.Contains(out.String(), "herdr is not installed") { + t.Fatalf("tmux engine must not be nagged about herdr:\n%s", out.String()) + } +} diff --git a/go/internal/cli/engine.go b/go/internal/cli/engine.go new file mode 100644 index 0000000..6ed0ce5 --- /dev/null +++ b/go/internal/cli/engine.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/srcful/terminal-relay/go/internal/agent" +) + +const defaultTmuxShell = "tmux:new:-A:-s:main" + +// resolveLaunch picks the PTY argv and persisted engine from --engine / --shell +// and the last successful `mir up`. --engine and --shell together is an error: +// one is the product mode, the other is a raw argv override. +func resolveLaunch(engineFlag, shell string, engineSet, shellSet bool, persisted string) (launch []string, persistEngine string, notice string, err error) { + if engineSet && shellSet { + return nil, "", "", fmt.Errorf("--engine and --shell cannot be used together") + } + if engineSet { + switch strings.TrimSpace(engineFlag) { + case "herdr": + return []string{"herdr"}, "herdr", herdrPhoneNotice(), nil + case "tmux", "": + return strings.Split(defaultTmuxShell, ":"), "tmux", "", nil + default: + return nil, "", "", fmt.Errorf("unknown engine %q (known: tmux, herdr)", engineFlag) + } + } + if shellSet { + return strings.Split(shell, ":"), "", "", nil + } + if persisted == "herdr" { + return []string{"herdr"}, "herdr", herdrPhoneNotice(), nil + } + if shell == "" { + shell = defaultTmuxShell + } + return strings.Split(shell, ":"), "", "", nil +} + +func persistEngineAfterUp(dir, engine string) error { + if engine == "" { + return nil + } + return agent.SetEngine(dir, engine) +} + +func herdrPhoneNotice() string { + return strings.TrimSpace(` +herdr is the PTY on this machine. On the phone, open Miranda, tap this +machine, and you land in this herdr — same session as the desktop TUI. + +Miranda's window strip and mir share stay tmux-only. Switch machines +with the ⇄ list; switch herdr tabs with herdr's own prefix (Ctrl-B). +`) + "\n" +} diff --git a/go/internal/cli/engine_test.go b/go/internal/cli/engine_test.go new file mode 100644 index 0000000..a1a7d5e --- /dev/null +++ b/go/internal/cli/engine_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestResolveLaunch(t *testing.T) { + notice := herdrPhoneNotice() + cases := []struct { + name string + engineFlag string + shell string + engineSet bool + shellSet bool + persisted string + wantLaunch string + wantPersist string + wantNotice bool + wantErr string + }{ + { + name: "engine herdr", + engineFlag: "herdr", + engineSet: true, + wantLaunch: "herdr", + wantPersist: "herdr", + wantNotice: true, + }, + { + name: "engine tmux", + engineFlag: "tmux", + engineSet: true, + wantLaunch: "tmux:new:-A:-s:main", + wantPersist: "tmux", + }, + { + name: "engine unknown", + engineFlag: "zellij", + engineSet: true, + wantErr: `unknown engine "zellij" (known: tmux, herdr)`, + }, + { + name: "engine and shell together", + engineFlag: "herdr", + shell: "sh", + engineSet: true, + shellSet: true, + wantErr: "--engine and --shell cannot be used together", + }, + { + name: "shell only does not persist", + shell: "herdr", + shellSet: true, + persisted: "tmux", + wantLaunch: "herdr", + }, + { + name: "persisted herdr without flags", + shell: defaultTmuxShell, + persisted: "herdr", + wantLaunch: "herdr", + wantPersist: "herdr", + wantNotice: true, + }, + { + name: "default is tmux", + shell: defaultTmuxShell, + wantLaunch: "tmux:new:-A:-s:main", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + launch, persist, gotNotice, err := resolveLaunch(tc.engineFlag, tc.shell, tc.engineSet, tc.shellSet, tc.persisted) + if tc.wantErr != "" { + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.Join(launch, ":"); got != tc.wantLaunch { + t.Errorf("launch = %q, want %q", got, tc.wantLaunch) + } + if persist != tc.wantPersist { + t.Errorf("persist = %q, want %q", persist, tc.wantPersist) + } + if tc.wantNotice { + if gotNotice != notice { + t.Errorf("notice mismatch:\n%s", gotNotice) + } + } else if gotNotice != "" { + t.Errorf("notice = %q, want empty", gotNotice) + } + }) + } +} + +func TestHerdrPhoneNoticeNamesThePhonePath(t *testing.T) { + s := herdrPhoneNotice() + for _, want := range []string{"phone", "same session", "Ctrl-B", "tmux-only"} { + if !strings.Contains(s, want) { + t.Errorf("notice missing %q:\n%s", want, s) + } + } +} diff --git a/go/internal/cli/herdr_bootstrap.go b/go/internal/cli/herdr_bootstrap.go new file mode 100644 index 0000000..5350067 --- /dev/null +++ b/go/internal/cli/herdr_bootstrap.go @@ -0,0 +1,247 @@ +package cli + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + herdrManifestURL = "https://herdr.dev/latest.json" + herdrInstallHint = "curl -fsSL https://herdr.dev/install.sh | sh" + herdrWhy = "herdr is the multiplexer on this machine — Miranda will attach your phone to the same session." + herdrManifestMax = 2 << 20 // 2 MiB + herdrBinaryMax = 80 << 20 // 80 MiB + herdrHTTPTimeout = 120 * time.Second +) + +// herdrBootstrap decides whether `mir up --engine herdr` can launch herdr, +// offering to install it into ~/.local/bin when it is missing. Unlike tmux, +// a missing herdr never falls back to another engine — the user asked for +// herdr, and a silent tmux session would be a different product. OS/process +// dependencies are fields so the decision tree unit-tests without the +// network. +type herdrBootstrap struct { + lookPath func(string) (string, error) + home func() (string, error) + get func(url string, max int64) ([]byte, error) + goos string + goarch string + isTTY bool + in io.Reader +} + +func newHerdrBootstrap(isTTY bool, in io.Reader) *herdrBootstrap { + return &herdrBootstrap{ + lookPath: exec.LookPath, + home: os.UserHomeDir, + get: httpGetBytes, + goos: runtime.GOOS, + goarch: runtime.GOARCH, + isTTY: isTTY, + in: in, + } +} + +func isHerdrLaunch(launch []string) bool { + return len(launch) > 0 && filepath.Base(launch[0]) == "herdr" +} + +func herdrAssetKey(goos, goarch string) (string, error) { + var osName string + switch goos { + case "linux": + osName = "linux" + case "darwin": + osName = "macos" + default: + return "", fmt.Errorf("herdr has no binary for %s (Miranda agents target macOS and Linux)", goos) + } + var arch string + switch goarch { + case "amd64": + arch = "x86_64" + case "arm64": + arch = "aarch64" + default: + return "", fmt.Errorf("herdr has no binary for %s/%s", goos, goarch) + } + return osName + "-" + arch, nil +} + +// localHerdrPath is where the official installer and this bootstrap write the +// binary. Looked up even when that directory is not on PATH. +func localHerdrPath(home string) string { + return filepath.Join(home, ".local", "bin", "herdr") +} + +func herdrOnPathOrLocal(lookPath func(string) (string, error), home func() (string, error)) (string, bool) { + if p, err := lookPath("herdr"); err == nil && p != "" { + return p, true + } + if home == nil { + return "", false + } + h, err := home() + if err != nil || h == "" { + return "", false + } + p := localHerdrPath(h) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + return p, true + } + return "", false +} + +func (b *herdrBootstrap) find(argv0 string) string { + if filepath.IsAbs(argv0) { + if fi, err := os.Stat(argv0); err == nil && !fi.IsDir() { + return argv0 + } + } + if p, ok := herdrOnPathOrLocal(b.lookPath, b.home); ok { + return p + } + return "" +} + +func withHerdrBin(launch []string, path string) []string { + out := make([]string, len(launch)) + copy(out, launch) + out[0] = path + return out +} + +// ensure makes a herdr launch runnable. If herdr is already on PATH or in +// ~/.local/bin, launch is rewritten to that absolute path. Otherwise: +// - no TTY: refused with the official install one-liner. +// - TTY, user accepts: download latest.json, verify SHA-256, write +// ~/.local/bin/herdr. +// - TTY, declined or install failed: refused. Never fall back to tmux. +func (b *herdrBootstrap) ensure(out, errOut io.Writer, launch []string) ([]string, error) { + if !isHerdrLaunch(launch) { + return launch, nil + } + if path := b.find(launch[0]); path != "" { + return withHerdrBin(launch, path), nil + } + if !b.isTTY { + return nil, fmt.Errorf("herdr is not installed: run `%s`, then re-run `mir up --engine herdr`", herdrInstallHint) + } + + fmt.Fprintln(out, herdrWhy) + fmt.Fprintf(out, "Install herdr to ~/.local/bin with a checksum-verified download? [Y/n] ") + line, _ := bufio.NewReader(b.in).ReadString('\n') + if !installConsent(line) { + return nil, fmt.Errorf("herdr is not installed: run `%s`, then re-run `mir up --engine herdr`", herdrInstallHint) + } + + fmt.Fprintln(out, "Installing herdr…") + path, err := b.install(out, errOut) + if err != nil { + return nil, fmt.Errorf("herdr install failed: %w; run `%s` and re-run `mir up --engine herdr`", err, herdrInstallHint) + } + fmt.Fprintf(out, "herdr installed at %s\n", path) + return withHerdrBin(launch, path), nil +} + +type herdrManifest struct { + Version string `json:"version"` + Assets map[string]string `json:"assets"` + SHA256 map[string]string `json:"sha256"` +} + +func (b *herdrBootstrap) install(out, errOut io.Writer) (string, error) { + _ = errOut + key, err := herdrAssetKey(b.goos, b.goarch) + if err != nil { + return "", err + } + raw, err := b.get(herdrManifestURL, herdrManifestMax) + if err != nil { + return "", fmt.Errorf("fetch %s: %w", herdrManifestURL, err) + } + var man herdrManifest + if err := json.Unmarshal(raw, &man); err != nil { + return "", fmt.Errorf("parse latest.json: %w", err) + } + assetURL, ok := man.Assets[key] + if !ok || strings.TrimSpace(assetURL) == "" { + return "", fmt.Errorf("latest.json has no asset for %s", key) + } + want := strings.ToLower(strings.TrimSpace(man.SHA256[key])) + if len(want) != 64 { + return "", fmt.Errorf("latest.json has no SHA-256 for %s", key) + } + if man.Version != "" { + fmt.Fprintf(out, "downloading herdr %s (%s)…\n", man.Version, key) + } + bin, err := b.get(assetURL, herdrBinaryMax) + if err != nil { + return "", fmt.Errorf("download herdr: %w", err) + } + sum := sha256.Sum256(bin) + got := hex.EncodeToString(sum[:]) + if got != want { + return "", fmt.Errorf("checksum mismatch (got %s, want %s)", got, want) + } + home, err := b.home() + if err != nil { + return "", err + } + dest := localHerdrPath(home) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + tmp, err := os.CreateTemp(filepath.Dir(dest), "herdr-*.tmp") + if err != nil { + return "", err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(bin); err != nil { + tmp.Close() + return "", err + } + if err := tmp.Chmod(0o755); err != nil { + tmp.Close() + return "", err + } + if err := tmp.Close(); err != nil { + return "", err + } + if err := os.Rename(tmpName, dest); err != nil { + return "", err + } + return dest, nil +} + +func httpGetBytes(rawURL string, max int64) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + // Identity encoding: we hash the body, so a transparent gzip decode would + // make the digest disagree with the published SHA-256 of the file. + req.Header.Set("Accept-Encoding", "identity") + client := &http.Client{Timeout: herdrHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, max)) +} diff --git a/go/internal/cli/herdr_bootstrap_test.go b/go/internal/cli/herdr_bootstrap_test.go new file mode 100644 index 0000000..827c4df --- /dev/null +++ b/go/internal/cli/herdr_bootstrap_test.go @@ -0,0 +1,231 @@ +package cli + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHerdrAssetKey(t *testing.T) { + cases := []struct { + goos, goarch, want string + ok bool + }{ + {"linux", "amd64", "linux-x86_64", true}, + {"linux", "arm64", "linux-aarch64", true}, + {"darwin", "amd64", "macos-x86_64", true}, + {"darwin", "arm64", "macos-aarch64", true}, + {"windows", "amd64", "", false}, + {"linux", "386", "", false}, + } + for _, tc := range cases { + got, err := herdrAssetKey(tc.goos, tc.goarch) + if tc.ok { + if err != nil || got != tc.want { + t.Errorf("herdrAssetKey(%s,%s) = %q, %v; want %q", tc.goos, tc.goarch, got, err, tc.want) + } + continue + } + if err == nil { + t.Errorf("herdrAssetKey(%s,%s) = %q, want error", tc.goos, tc.goarch, got) + } + } +} + +func TestIsHerdrLaunch(t *testing.T) { + if !isHerdrLaunch([]string{"herdr"}) { + t.Fatal("herdr") + } + if !isHerdrLaunch([]string{"/home/you/.local/bin/herdr"}) { + t.Fatal("absolute herdr") + } + if isHerdrLaunch([]string{"tmux", "new"}) { + t.Fatal("tmux") + } + if isHerdrLaunch(nil) { + t.Fatal("nil") + } +} + +func TestHerdrEnsureSkipsNonHerdrLaunch(t *testing.T) { + b := &herdrBootstrap{lookPath: lookPathOnly()} + got, err := b.ensure(&bytes.Buffer{}, &bytes.Buffer{}, []string{"sh"}) + if err != nil || len(got) != 1 || got[0] != "sh" { + t.Fatalf("ensure(sh) = %v, %v", got, err) + } +} + +func TestHerdrEnsureUsesLookPath(t *testing.T) { + b := &herdrBootstrap{ + lookPath: lookPathOnly("herdr"), + home: func() (string, error) { return t.TempDir(), nil }, + get: func(string, int64) ([]byte, error) { + t.Fatal("must not download when herdr is on PATH") + return nil, nil + }, + } + got, err := b.ensure(&bytes.Buffer{}, &bytes.Buffer{}, []string{"herdr"}) + if err != nil { + t.Fatal(err) + } + if got[0] != "/usr/bin/herdr" { + t.Fatalf("launch = %v, want absolute PATH entry", got) + } +} + +func TestHerdrEnsureUsesLocalBinWhenNotOnPATH(t *testing.T) { + home := t.TempDir() + dest := localHerdrPath(home) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dest, []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + b := &herdrBootstrap{ + lookPath: lookPathOnly(), + home: func() (string, error) { return home, nil }, + get: func(string, int64) ([]byte, error) { + t.Fatal("must not download when ~/.local/bin/herdr exists") + return nil, nil + }, + } + got, err := b.ensure(&bytes.Buffer{}, &bytes.Buffer{}, []string{"herdr"}) + if err != nil { + t.Fatal(err) + } + if got[0] != dest { + t.Fatalf("launch = %v, want %s", got, dest) + } +} + +func TestHerdrEnsureNonTTYRefuses(t *testing.T) { + b := &herdrBootstrap{ + lookPath: lookPathOnly(), + home: func() (string, error) { return t.TempDir(), nil }, + isTTY: false, + } + _, err := b.ensure(&bytes.Buffer{}, &bytes.Buffer{}, []string{"herdr"}) + if err == nil || !strings.Contains(err.Error(), herdrInstallHint) { + t.Fatalf("expected the install-hint refusal, got %v", err) + } + if !strings.Contains(err.Error(), "herdr is not installed") { + t.Fatalf("got %v", err) + } + if strings.Contains(err.Error(), "tmux") || strings.Contains(err.Error(), "--shell sh") { + t.Fatalf("herdr refusal must not offer a tmux/sh fallback: %v", err) + } +} + +func TestHerdrEnsureTTYDeclineRefusesNoFallback(t *testing.T) { + out := &bytes.Buffer{} + b := &herdrBootstrap{ + lookPath: lookPathOnly(), + home: func() (string, error) { return t.TempDir(), nil }, + get: func(string, int64) ([]byte, error) { + t.Fatal("declined install must not download") + return nil, nil + }, + isTTY: true, + in: strings.NewReader("n\n"), + } + _, err := b.ensure(out, out, []string{"herdr"}) + if err == nil { + t.Fatal("decline must refuse, not fall back") + } + if !strings.Contains(err.Error(), herdrInstallHint) { + t.Fatalf("got %v", err) + } +} + +func TestHerdrEnsureTTYInstallsChecksumVerifiedBinary(t *testing.T) { + payload := []byte("herdr-binary-bytes") + sum := sha256.Sum256(payload) + hexSum := hex.EncodeToString(sum[:]) + manifest := fmt.Sprintf(`{"version":"0.9.0","assets":{"linux-x86_64":"https://example.test/herdr"},"sha256":{"linux-x86_64":%q}}`, hexSum) + home := t.TempDir() + var fetched []string + out := &bytes.Buffer{} + b := &herdrBootstrap{ + lookPath: lookPathOnly(), + home: func() (string, error) { return home, nil }, + get: func(url string, max int64) ([]byte, error) { + fetched = append(fetched, url) + switch url { + case herdrManifestURL: + return []byte(manifest), nil + case "https://example.test/herdr": + return payload, nil + default: + return nil, fmt.Errorf("unexpected GET %s", url) + } + }, + goos: "linux", + goarch: "amd64", + isTTY: true, + in: strings.NewReader("y\n"), + } + got, err := b.ensure(out, io.Discard, []string{"herdr"}) + if err != nil { + t.Fatal(err) + } + want := localHerdrPath(home) + if got[0] != want { + t.Fatalf("launch = %v, want %s", got, want) + } + body, err := os.ReadFile(want) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(body, payload) { + t.Fatalf("installed bytes = %q", body) + } + st, err := os.Stat(want) + if err != nil { + t.Fatal(err) + } + if perm := st.Mode().Perm(); perm&0o111 == 0 { + t.Fatalf("installed herdr is not executable: %o", perm) + } + if len(fetched) != 2 || fetched[0] != herdrManifestURL { + t.Fatalf("fetches = %v", fetched) + } + if !strings.Contains(out.String(), "herdr 0.9.0") { + t.Fatalf("expected version in output:\n%s", out.String()) + } +} + +func TestHerdrEnsureChecksumMismatchRefuses(t *testing.T) { + manifest := `{"version":"0.9.0","assets":{"linux-x86_64":"https://example.test/herdr"},"sha256":{"linux-x86_64":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}` + b := &herdrBootstrap{ + lookPath: lookPathOnly(), + home: func() (string, error) { return t.TempDir(), nil }, + get: func(url string, max int64) ([]byte, error) { + if url == herdrManifestURL { + return []byte(manifest), nil + } + return []byte("tampered"), nil + }, + goos: "linux", + goarch: "amd64", + isTTY: true, + in: strings.NewReader("\n"), + } + _, err := b.ensure(&bytes.Buffer{}, &bytes.Buffer{}, []string{"herdr"}) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected checksum refusal, got %v", err) + } +} + +func TestNewHerdrBootstrapWiresRealDependencies(t *testing.T) { + b := newHerdrBootstrap(false, strings.NewReader("")) + if b.lookPath == nil || b.home == nil || b.get == nil || b.goos == "" || b.goarch == "" { + t.Fatalf("newHerdrBootstrap left a field unset: %+v", b) + } +} diff --git a/go/internal/cli/up_pair_test.go b/go/internal/cli/up_pair_test.go index 2f45e4c..86685c6 100644 --- a/go/internal/cli/up_pair_test.go +++ b/go/internal/cli/up_pair_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http/httptest" "os" + "path/filepath" "strings" "syscall" "testing" @@ -205,3 +206,113 @@ func TestPairOnFirstRunAbortsOnRefusal(t *testing.T) { t.Fatalf("refused pairing must pin nothing, got %v", owners) } } + +func stubHerdrOnPATH(t *testing.T) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "herdr"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + t.Setenv("HOME", t.TempDir()) +} + +func TestUpRejectsUnknownEngine(t *testing.T) { + a := &app{binary: "mir", out: io.Discard, errOut: io.Discard} + err := a.cmdUp([]string{"--dir", t.TempDir(), "--engine", "bogus", "--no-lan"}) + if err == nil || !strings.Contains(err.Error(), `unknown engine "bogus"`) { + t.Fatalf("got %v", err) + } +} + +func TestUpRejectsEngineAndShellTogether(t *testing.T) { + a := &app{binary: "mir", out: io.Discard, errOut: io.Discard} + err := a.cmdUp([]string{"--dir", t.TempDir(), "--engine", "herdr", "--shell", "sh", "--no-lan"}) + if err == nil || !strings.Contains(err.Error(), "--engine and --shell cannot be used together") { + t.Fatalf("got %v", err) + } +} + +func TestUpEngineHerdrPersistsBeforePairing(t *testing.T) { + stubHerdrOnPATH(t) + dir := t.TempDir() + a := &app{binary: "mir", out: io.Discard, errOut: io.Discard} + err := a.cmdUp([]string{"--dir", dir, "--signal", "http://127.0.0.1:1", + "--engine", "herdr", "--no-lan", "--no-pair"}) + if err == nil || !strings.Contains(err.Error(), "no paired owner") { + t.Fatalf("expected the no-paired-owner refusal after persist, got %v", err) + } + cfg, err := agent.LoadOrInit(dir, "box", "http://127.0.0.1:1") + if err != nil { + t.Fatal(err) + } + if cfg.Engine != "herdr" { + t.Fatalf("engine = %q, want herdr", cfg.Engine) + } +} + +func TestUpShellHerdrDoesNotPersistEngine(t *testing.T) { + stubHerdrOnPATH(t) + dir := t.TempDir() + if _, err := agent.LoadOrInit(dir, "box", "http://127.0.0.1:1"); err != nil { + t.Fatal(err) + } + a := &app{binary: "mir", out: io.Discard, errOut: io.Discard} + err := a.cmdUp([]string{"--dir", dir, "--signal", "http://127.0.0.1:1", + "--shell", "herdr", "--no-lan", "--no-pair"}) + if err == nil || !strings.Contains(err.Error(), "no paired owner") { + t.Fatalf("got %v", err) + } + cfg, err := agent.LoadOrInit(dir, "box", "http://127.0.0.1:1") + if err != nil { + t.Fatal(err) + } + if cfg.Engine != "" { + t.Fatalf("--shell must not persist engine, got %q", cfg.Engine) + } +} + +func TestUpPersistedHerdrDoesNotRequireTmux(t *testing.T) { + stubHerdrOnPATH(t) + dir := t.TempDir() + if _, err := agent.LoadOrInit(dir, "box", "http://127.0.0.1:1"); err != nil { + t.Fatal(err) + } + if err := agent.SetEngine(dir, "herdr"); err != nil { + t.Fatal(err) + } + a := &app{binary: "mir", out: io.Discard, errOut: io.Discard} + err := a.cmdUp([]string{"--dir", dir, "--signal", "http://127.0.0.1:1", "--no-lan", "--no-pair"}) + if err == nil || !strings.Contains(err.Error(), "no paired owner") { + t.Fatalf("persisted herdr should reach pairing/serve, got %v", err) + } + if strings.Contains(err.Error(), "tmux") { + t.Fatalf("persisted herdr must not demand tmux: %v", err) + } +} + +func TestUpEngineHerdrMissingRefusesNonInteractive(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + out := &safeBuf{} + a := &app{binary: "mir", out: out, errOut: io.Discard, in: strings.NewReader("")} + err := a.cmdUp([]string{"--dir", dir, "--signal", "http://127.0.0.1:1", + "--engine", "herdr", "--no-lan"}) + if err == nil || !strings.Contains(err.Error(), "herdr is not installed") { + t.Fatalf("got %v", err) + } + if !strings.Contains(err.Error(), herdrInstallHint) { + t.Fatalf("refusal must carry the install command, got: %v", err) + } + if strings.Contains(out.String(), "First run") || strings.Contains(out.String(), "safety number") { + t.Fatalf("herdr bootstrap must run before pairing:\n%s", out.String()) + } + cfg, loadErr := agent.LoadOrInit(dir, "box", "http://127.0.0.1:1") + if loadErr != nil { + t.Fatal(loadErr) + } + if cfg.Engine != "" { + t.Fatalf("failed herdr install must not persist engine, got %q", cfg.Engine) + } +} diff --git a/web/src/app.js b/web/src/app.js index cd03e23..9122c3f 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -22,6 +22,7 @@ import { signBinding, recordJSON } from './identity/binding.js'; import { signAuth, attachChallenge } from './identity/auth.js'; import { filterRevoked, isMachineRevoked, loadRevocations, revokeMachine, syncRevocations } from './revocations.js'; import { makeKeybar, shouldShowKeybar } from './ui/keybar.js'; +import { applyHello, hideTmuxStrip } from './ui/hello.js'; import jsQR from '/vendor/jsqr.js'; const te = new TextEncoder(); @@ -247,7 +248,8 @@ export async function connectOnce(machine, term, current, onConnected, onWindows else if (type === FRAME_WINDOWS) { try { onWindows && onWindows(JSON.parse(td.decode(payload))); } catch {} } // A HELLO after the first one is the agent re-announcing itself — today // that means a machine rename (ours: the acknowledgement; another - // device's: a live update). + // device's: a live update). engine, if present, is sticky: a name-only + // rename HELLO must not drop a herdr attach back to tmux chrome. else if (type === FRAME_HELLO) { try { onHello && onHello(JSON.parse(td.decode(payload))); } catch {} } } } catch (e) { @@ -377,9 +379,9 @@ function startLoop(sess) { ping(sess); }, // A late HELLO is a rename: ours (the ack) or another device's (live - // update). The store is only written on OUR rename (we know its sealed - // ts); here the session's name just follows the machine. - (meta) => { if (meta && meta.name) { sess.machine = { ...sess.machine, name: meta.name }; ping(sess); } }), + // update). engine is sticky so a name-only HELLO cannot un-hide the + // tmux strip on a herdr attach. + (meta) => { sess.machine = applyHello(sess.machine, meta); ping(sess); }), onState: (state, attempt) => { sess.attempt = attempt || 0; if (state === 'connected') { @@ -990,6 +992,11 @@ function viewTerminal(root, machineToOpen) { // grid overview. Falls back to +/‹/› before any snapshot (or non-tmux shells). function renderStrip() { strip.replaceChildren(); + if (hideTmuxStrip(m().engine)) { + strip.hidden = true; + return; + } + strip.hidden = false; const sess = sessionsView(); const cur = sess && (sess.find((s) => s.act) || sess[0]); if (!cur || !cur.win || !cur.win.length) { diff --git a/web/src/ui/hello.js b/web/src/ui/hello.js new file mode 100644 index 0000000..51bc029 --- /dev/null +++ b/web/src/ui/hello.js @@ -0,0 +1,22 @@ +// web/src/ui/hello.js — HELLO payload helpers. +// +// The agent sends {"name":"…"} on tmux/plain shells and +// {"name":"…","engine":"herdr"} on a herdr PTY. Old clients ignore unknown +// fields. A later rename HELLO may omit engine; we must not wipe the one +// we already learned. + +export function applyHello(machine, meta) { + if (!machine) return machine; + if (!meta || typeof meta !== 'object') return machine; + const name = typeof meta.name === 'string' && meta.name ? meta.name : ''; + const engine = typeof meta.engine === 'string' && meta.engine ? meta.engine : ''; + if (!name && !engine) return machine; + const next = { ...machine }; + if (name) next.name = name; + if (engine) next.engine = engine; + return next; +} + +export function hideTmuxStrip(engine) { + return engine === 'herdr'; +} diff --git a/web/src/ui/keybar.js b/web/src/ui/keybar.js index 8d07bed..e06ff86 100644 --- a/web/src/ui/keybar.js +++ b/web/src/ui/keybar.js @@ -41,6 +41,7 @@ export function ctrlByte(ch) { export const KEY_BYTES = { esc: new Uint8Array([0x1b]), tab: new Uint8Array([0x09]), + prefix: new Uint8Array([0x02]), // Ctrl-B — herdr and tmux share this prefix up: new Uint8Array([0x1b, 0x5b, 0x41]), // ESC [ A down: new Uint8Array([0x1b, 0x5b, 0x42]), // ESC [ B right: new Uint8Array([0x1b, 0x5b, 0x43]), // ESC [ C @@ -101,6 +102,7 @@ export function shouldShowKeybar(mm = (typeof window !== 'undefined' ? window.ma const KEYS = [ ['esc', 'Esc'], ['ctrl', 'Ctrl'], + ['prefix', '^B'], ['tab', 'Tab'], ['up', '↑'], ['down', '↓'], @@ -143,7 +145,7 @@ export function makeKeybar(sendRaw, focus) { focus && focus(); }; - const arrowAria = { up: 'up arrow', down: 'down arrow', left: 'left arrow', right: 'right arrow' }; + const arrowAria = { up: 'up arrow', down: 'down arrow', left: 'left arrow', right: 'right arrow', prefix: 'prefix (Ctrl-B)' }; for (const [token, label] of KEYS) { const b = doc.createElement('button'); b.className = 'keybar-btn' + (token === 'ctrl' ? ' keybar-ctrl' : ''); diff --git a/web/sw.js b/web/sw.js index eaee66f..7d3adc4 100644 --- a/web/sw.js +++ b/web/sw.js @@ -13,7 +13,7 @@ // relay serves sw.js no-store — so a new deploy replaces this worker on the // next online load. -const CACHE = 'mir-shell-v6'; +const CACHE = 'mir-shell-v7'; // Everything the app needs to boot. test/sw.test.js fails if this list drifts // from the files on disk — add new modules here when you add them to src/. @@ -52,6 +52,7 @@ const SHELL = [ '/src/revocations.js', '/src/rp.js', '/src/store.js', + '/src/ui/hello.js', '/src/ui/keybar.js', '/src/wallet/base58.js', '/src/wallet/bip39.js', diff --git a/web/test/hello.test.js b/web/test/hello.test.js new file mode 100644 index 0000000..c237c14 --- /dev/null +++ b/web/test/hello.test.js @@ -0,0 +1,31 @@ +// web/test/hello.test.js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyHello, hideTmuxStrip } from '../src/ui/hello.js'; + +test('applyHello sets name and engine from the first HELLO', () => { + const m = applyHello({ machine_id: 'abc', name: 'old' }, { name: 'box', engine: 'herdr' }); + assert.equal(m.name, 'box'); + assert.equal(m.engine, 'herdr'); + assert.equal(m.machine_id, 'abc'); +}); + +test('applyHello keeps engine when a later HELLO is name-only', () => { + const m = applyHello({ name: 'box', engine: 'herdr' }, { name: 'renamed' }); + assert.equal(m.name, 'renamed'); + assert.equal(m.engine, 'herdr'); +}); + +test('applyHello ignores empty payloads', () => { + const orig = { name: 'box', engine: 'herdr' }; + assert.equal(applyHello(orig, null), orig); + assert.equal(applyHello(orig, {}), orig); + assert.deepEqual(applyHello(orig, { name: '', engine: '' }), orig); +}); + +test('hideTmuxStrip is herdr-only', () => { + assert.equal(hideTmuxStrip('herdr'), true); + assert.equal(hideTmuxStrip(''), false); + assert.equal(hideTmuxStrip(undefined), false); + assert.equal(hideTmuxStrip('tmux'), false); +}); diff --git a/web/test/keybar.test.js b/web/test/keybar.test.js index bd120d2..959fb0c 100644 --- a/web/test/keybar.test.js +++ b/web/test/keybar.test.js @@ -44,6 +44,7 @@ test('ctrlByte returns null for characters with no control mapping', () => { test('named keys emit the exact terminal byte sequences', () => { assert.deepEqual(KEY_BYTES.esc, new Uint8Array([0x1b])); assert.deepEqual(KEY_BYTES.tab, new Uint8Array([0x09])); + assert.deepEqual(KEY_BYTES.prefix, new Uint8Array([0x02])); // Ctrl-B assert.deepEqual(KEY_BYTES.up, new Uint8Array([0x1b, 0x5b, 0x41])); // ESC [ A assert.deepEqual(KEY_BYTES.down, new Uint8Array([0x1b, 0x5b, 0x42])); // ESC [ B assert.deepEqual(KEY_BYTES.right, new Uint8Array([0x1b, 0x5b, 0x43])); // ESC [ C @@ -60,6 +61,7 @@ test('arrow sequences decode to the canonical ANSI escapes', () => { test('keyBytes resolves named keys and literal characters to bytes', () => { assert.deepEqual(keyBytes('esc'), new Uint8Array([0x1b])); assert.deepEqual(keyBytes('tab'), new Uint8Array([0x09])); + assert.deepEqual(keyBytes('prefix'), new Uint8Array([0x02])); assert.deepEqual(keyBytes('up'), new Uint8Array([0x1b, 0x5b, 0x41])); // literal extras are UTF-8 encoded assert.deepEqual(keyBytes('|'), new Uint8Array([0x7c])); diff --git a/web/test/sw.test.js b/web/test/sw.test.js index 4519b6f..a9fa339 100644 --- a/web/test/sw.test.js +++ b/web/test/sw.test.js @@ -102,7 +102,7 @@ test('network-first: serves the fresh response and rewrites the cache', async () request: getRequest('/src/app.js'), }); assert.equal(await response.text(), 'shell:/src/app.js'); - assert.equal(await stores.get('mir-shell-v6').get('/src/app.js').clone().text(), 'shell:/src/app.js'); + assert.equal(await stores.get('mir-shell-v7').get('/src/app.js').clone().text(), 'shell:/src/app.js'); }); test('relay unreachable: falls back to the cached shell', async () => {