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`).
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user