a73x

docs/superpowers/specs/2026-09-01-e2e-dedupe-and-vm-gate-design.md

Ref:   Size: 6.8 KiB   History

# e2e harness: dedupe, and a real-VM gate

2026-09-01. Two improvements to the test harness, independent enough to land
separately, ordered so the cheap one goes first.

## Why now

The retro record names the costs this design answers:

- Two askpass legs went green about a prompt that was never raised, because
  the ssh *shim* is an assumption wearing a test's clothes (2026-08-29,
  twice). "Assert behavior, don't assume it. Mocks are assumptions" — the
  shim is the one standing mock in the suite.
- Four group files each hand-build the same shim rig; ~123 sites spell the
  same four-line rc-capture idiom; ~57 ptyclient scripts end in the same
  detach tail. Every copy is a place a fix has to be found.
- `wan.sh` proves the real-remote harness pattern works but is hand-run
  against pet boxes, outside every gate.

## Part 1 — dedupe (no behavior change)

As designed: three lib helpers. As measured on contact (2026-09-01): one
survived. The scenario pins (105/38 in `test/e2e.sh`) do not move; that
they do not move is the delivery check that this part changed nothing.

**1. `ssh_shim_rig DIR` — DROPPED.** Reading all five shim bodies showed
the common factor is already extracted: `ssh_shim_head` IS it, done
deliberately. The bodies differ in exactly what each group pins — 04 logs
pid/comm/args, 09 remaps a runtime dir per fake box, 09's second shim
fakes unreachable-with-a-reason, 16 remaps a whole HOME — and 15 refuses
the shared head on purpose, with a comment saying why (its subject is the
options the head discards). A rig helper would have served one caller and
blurred the other four.

**2. `rc0 "message" [FILE...]`** (renamed from the spec's `must`, reshaped
to read the `RC` the enclosing `set +e … RC=$? … set -e` capture already
holds — a wrapper taking `cmd args...` cannot carry the heredocs most
sites feed ptyclient). Fails the suite printing the caller's own prose and
the named capture files. It appends no "(rc N)" suffix: the messages carry
`$RC` in bespoke positions, some with rc legends ("134 = panic"). 58 of
the 82 plain `-eq 0` sites converted; the ~24 that compare RC to specific
values, kill something, or dump state on the way out keep their bespoke
form, and that boundary is the point — the helper covers the shape, never
the judgment.

**3. A shared detach tail — DROPPED.** The heredocs are quote-protected so
`$$` and `$?` reach the REMOTE shell literally; interpolating a shared
tail means unquoting every script, which breaks every marker. Two clear
lines per site beat that trade.

Conversion gate: full `make e2e` after each group file converts (the suite is
linear and stateful; `E2E_ONLY` cannot vouch for a group's neighbors), and
`make ci` before delivery.

## Part 2 — `make vm`: user journeys against a real box

A new `test/vm.sh` and Makefile target. It is a delivery-ritual gate beside
`make ci`, never inside it: `make ci` stays hermetic and offline. The e2e ssh
shims stay — they are the fast local pins; `make vm` is where their
assumptions get audited against a real sshd, a real network, a real Ubuntu.

### Fixture

One long-lived VM, like a real user's server:

- **`mux-e2e`** on the eitri fleet, pinned to host **charizard** (this
  machine), x86_64, 2 vCPU / 2 GB / 10 GB, underlay address `10.78.5.3`.
- Reachable by plain non-interactive ssh as `ubuntu@10.78.5.3` — proven
  2026-09-01: cert auth, no gate hop, `mux d endpoint` on the mux-less box
  answers rc 127.
- Auth is pinned by a `~/.ssh/config` Host block for `10.78.5.3` naming the
  harness identity (`~/.config/mux-vm/vm_key` + its 5-year cert, CA
  registered with the tenant as `mux-vm-gate`), so the gate never depends on
  the freshness of anyone's interactive cert.
- The hosts-file spelling the journeys use is `ubuntu@10.78.5.3` —
  `[user@]host` is in `hosts.zig`'s grammar.

Hermeticity comes from a **scorched-earth reset inside the VM** at the top of
every run — kill every mux process, remove the installed binary and all four
XDG dirs — not from re-creating the VM. The client side runs on this box
under `ptyclient` with every XDG dir isolated to scratch, per the standing
rig rule.

`vm.sh` preflights `ssh ubuntu@10.78.5.3 true` and REFUSES with the recovery
recipe when it fails (VM gone: recreate on charizard and re-read the underlay
IP; cert stale: re-sign against `~/.config/mux-vm/vm_ca`). A missing VM is a
loud failure, never a skip — the same stance e2e.sh takes on nvim and curl:
a gate that can quietly not run is the one failure a green tree cannot show.

### Journeys, v1

Each claim about the VM is asserted by a separate ssh reading `/proc` or the
filesystem there — never by the daemon's own report. The build under test is
the static musl `mux` (same target `make release` ships).

1. **Install like a user.** scp the binary to `~/.local/bin/mux` on the
   mux-less box; `mux d endpoint` over ssh now answers instead of 127.
2. **Entry dial.** `ptyclient` runs `mux ubuntu@10.78.5.3`: real ssh, real
   `mux d endpoint --start` auto-start, session paints. Type a marker,
   assert it via ssh against the VM's own pty (grid dump run remotely),
   detach, reattach, marker still present. Asserts the daemon's pid file /
   `/proc` state remotely: exactly one daemon, exec'd from the installed
   image.
3. **No autostart.** `mux d stop` on the VM; hold a wall polling the host
   for >3 poll cycles; assert via ssh that NO mux process exists on the VM
   after — the poll must not have resurrected it (the live-box bug,
   2026-08-30 era, now pinned against a real remote).
4. **Remote upgrade.** `mux d upgrade ubuntu@10.78.5.3` pushing the same
   image (the shape `e2e_16_push` already pins locally): assert the daemon
   pid did
   NOT change across the exec (`/proc/PID/exe` re-read through ssh), the
   session and its OSC 133 marks mechanism survive, and `mux a` still
   answers with `mechanism: marks`.

### Stretch (recorded, not v1)

- QUIC journey over the underlay address.
- Version-skew journey: install the released v15 tarball on the VM, assert
  the documented `[unreachable]` reading and the stop-then-start migration.
- Reconnect-latency journey: kill the ssh under a live tile, measure
  tear-to-repaint against a wall clock (the "why does reconnect take so
  long" question, made a number).

### Counting

`vm.sh` carries its own `ok()` count and its own literal pin at the bottom,
e2e.sh-style: a journey added means editing the pin by hand, on purpose. It
does not touch e2e's pins.

## Non-goals

- Parallelizing the e2e groups, per-group pins, keep-going mode: real
  candidates, out of scope here.
- Multi-VM topologies (client VM + server VM): the laptop-to-server shape is
  this box to `mux-e2e`; a second VM buys nothing until a journey needs an
  observer from a third box.
- eitri MCP/API calls from `vm.sh`: the harness speaks ssh only. VM
  lifecycle (create/destroy/recreate) stays a documented human/Claude
  action, not harness code.