From b1330f4a47886cf6316f4d28880e371fbc221458 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 11 Sep 2026 05:27:17 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs(spec):=20herdr-aware=20mobile=20webapp?= =?UTF-8?q?=20=E2=80=94=20inbox,=20not=20a=20TUI=20dump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit herdr 0.9.0 orchestrates machines in one TUI over SSH and still has no phone client. Re-measure observe/control on 0.9.0, specify the existing PWA as an agent inbox across paired machines, and keep the engine seam as the prerequisite rather than painting herdr into xterm. Co-authored-by: Fredrik Ahlgren --- docs/status.md | 116 +++--- .../2026-08-31-engine-seam-herdr-design.md | 9 + .../2026-09-11-herdr-mobile-webapp-design.md | 375 ++++++++++++++++++ 3 files changed, 450 insertions(+), 50 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md diff --git a/docs/status.md b/docs/status.md index ebcea00..1b989d7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,11 +1,12 @@ # 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 +**Next goal: herdr-aware mobile webapp** (agent inbox across paired machines). +The engine seam is the prerequisite, not the product. 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,65 @@ 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`. +**Build the herdr-aware mobile webapp.** Spec: +[`docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md`](superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md). +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)) +is still the prerequisite; its E1/E2 slices (tmux-only) land first. The +webapp spec revises what *then* gets built: an agent inbox on the existing +PWA, not `--shell herdr` painted into xterm. + +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 +112,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..44017b8 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,15 @@ > 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). +> That spec uses pane observe/control as the web v1 attach, not a herdr TUI +> dumped into xterm. 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..d1a2641 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md @@ -0,0 +1,375 @@ +# Herdr-aware mobile webapp — the inbox herdr does not have + +> Investigation + design; no build. 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. + +## One sentence + +Miranda already ships the mobile client herdr refused to build; the webapp +should become an **agent inbox across paired machines**, not a phone-sized +dump of herdr's TUI. + +## 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 + +| # | Slice | Acceptance | +|---|---|---| +| M0 | This spec. No code. | — | +| 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`. Read-only. | 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 `engine` + `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 | +| M6 | Bootstrap: `mir up --engine herdr` (engine E8). | Clean machine: prompt → install → up → inbox on the PWA | + +Order M0 → M1 → M2 → M3 → M4. M5 can land with M3 if the crop re-measure is +cheap. M6 can parallel M4. + +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. From c9af79057c9096154c85250f784626cef0316adb Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 11 Sep 2026 05:54:52 +0000 Subject: [PATCH 2/3] feat: attach the phone to the machine's herdr mir up --engine herdr launches the user's existing herdr session (no private socket), persists the engine, and tells the PWA via HELLO so the tmux window strip stays hidden. Missing herdr refuses instead of falling back to tmux. The mobile keybar gains Prefix (^B). Co-authored-by: Fredrik Ahlgren --- README.md | 17 +- docs/status.md | 19 +- .../2026-08-31-engine-seam-herdr-design.md | 7 +- .../2026-09-11-herdr-mobile-webapp-design.md | 48 +++- go/internal/agent/guest.go | 7 +- go/internal/agent/rename.go | 2 +- go/internal/agent/rename_test.go | 2 +- go/internal/agent/runtime.go | 2 +- go/internal/agent/session.go | 35 ++- go/internal/agent/session_test.go | 45 +++- go/internal/agent/store.go | 39 ++- go/internal/agent/store_test.go | 34 +++ go/internal/cli/agent_cmds.go | 36 ++- go/internal/cli/cli.go | 1 + go/internal/cli/doctor.go | 19 ++ go/internal/cli/doctor_test.go | 43 +++ go/internal/cli/engine.go | 56 ++++ go/internal/cli/engine_test.go | 108 ++++++++ go/internal/cli/herdr_bootstrap.go | 247 ++++++++++++++++++ go/internal/cli/herdr_bootstrap_test.go | 231 ++++++++++++++++ go/internal/cli/up_pair_test.go | 111 ++++++++ web/src/app.js | 15 +- web/src/ui/hello.js | 19 ++ web/src/ui/keybar.js | 4 +- web/sw.js | 3 +- web/test/hello.test.js | 30 +++ web/test/keybar.test.js | 2 + web/test/sw.test.js | 2 +- 28 files changed, 1124 insertions(+), 60 deletions(-) create mode 100644 go/internal/cli/engine.go create mode 100644 go/internal/cli/engine_test.go create mode 100644 go/internal/cli/herdr_bootstrap.go create mode 100644 go/internal/cli/herdr_bootstrap_test.go create mode 100644 web/src/ui/hello.js create mode 100644 web/test/hello.test.js 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 1b989d7..68904f8 100644 --- a/docs/status.md +++ b/docs/status.md @@ -5,9 +5,9 @@ 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: herdr-aware mobile webapp** (agent inbox across paired machines). -The engine seam is the prerequisite, not the product. 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 @@ -39,14 +39,15 @@ Shipped over 2026-08-29/30, all merged and released: ## What comes next -**Build the herdr-aware mobile webapp.** Spec: -[`docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md`](superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md). -The engine seam +**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)) -is still the prerequisite; its E1/E2 slices (tmux-only) land first. The -webapp spec revises what *then* gets built: an agent inbox on the existing -PWA, not `--shell herdr` painted into xterm. +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 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 44017b8..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 @@ -10,9 +10,10 @@ > `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). -> That spec uses pane observe/control as the web v1 attach, not a herdr TUI -> dumped into xterm. This document remains the `Engine` interface and the -> tmux coupling map. +> 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 index d1a2641..6907407 100644 --- a/docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md +++ b/docs/superpowers/specs/2026-09-11-herdr-mobile-webapp-design.md @@ -1,16 +1,39 @@ -# Herdr-aware mobile webapp — the inbox herdr does not have +# Herdr-aware mobile webapp — run every herdr from the phone -> Investigation + design; no build. Grounded in herdr **0.9.0** (protocol 22, +> 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; the webapp -should become an **agent inbox across paired machines**, not a phone-sized -dump of herdr's TUI. +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 @@ -327,18 +350,21 @@ 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 | |---|---|---| -| M0 | This spec. No code. | — | +| 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`. Read-only. | 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 `engine` + `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 | +| 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 | -| M6 | Bootstrap: `mir up --engine herdr` (engine E8). | Clean machine: prompt → install → up → inbox on the PWA | -Order M0 → M1 → M2 → M3 → M4. M5 can land with M3 if the crop re-measure is -cheap. M6 can parallel M4. +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, 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..94b20f1 --- /dev/null +++ b/web/src/ui/hello.js @@ -0,0 +1,19 @@ +// 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 next = { ...machine }; + if (typeof meta.name === 'string' && meta.name) next.name = meta.name; + if (typeof meta.engine === 'string' && meta.engine) next.engine = meta.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..df04472 --- /dev/null +++ b/web/test/hello.test.js @@ -0,0 +1,30 @@ +// 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); +}); + +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 () => { From b99d4a2f0a9d8f124e43764d3cae87ddee242754 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 11 Sep 2026 05:57:19 +0000 Subject: [PATCH 3/3] fix: keep the machine object when HELLO has no new fields Empty HELLO payloads must not clone the session machine, so a later rename cannot look like an engine change. Co-authored-by: Fredrik Ahlgren --- web/src/ui/hello.js | 7 +++++-- web/test/hello.test.js | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/ui/hello.js b/web/src/ui/hello.js index 94b20f1..51bc029 100644 --- a/web/src/ui/hello.js +++ b/web/src/ui/hello.js @@ -8,9 +8,12 @@ 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 (typeof meta.name === 'string' && meta.name) next.name = meta.name; - if (typeof meta.engine === 'string' && meta.engine) next.engine = meta.engine; + if (name) next.name = name; + if (engine) next.engine = engine; return next; } diff --git a/web/test/hello.test.js b/web/test/hello.test.js index df04472..c237c14 100644 --- a/web/test/hello.test.js +++ b/web/test/hello.test.js @@ -20,6 +20,7 @@ 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', () => {