Files
keyrelay/.agents/HW_VERIFICATION.md
T
ducoterra 121491bab4 phase: 01_verification_gate
All green. Report:

**Implemented / verified (phase 01 final pass)**
- `.agents/validate.sh` re-verified as the gate: ①`tests/run_tests.sh` ②`SANITIZE=1` run ③`uv … platformio run -e bridge -e console` ④page byte count ⑤hygiene, then the informational `tools/hw_probe.sh`. Added an explicit `== repo hygiene ==` header + `repo hygiene: OK (57 tracked files…)` verdict line so every check prints one (previously hygiene passed in silence).
- `.agents/HW_VERIFICATION.md`: baseline (bridge 26.7 %/87 592 B, 36.1 %/1 135 293 B; console 16.8 %/33.6 %; 3406 checks; page 48 598 B) re-confirmed; served-page row extended with this pass's one-attempt measurements.

**Tests / gate (100 % green)**
- `bash .agents/validate.sh` → **exit 0** (also from `/tmp`): `RESULT: all suites passed` twice (3406 = 3162 C++ + 160 relayctl + 84 page), `2 succeeded`, `page bytes: 48598`, `repo hygiene: OK`, `validation OK`.
- Faults re-demonstrated then reverted: (a) inverted `test_keystate` assert → cold-copy gate `RESULT: FAILED`, **GATE_EXIT=1**; (b) markup id `pArmed`→`pArmedZ` → page suite rc 1; (c) type error in `src/main.cpp` → `bridge FAILED / console FAILED`, rc 1; (d) hygiene regex matches `certs/test.pem` (git index commands are forbidden in this pass — task 01's report holds the original live `git add` demo).
- Lint: no linter is configured for this repo; compiler is the linter — `-Wall -Wextra` host suites + both firmware builds: **0 warnings**; `bash -n` clean; `py_compile` clean. `ruff check` (this machine's broad ruleset) reports 21 pre-existing *preview-rule* style nits in untouched `tools/*.py` + `tests/test_relayctl.py` (SIM/UP/ISC/EXE/RUF/FURB), zero correctness findings — not churned: `watch_target.py`/`relayctl.py` are hardware-verified artifacts outside this phase. `shellcheck` is absent here and not in the local container image (fetching it would violate the gate's no-network rule). Coverage: n/a (shell + docs; no new Python).

**Completion criteria** — all met: exit 0 with per-check output ✓; SANITIZE in-gate ✓; no `-t upload`/device curl/network fetch (grep: only in comments; `run_tests.sh` checks `podman image exists` first, so never pulls) ✓; faults bite ✓; HW_VERIFICATION.md names command+expected observation for every 02–05 device claim + one-attempt rule ✓; full suite green, `git status` = phase artifacts + mandated doc edits, nothing stray ✓.

**Documentation** — README inconsistencies found & fixed: `test_proto` 193→**2327** (table now sums to the headline 3406); duplicate stale `test_page 47` row deleted (element-id clause merged into the 84 row); "last six checks"/"fails 3 checks" claims replaced by measurement (5 fail-safe source pins; `linked()` gate → 2, accept-stamp → 1, both → 3); console line formats corrected to what the code prints (`mouse interface=present target=report protocol buttons=0x00 …`, `queue 0 keys pending…`, `target=BOOT protocol (refused)`); status-JSON sample gained `typed/typing/mouse/mbtn/mframes`; footprint 87 400/35.5 %→87 592/36.1 %; page delivery `Content-Length: 35164`→**48 598** (re-measured live, one capped curl: 200/48 598, 404 on `/nope`, 401 on `/status`); stale Backlog item "Mouse." removed and renumbered; "~35 KB"→"~48 KB"; page-button names matched to the real markup; `validate.sh`/`HW_VERIFICATION.md`/`hw_probe.sh` added to Architecture, Tests and End-to-end. AGENTS.md: only stale statements fixed — "one commit, `Initial import`… predates the mouse and `output.h`" and the pure-module list missing `mouse.h`/`output.h`; not expanded.

**Deviations** — Did not run the keystroke/mouse device rows: the board's target is this very machine, so injecting input during an unattended pass is the side effect the README's "hazard nobody warns you about" forbids; those rows stay blank per the contract (read-only GETs only). No live infra changed.

**Next pending phase:** `02_tls_control_channel` (tasks `01_tls_identity_module` … `06_tls_docs_rollback`).
2026-11-09 00:04:45 -05:00

9.5 KiB
Raw Blame History

HW_VERIFICATION.md — the device half of this roadmap's verification

The host gate (.agents/validate.sh) proves what can be proven without a board: the pure-module suites under sanitisers, the page and tool suites, both firmware builds, repo hygiene. It deliberately contains no -t upload, no curl to a device, no network fetch — an upload in a gate is a device side effect on somebody else's schedule (AGENTS.md).

That leaves claims that only a device can answer. AGENTS.md's standard: "A feature that lives on the device is not done when the source builds — it is done when the device is running it and something observed it." This file pairs every such claim in phases 02–05 with the exact command that observes it and the observation that counts, and states the rule that keeps a unit from ever blocking on a board nobody is holding.

bash tools/hw_probe.sh (printed at the end of every gate run, exit status deliberately ignored) says what is reachable right now — serial nodes, keyrelay.local, http/https, uv/node, target-side evdev nodes — without side effects and without blocking. It informs; it never gates and never records an outcome by itself.

Baseline (measured, phase 01 task 01 gate run)

Later phases quote a delta against these numbers, never a fresh guess.

metric value
bridge RAM 26.7 % (87 592 / 327 680 B)
bridge Flash 36.1 % (1 135 293 / 3 145 728 B)
console RAM 16.8 % (55 200 / 327 680 B)
console Flash 33.6 % (1 056 081 / 3 145 728 B)
host checks 3406 (3162 C++ + 160 relayctl + 84 page), green in the normal and the SANITIZE=1 run
served page 48 598 bytes — the raw-string length the gate prints every run; a reflashed device must serve exactly this many

Phase 02 additionally quotes the heap cost of one and two live TLS sessions as a delta against the bridge RAM/heap line above.

Claims that need hardware

Outcome vocabulary, written into the row in the phase's commit by whoever ran the command: ok · skipped (no device) · failed: <what>. Blank = not run yet. A row nobody ran is recorded skipped (no device) at phase close — an unobserved claim is never back-filled with a guess (ASSUMPTION: unattended runs may have a board attached that nobody is watching, and a second machine for watch_target.py is rarer than either).

Every phase that touches the keystroke path (02–05 regression baseline)

claim command expected observation outcome
keystrokes reach the target python3 tools/watch_target.py --seconds 60 on the target, driven from the page or tools/relayctl.py one press and release per key with timestamps; still held absent from the summary
a chord is the keys its label claims python3 tools/relayctl.py --host keyrelay.local chord 04 2b while watch_target.py runs LAlt press then Tab press — and not LCtrl. The watcher is the only thing that says what the target received (README: Alt+Tab was Ctrl+Alt+A on the wire once)
served page == built page curl -s http://keyrelay.local/ | wc -c (after phase 02 lands: curl -sk https://keyrelay.local/ | wc -c) byte-equal to the page bytes: line the gate prints. A mismatch means the page in flash is stale — reflash and reload any open tab ok — measured 2026-11-09: curl -s http://keyrelay.local/ | wc -c = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on /nope, 401 on /status without the token, zero src=/href= in the served bytes. The first cold probe returned nothing (http_code=000, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check
silence disarms arm, kill the controller process without a clean close (or close the tab), wait kNoClientTimeoutMs (8 s), python3 tools/relayctl.py --host keyrelay.local status armed: 0, and watch_target.py shows releases for anything held. Test the timer, not the socket close (README bug 10)
nothing outlives the link hold a key and a mouse button, then drop the link every key up, every button up; mouse summary shows no still held

Phase 02 — HTTPS + wss://

claim command expected observation outcome
TLS handshake completes curl -sk https://keyrelay.local/status or openssl s_client -connect keyrelay.local:443 </dev/null 200 + status JSON / a session whose only verify failure is self-signed. HTTP 000 / connection refused = listener not up
fingerprint printed once, on serial serial console boot log after nvs erase; then python3 tools/console.py cert (opens the port — one attempt, capped) SHA-256 fingerprint appears exactly once at first boot, on demand afterwards; never the key; survives reflashing (NVS)
pinned fingerprint == handshake cert openssl s_client -connect keyrelay.local:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256 same hex as the serial console printed; mismatch = two identities, investigate before pinning
:80 is redirect-only curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/ 3xx to https://, and no page bytes on plaintext
plaintext /ws refused, not downgraded a ws:// upgrade attempt on :80 (e.g. curl -i -N with upgrade headers, or relayctl.py pointed at plaintext) refused loudly (per README error style), never a silent plaintext session
TLS session heap cost build RAM delta from the gate output + live free-heap readout the phase's status/serial path exposes, at 0 / 1 / 2 live sessions quoted as a delta against the baseline table above; two sessions must fit without allocation failure
-D KR_TLS=0 rollback behaves like today build the rollback env, flash once, curl -s http://keyrelay.local/ | wc -c + page connects over ws:// page and ws:// work exactly as pre-phase-02; README documents the rollback

Phase 03 — session takeover

claim command expected observation outcome
takeover releases the incumbent's held keys two controllers on one board: incumbent holds a key, newcomer takes over (relayctl.py --take-over), all while watch_target.py runs on the target watch_target.py shows the key up before the new session's first key-down — the ordering is the whole claim
the board comes back disarmed python3 tools/relayctl.py --host keyrelay.local status immediately after the handover armed: 0 across the handover; the newcomer must arm again on purpose; nothing in the handover path arms
a third controller is still refused a third /ws upgrade while two sessions exist E3 — takeover consumes the one spare slot, it does not open the door

Phase 04 — responsive page

claim command expected observation outcome
page still byte-exact after the restructure the generic curl … | wc -c row above equals the gate's new page bytes: count (the count moves when the page does — that is fine; equality with the gate is the check)
one column on a phone, ARM thumb-reachable open https://keyrelay.local/ on a phone at ~360 px single column, no horizontal scroll, sticky ARM bar on screen, log collapsed and not pushing ARM off screen

Phase 05 — soft keyboard and touch pad

claim command expected observation outcome
arm on a phone raises the soft keyboard, characters land phone on the board's AP/network, tap ARM, type a word; watch_target.py on the target phone keyboard appears; the watcher's typed: line reproduces exactly what was typed, one P=0: character per frame
a latched modifier chords correctly latch Ctrl on the row, tap c; watch on the target LCtrl press + C press — Ctrl+C on the wire, latch visibly cleared after
pad moves the pointer; lift releases everything press-and-hold the pad, move, lift; hold a button meanwhile, then drop the link movement dx … dy … lines that stop at lift; still held absent from the mouse summary; no button stuck

The blocking rule

A unit running any of the rows above:

  1. Look first, side-effect-free. bash tools/hw_probe.sh (always exits 0, every probe capped, no serial port opened — opening /dev/ttyACM* toggles DTR/RTS and resets the board). It tells you whether an attempt is plausible.
  2. One attempt, capped. Each device step runs at most once, wrapped in timeout (or the tool's own -m). No polling a port, no retry loops, no re-powering, no re-flashing "to see if it works". A timeout is an outcome (failed: timeout … or skipped (no device)), not a cue to try again.
  3. Record what was observed. The outcome — ok, skipped (no device), failed: … — plus the exact command written goes into the row above, in the phase's commit. Never write ok for a command that did not run; the unattended-run contract is that the human picks these rows up later.
  4. Never strand the board. tools/to_bootloader.py drops the board into ROM download mode deliberately, and a power cycle is the only way back. If a run leaves the board in download mode, its report says so explicitly — nobody may be there to notice.
  5. Never block the gate. None of this is inside .agents/validate.sh. The gate stays device-free and network-free; the probe line at its end is context whose exit status is deliberately discarded.