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`).
9.5 KiB
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:
- 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. - 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 …orskipped (no device)), not a cue to try again. - 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 writeokfor a command that did not run; the unattended-run contract is that the human picks these rows up later. - Never strand the board.
tools/to_bootloader.pydrops 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. - 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.