Compare commits

...
6 Commits
Author SHA1 Message Date
ducoterra c43a088ce8 phase: 04_controller_page_responsive
**Final verification pass** (all three task reports PASS; tasks already in `complete/`). One real defect found and fixed.

**Commands / results.** `bash tests/run_tests.sh` → `RESULT: all suites passed`, **5 104** checks (page **220**). `SANITIZE=1 bash tests/run_tests.sh` → passed. `bash .agents/validate.sh` → `validation OK`; bridge/console/rollback `SUCCESS`; `page bytes: 59262`; `README/AGENTS claims: OK`; `repo hygiene: OK`. Real-browser leg (headless Chromium, served assembled bytes): no horizontal scroll at 360/390/768/1440, ARM first-in-`main` and sticky, log height-capped, all 5 folds openable, all 3 changed page statements execute.

**Fix.** The folded notes' `<summary>` — the only route to five pinned safety sentences — rendered **17 px** on coarse pointers, under the page's own 44 px thumb floor, while every byte-level assertion stayed green. `src/web_page.h`: `button.k,.pill,input,summary{min-height:44px}` + `summary{padding:13px 0}`; `tests/test_page.js` +1 check (219→220); mutation-tested (reverting the CSS fails host *and* browser legs, then restored byte-for-byte).

**Criteria.** 1 ✓ browser-measured (physical phone still unobserved). 2 ✓ 220 checks + prose untouched. 3 ✓ page 59 286→**59 262 B** (−24 vs phase start), README = gate figure. 4 ✓ one `test_page` row at 220, intro 5 104 = measured. 5 ✓ gate green; phases 01–03 assertions unchanged apart from the re-pinned byte constant.

**Docs.** Re-pinned 59 262 (README, `config.h`, `test_conn.cpp`), totals 5 103→5 104, README Tests row 220; re-measured footprint rows (flash 1 257 901 / 1 178 141 / 1 157 593, TLS +6 176/+100 308); fixed stale "48 KB"/"~50 KB" (×4 src comments, ×2 README) and the 51 626-byte page-delivery row; added **bug 14** with root cause and renamed *Thirteen*→*Fourteen bugs* in README + AGENTS.md's two stale references; added the Phase 04 browser+device row to `.agents/HW_VERIFICATION.md`.

**Deviations.** Device leg stays open: live board serves 51 626 B (pre-phase image); no reflash attempted, per the standing no-unattended-reflash rule — no byte-equality claim made. `ruff check tools tests`: 29 findings, identical at HEAD and `--isolated`, `E9,F` clean, no repo lint config or gate step → left untouched. `.agents/validate.sh` is **untracked and has never been committed** (`git log --all --` empty) though README/AGENTS/PLAN cite it 13× — harness needs `git add` it; I did not stage anything.
2026-11-09 11:00:27 -05:00
ducoterra d629a0ff51 phase: 03_session_takeover
**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
2026-11-09 08:57:35 -05:00
ducoterra e0780f58e0 phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)**
- All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label).
- Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no).
- Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page.

**Tests / lint (100 % green)**
- `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`.
- Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2).
- `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free.

**Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓.

**Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`.

**Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit.

**Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
2026-11-09 05:51:57 -05:00
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
ducoterra 56aa97d60d chore(agent): phase roadmap from TODO.md, 5 phases
The four open items in TODO.md become an executable pipeline: 01 the
verification gate (host suites + sanitiser + both firmware envs, and the
hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS
and wss:// with an identity the board generates itself into NVS, 03 session
takeover that always lands disarmed, 04 a responsive page whose safety
statements are pinned before they are trimmed, 05 the phone as a controller
(press-and-hold pad, soft keyboard, modifier latches over a new Q= frame).

TODO.md is cleared to its empty title; the items live in .agents/phases/ now.
AGENTS.md gains the five protocol rules and nothing else.

LLM-Generated: true
2026-11-08 23:28:25 -05:00
ducoterra ece0313d02 feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same
HID interface. The pointer is captured, never grabbed: Pointer Lock in the
page, so Esc (the browser's own, never forwarded) always frees it, and every
frame restates the whole button byte so a frame lost in the WiFi cannot leave
a button held. Losing the lock, the tab or the link runs the same release
funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames
with E11 rather than sending bytes a boot interface would misparse as keys.

src/mouse.h owns the report, src/output.h the queues and the funnel; the page,
relayctl.py and watch_target.py carry the button bits and the evdev decoding.
The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the
first version guessed from the report's field order, so a working wheel looked
dead while the summary said no scroll happened.

LLM-Generated: true
2026-11-08 23:27:42 -05:00
138 changed files with 18979 additions and 748 deletions
+130
View File
@@ -0,0 +1,130 @@
# 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 | skipped (no passphrase) — task 04, 2026-11-09: the board is reachable on `:443` and the TLS/`wss://` transport rows below are measured, but this board's control passphrase is not the default (`status` says `dflt:0`) and a verification run will not change a credential on a device it cannot put back. To close it: `console.py token <known>` on a board you are willing to re-provision, then drive `wss://` |
| 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) | skipped (no device run) — phase-03 close, 2026-11-09. This row is still open for the same standing reason as every row in the phase-03 table below: the board is a live device this pass neither reflashed nor armed (arming means typing into whatever this operator's console has focus, and the passphrase is the operator's). Host-side the claim is pinned instead: `test_proto` asserts every labelled chord button's `data-chord` bytes against the combination its label promises, so `04:2b` is Alt+Tab or the suite fails. To close: reflash `bridge`, arm, run the command above with the watcher open |
| 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 — re-measured 2026-11-09 **over TLS after the phase-02 flash**: `curl -sk https://10.2.0.22/` = 48598 and `curl -s http://keyrelay.local/` = 48598, both = the gate's count. **Task 04** re-ran it over TLS three times in a row: 48 598 bytes each time with `sha256` equal to the raw string in `src/web_page.h` (`f7aae9e5…c6f7`) — the page now leaves through `kr::Conn` at 1460 B per write and 5840 B per tick (34 chunks over ~9 `loop()` iterations) instead of one `WiFiClient::print`, so byte-equality across a multi-tick send is the transport claim. Original — 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) | skipped (no passphrase), same reason as the row above. What task 04 *did* pin is the property this row depends on and that the transport rewrite could silently have moved: `_lastInboundMs` is stamped in exactly one place, gated on `_phase == WebSocket && _authed`, and neither the TLS handshake, a `/status` poll nor a viewer refresh reaches it — asserted structurally in `test_relayctl` (`the controller clock is stamped in exactly one place`, `no HTTP path can refresh the controller clock`) |
| 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` | skipped (no device run) — phase-03 close, 2026-11-09, same standing as the row above. Phase 03 is the phase that makes this row sharper (a *handover* is a link teardown with a new occupant waiting, and the claim path must not keep anything alive), so the host-side pins are the ones named in the phase-03 table: `test_output`'s release-funnel suite and `test_relayctl`'s funnel-runs-before-adoption pin. To close: the run described in the phase-03 table |
### Phase 02 — HTTPS + `wss://`
Every row below was produced by one capped command on the board at `10.2.0.22` /
`keyrelay.local`, joined to the LAN, flashed with `bridge`. Two rows are honestly `skipped`:
the passphrase-dependent ones (arming/typing over a pinned session need a credential the
operator owns) and the rollback's device leg; one row is `failed` and fixed in the tree with
its device leg still open (the certificate's SAN, found at the final pass) — see the rows
themselves for what would close them and why it was not run.
| 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 | ok — measured 2026-11-09 on the flashed bridge: `openssl s_client` negotiates **TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384**; `curl -sk https://10.2.0.22/` → 48 598 bytes (= the gate's count); `/status` without the token over TLS → **401**, path still gated. `hw_probe`'s `https` line now reports `HTTP 200 (TLS listener is 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) | ok (minus the `nvs erase` leg) — measured 2026-11-09: captured boot log shows `[tls] identity loaded from NVS (reprint with \`cert\`)` with **no** fingerprint on it, and `console.py cert` prints it on demand; the identity survived two reflashes and three reboots with the same fingerprint. `nvs erase` → first-boot printing was not re-run (the regeneration path had just been observed end-to-end). **Finding:** the *first* boot under the new firmware rotated the fingerprint once for a reason the boot log could not show (no host had the CDC open yet); `loadTlsIdentity()`/`storeTlsIdentity()` now print the failing gate and the record **lengths** (`stored records cert=… key=… fp=… -- shape/selfCheck`, `NVS put refused: …`, `read-back mismatch: …`) so a rotation can never again be a mystery — every later boot loads cleanly. **(That first
"rotation" is corrected two rows down: it was the `openssl -ext` artifact, not a regenerated
identity. The instrumentation stays worth having — it is what makes the next claim provable.)** |
| 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 | ok — measured 2026-11-09: console `cert` `61:54:19:30:ed:df:e5:52:d1:ee:18:6f:cc:f9:57:c5:41:47:d4:15:61:26:1e:11:aa:49:4f:07:79:b1:af:f9` == the `x509 -fingerprint -sha256` of the live handshake, and the pair still matched after two further `reboot` cycles. **Corrected at the phase-02 final pass, same day:** the earlier mismatch recorded as `C8:76:F1:69:…` was **not a rotation** — it is what `openssl x509 -noout -fingerprint -sha256` prints when `-ext <oid>` is passed in the same invocation, on OpenSSL 4.0.3, for *this same* certificate. Reproduced byte for byte: the peer certificate captured with `s_client`, written with `-outform der`, hashes to `61541930…b1aff9`; the same file run through `-fingerprint -sha256 -ext subjectAltName` prints `C8:76F1…E6D2` *and* “No extensions in certificate”. So: take the fingerprint with `-fingerprint -sha256` alone (or `sha256sum` of `-outform der`) and never with `-ext` in the same command, or a stable identity looks like a rotation. Re-measured at the final pass (`console.py cert`, one capped attempt, and `s_client` + `openssl x509 -outform der | sha256sum`): console `61:54:19:30:…:b1:af:f9` == `61541930eddfe552…79b1aff9`, one identity, one board, no rotation since the instrumented boot. |
| `: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 | ok — measured 2026-11-09 on the task-04 flash, one capped `curl` each: `/` → **301** `Location: https://10.2.0.22/`; `/index.html` → **301** to the same path over `https://`; `/nope` → **301** to `/` (an unknown target is a 404 on 443, and an arbitrary request target is never echoed into a `Location`); `/status?token=…` → **426** with `Upgrade: TLS/1.2, HTTP/1.1`, **no `Location`**, and the body verified free of the token (`grep -c` of the value → 0). No plaintext path returns page bytes any more |
| 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 | ok — measured 2026-11-09: `curl -i -N` with `Upgrade: websocket` on `:80` → **426 Upgrade Required**, `Connection: Upgrade`, `Upgrade: websocket, TLS/1.2, HTTP/1.1`, body `plaintext websocket refused; reconnect over wss:// (https://<host>)` — no `101`, no session; the board's own counter moved (`link controller=no rejected=1`). `relayctl`'s plaintext path now raises the same sentence via `handshake_refusal()` rather than the old "board refused the websocket upgrade". **Re-measured at the phase-02 final pass** (the bodies were reworded after that flash): `/ws` upgrade → **426**, `Upgrade: TLS/1.2, HTTP/1.1`, `Connection: close`, `Content-Length: 96`, body `keyrelay: this controller channel is TLS-only -- reconnect over wss://, the page is on https://`; `/status?token=<dummy>` → **426**, `Content-Length: 140`, **no `Location:`** (`grep -c location: → 0`) and the value not echoed (`grep -c <dummy> → 0`); `/` → **301** to `https://keyrelay.local/` with a 77-byte redirect body and **no page bytes**; over `:443`: `/status` without the token → **401**, `/nope` → **404**, `/` → 200 / 51 626 B / `sha256 dd16e195…f04e9` == the locally assembled page |
| 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 | ok — measured 2026-11-09 from the serial `[tls] sessions=N heap=…` lines: boot `[tls] https :443 listening heap=187936`; **1 session ≈ 145.5 k, 2 sessions ≈ 107.9 k** (second session ≈ −37.6 k: the mbed TLS record buffers), close returns heap to 168–185 k. **Task 04** (transport view on both ports: 2 KB `:80` redirect ring + 1460 B page chunk + 64 B `tRing` per slot; gate RAM 97 240 → 99 616) measured in one run `sessions=1` `140 092` → `sessions=2` `109 228` → close one `144 836` → close all `182 256`: second session ≈ −31 k, and the memory comes back inside the band above. 20 further connect/disconnect cycles on `:443` ran in the same pass. Original 20 connect/disconnect cycles: the `sessions=1` readings held at 145.5–149.8 k with **no downward drift** — no leak. Slot cap enforced live: with two sessions held, a third got a **fatal TLS alert (handshake_failure, 40) in 0.0 s** while both listeners kept serving. Stall check: a connection that completed the handshake and sent nothing did **not** block `loop()` — the full page still served over a second connection during the stall, and the stalled socket was closed by the ~10 s deadline |
| `-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 | **build leg ok, device leg skipped (not flashed)** — task 06, 2026-11-09. The rollback is now a first-class environment, `[env:rollback]`, built by `.agents/validate.sh` on every run alongside `bridge` and `console`: RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) against the TLS `bridge` 30.4 % (99 616 B) / 39.6 % (1 245 353 B), so TLS costs +6 160 B RAM and +99 216 B flash. *(Final pass, same day: after the SAN fix `bridge` is 99 616 B / 1 246 145 B — RAM unmoved, +792 B of flash — and the rollback image is **byte-for-byte unchanged** at 93 456 B / 1 146 137 B, because the fix lives entirely inside `#if KR_TLS`; TLS now costs +100 008 B of flash.)* That the flag *took* is measured, not assumed: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** (115 in `bridge`), `… \| grep -c mbedtls_x509write` → **0** (15), `strings .pio/build/rollback/firmware.elf \| grep -c "pin this certificate"` → **0** (1). The device half was deliberately not run: flashing the rollback and back is two write cycles on a board that is a live HID keyboard into the machine running this pipeline, `to_bootloader.py` strands it in ROM download mode if anything goes wrong after that point, and no criterion for task 06 needed it. To close it, in order: `uv run --python 3.14 --with platformio python -m platformio run -e rollback -t upload` → `timeout 10 curl -s http://keyrelay.local/ \| wc -c` (expect the same 51 626 the gate prints, on plaintext, with `var KR_TLS_BUILD=0;` from `curl -s http://keyrelay.local/ \| grep -o 'var KR_TLS_BUILD=[01];'`) → `timeout 10 curl -s -o /dev/null -w '%{http_code}' https://keyrelay.local/` (expect 000/refused: no `:443` exists any more) → boot log line `[tls] TLS compiled out (KR_TLS=0) -- plaintext control channel` → `python3 tools/relayctl.py --plain --host keyrelay.local --token … status` → then reflash the TLS build (`run -e bridge -t upload`) and confirm `curl -sk` serves 51 626 again |
| TLS handshake latency and page fetch over TLS | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` (and `…/status`), one capped run each | a handshake that completes in a time a browser operator will not mistake for a dead board, and a page that arrives whole over the chunked TLS send | ok — measured 2026-11-09 (task 06, read-only, board on the LAN): seven warm connections, **handshake 0.349–0.423 s**; page 51 626 B in **0.528–0.618 s** total; `/status` 0.382–0.434 s total. The first connection after ~10 min of quiet took **3.94 s** of handshake (one observation, recorded because it looks like a timeout from a browser and is a cold start). Both numbers are in README “Measured behaviour” with the command |
| served page carries the build marker and the pinning UI | `curl -sk https://keyrelay.local/ \| wc -c` + `grep -o 'var KR_TLS_BUILD=[01];'` + `sha256sum` against the locally **assembled** page (chunks + KR_TLS=1 branch) | 51 626 bytes, marker `=1`, `certFp`/`pTls`/`clearWarn` present, digest equal to the assembled TLS page | ok — measured 2026-11-09 after the task-05 flash: 51 626 bytes, `var KR_TLS_BUILD=1;`, all three new ids (6 matches), `sha256 dd16e195…f04e9` **byte-equal** to the locally assembled page — the multi-tick chunked send still delivers the changed page intact, and `curl -sI http://keyrelay.local/` is still a 301 |
| `relayctl.py` pinning, end to end | `--fingerprint 00…00` / the real fingerprint / `--ca <exported self-signed>` / `--insecure` / no argument / `--plain`, each `timeout 15`, dummy token | wrong pin aborts **before sending**; correct pin passes the pin and stops only at authentication; `--ca` verifies the self-signed cert with hostname checking on; `--insecure` warns on stderr every run; no argument → refusal at exit 2 without a socket; `--plain` reads the 426 as a TLS refusal, never as a passphrase problem | ok — measured 2026-11-09: wrong pin → `certificate fingerprint mismatch … Nothing was sent` (and the presented digest `61:54:19:30:ed:df:…:b1:af:f9` equals the serial `cert` value recorded above — the pin and the console agree on one identity across reflashes); correct pin, `--insecure` and `--ca /tmp/board.pem` all reached authentication and were refused there by `E1` (the board's passphrase is the operator's — I did not guess it, so **arming/typing over a pinned session is not device-confirmed in this row**, three `authfails` total, well under the 5-try lockout); no argument → the four-option refusal, exit 2, no connection attempted; `--plain` → `board refuses plaintext … wss:// on :443`, not one word about the passphrase |
| the certificate carries the name list `cert` prints | `openssl s_client -connect keyrelay.local:443 </dev/null \| openssl x509 -noout -text`, compared with `console.py cert` | the `X509v3 Subject Alternative Name` extension reads `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1`, so a client can check the name and not only the fingerprint | **failed → fixed in the tree, device leg needs `nvs erase`.** Found at the phase-02 final pass 2026-11-09 by reading the certificate instead of the code: `-text` printed the extension as `1.10.46.53.46.50.57.46.49.55` (not `X509v3 Subject Alternative Name`) and `-ext subjectAltName` said *no extensions*. A hex dump named the cause — the extension was framed `30 2f 06 09 32 2e 35 2e 32 39 2e 31 37 04 22 <the correct 34 bytes>`: the OID was written from the **ASCII text** `"2.5.29.17"` where the DER content bytes `55 1d 11` belong, because `mbedtls_x509write_crt_set_extension()` takes the OID as DER content bytes. So the served certificate has the right SAN *value* under a label no client reads, i.e. **no SAN**: encryption and pinning work, hostname validation cannot (browsers match SAN, not CN). Fixed in `src/tls_pem.h` (`kSanOidTlv`/`sanOidDer`, `sanExtensionFullDer` pinned to the 43 bytes OpenSSL writes for these three names, `derCarriesSan` requiring OID *and* value), used by `src/tls.cpp`, 33 new host checks in `test_tls_pem.cpp`; the regression fixture is the bytes above, and `derCarriesSan` answers no to it while `derContains` says yes. README bug 11. **To close the device leg** (deliberately not run here: `nvs erase` also forgets the passphrase and the Wi-Fi credentials, which the verification run cannot put back): reflash `bridge`, `nvs erase`, reboot, then `openssl s_client … \| openssl x509 -noout -text` must show `X509v3 Subject Alternative Name: DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1`, the new fingerprint must appear exactly once in the boot log, and `console.py cert` must print that same value with **no** `!! the stored certificate does NOT carry that name list` warning. Until then `cert` prints that warning for this board — which it now does, because the check looks at the OID. |
### Phase 03 — session takeover
| claim | command | expected observation | outcome |
|---|---|---|---|
| a second `/ws` upgrade arrives as a pending claim, not a 503; a third socket is still refused (task 01) | after reflashing `bridge` from this tree: controller A connects and authenticates; a second socket does a raw `/ws` upgrade (`openssl s_client -connect keyrelay.local:443` + upgrade headers, or a python one-liner) and sends **one** deliberately-wrong `T`; a third connection opens in parallel; `watch_target.py` runs on the target throughout | second upgrade answers **101** (not 503), then exactly `E1:wrong passphrase` and close (one attempt only — the claim shares the board-wide 5-fail lockout); the third connection is refused (fatal TLS alert / close) and `slotrefused`/`rejected` move while both listeners keep serving; A keeps typing throughout and the silence clock never moves | **skipped (not run on device), 2026-11-09** — the live board runs the phase-02 firmware; the claim path is not flashed, and the authenticating half needs the operator's passphrase, which verification runs must not guess (the same call phase 02 recorded, and more pointed here: a wrong claim token feeds the shared lockout this task wires). Host-side the gate is green: 95 new `test_claim` checks (whitelist matrix over every `proto::Cmd` × auth × role, both deadline clocks at the boundary and across the `millis()` rollover, oversized-frame refusal through the real codec), 4921 checks in both suite runs, all three firmware builds; bridge RAM 99 728 B vs the phase-02 99 616 B — **+112 B**, the claim's whole footprint (64 B buffer + reader state + role/auth bytes + the 4 B clock, matching the comment in `control_server.h`) |
| takeover releases the incumbent's held keys | after reflashing `bridge` from this tree, two controllers on one board: incumbent holds a key, newcomer takes over (`relayctl.py --take-over` — landed in task 03; or a raw `/ws` upgrade sending `T<passphrase>` then `X`), 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 | **skipped (no device run), 2026-11-09** — same standing as the task-01 row directly above: the live board still runs the phase-02 firmware (no claim path), and both controllers need the operator's passphrase, which a verification run must not guess into the shared 5-fail lockout. The ordering is what the run will observe; on the host it is *pinned*: `test_relayctl.py` fails if `closeLink("taken over")` ever moves after `_authed = true` in `handOverClaim` (demonstrated failing on an inverted tree, then reverted), and the funnel it runs is `test_output`'s release-funnel suite. To close: reflash `bridge`, run the two-controller procedure above, capture the transcript |
| 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 | **skipped (no device run), 2026-11-09** — same reason as the row above. Host-side the sentence is the (b) pin of `test_relayctl.py`'s **bridge handover** section: no `setArmed` / `armed = true` / `arm(` exists in `handOverClaim`'s code lines nor in the controller-side `X` case (demonstrated failing when a `…_rearm();` line was inserted, then reverted); `forgetPendingWrite()` is the one TLS state the adoption touches, and it writes no arming state. To close: the same reflash and run, `armed: 0` in the status read between handover and the newcomer's first `A1` |
| 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 | **skipped (no device run), 2026-11-09 (phase close)** — the live board still runs the pre-phase-03 firmware, so the second session would be the only claim-shaped thing on it, and opening the two sessions first needs the operator's passphrase. Host-side: `test_claim` pins `claim::acceptsUpgrade()` (a slot that already carries a Claim takes no second upgrade) and `test_tls_session` walks the admission table (`controller`, `spare`, `refused`) with the cap checked before the free slot. Note the mechanism, as the README's Security model states it: a third *socket* never reaches HTTP, so what it gets is the slot refusal — a fatal TLS alert record on `:443`, a close on `:80`, counted in `slotrefused` — and `503`/`E3` is the sentence for an upgrade that reaches the spare slot and cannot be a claim (a malformed upgrade, or that slot already holding one). To close: reflash `bridge`, hold controller + claim, open the third connection, read `slotrefused` moving while both listeners keep serving |
| the locked-out operator sees `held by another controller` and takes the bridge with one click; the incumbent's page says it was taken over, disarmed, in its log (task 03) | reflash `bridge` from this tree (check the build's own exit status first); laptop tab holds the bridge; on the phone open `https://keyrelay.local/`, type the passphrase, connect — the pill must read `held by another controller`, the **take over** button appears, and nothing further leaves the phone until it is clicked; press it while `watch_target.py` runs **on the target**; then read the laptop tab | pill `held by another controller`; one click sends `T` then `X` and nothing else; the target shows every key and button up before the phone's next key; the phone is DISARMED after the handover; the laptop tab shows pill `taken over` and a log line naming "another controller took over", and does not reconnect (no second `/ws` from it); `--take-over` from the terminal produces the same frames and states | **skipped (no device run), 2026-11-09** — same standing as the rows above: the live board still runs pre-phase-03 firmware, the gesture needs the operator's passphrase on two devices (guessing feeds the shared 5-fail lockout), and a phone-and-laptop dance is not a headless attempt. Host-side the gate is green: `test_page.js` executes exactly this flow (claim-close → button appears → nothing sent until the click → click sends `T` then `X` and nothing else → first status logged as the DISARMED start; `E12` disarms, releases the mouse, ends a typing run, marks `taken over`, and stays down through the close; `E3` keeps its own "no room" sentence), and `test_relayctl.py` pins the CLI's `T,X,H` ordering, the default refusal naming `--take-over`, and the free-board no-op. To close: the reflash and two-device run above, transcript captured |
### 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) | **skipped (board not reflashed), 2026-11-09 (task 01)** — one capped attempt was run *before* the page edit: `timeout 15 curl -sk https://keyrelay.local/ \| wc -c` → **51 626** and `sha256 dd16e195…f04e9`, byte-identical to the phase-02 close record, so the live board still runs pre-phase-03 firmware and the measurement path works. The 60 780-byte page of this tree is only served after `run -e bridge -t upload`, deliberately not run here: the board is a live HID keyboard into the machine running this pipeline and `to_bootloader.py` strands it if anything goes wrong unattended (same call phase 02 recorded). To close: reflash, then `timeout 15 curl -sk https://keyrelay.local/ \| wc -c` must equal the gate's `page bytes:` line for this tree. **Task 02** (the prose fold) re-ran that one capped read-only probe after the edit: **51 626** bytes, `sha256 dd16e1954a68…` — identical to the task-01 and phase-02 records, so the live board still runs the pre-phase-03 image and this tree's **59 223**-byte page (the gate's `page bytes:` line, equal in both builds) is not on it yet. Measurement path proven, byte comparison still open until a flash. **Task 03** (docs and suite) re-ran the same single capped probe: `timeout 15 curl -sk https://keyrelay.local/ | wc -c` → **51 626**, `sha256 dd16e1954a68…f04e9` — byte-identical to the task-02 record, and this tree's locally assembled page hashes `dc26b441…b634` at **59 223** B (the count the gate prints and the count README's Controller-page section now quotes with this exact `curl` command named as the device-side measurement). README == gate == assembled bytes; device equality still opens by the standing no-unattended-reflash call (live HID keyboard into the pipeline host) | **Final pass, 2026-11-09:** one more capped read-only probe — `timeout 15 curl -sk https://keyrelay.local/ \| wc -c` → **51 626**, `sha256 dd16e195…f04e9`, byte-identical to the task-01/02/03 records (the live board has not been reflashed all phase), while this tree assembles to **59 262 B** / `b9414d2f9847b774…` after the `summary` thumb-floor fix; README (×2), `config.h` (×2) and `test_conn.cpp`'s `kServedPageBytes` are re-pinned to 59 262 and the gate's `page bytes:` line agrees. |
| 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 | **skipped (no device), 2026-11-09 (task 01); still skipped for task 02, 2026-11-09** — no phone is reachable headlessly, and the page must be reflashed first (row above). Task 02 adds one thing for whoever runs it: the folded notes (`<details>` under Special keys, Connection, Mouse, Send text and the footer) must be readable and tappable at 360 px, and the State card's counters must wrap into a couple of lines rather than stacking eight rows — the counters stay on screen because they are the "is it typing" evidence. Host-side the layout is pinned instead: 17 new `test_page.js` checks assert the served bytes carry `viewport-fit=cover` with pinch zoom intact, the one 720 px breakpoint (one column; `main` as flex column, which is what makes the sticky ARM bar actually stick), the 16 px input floor (focus-zoom), the log height cap + `overscroll-behavior:contain`, the 44 px `pointer:coarse` floors, safe-area bottom padding, and ARM still first in `main`. To close: reflash `bridge`, open `https://keyrelay.local/` on a ~360 px phone — one column, no horizontal scroll at 360/768/1440 px, ARM bar visible while scrolling, focusing the passphrase field does not zoom the page. **Task 03** (2026-11-09): still `skipped (no device)` — no phone is reachable headlessly and the page must be reflashed first. The phone checklist for whoever runs it, from the phase's completion criteria: 360 px width, one column, no horizontal scroll, ARM bar reachable with a thumb at the top of the scroll, focusing the passphrase input does not zoom, and each folded note (Special keys, Connection, Mouse, Send text, footer shortcuts) expands with a tappable summary. Host-side task 03 adds the structural teeth for the last one: `test_page.js` pins exactly five `<details>`, each with a summary naming what is behind the tap and real sentences inside, and proves the fold checks fail on deletion (mutation-run, then restored byte-for-byte) |
| the responsive layout, observed in a real browser at phone widths (final pass) | assemble the served bytes from `src/web_page.h` (raw-string chunks + each marker branch in turn), serve them on `127.0.0.1`, and measure them in headless Chromium at 360×640, 390×844, 768×1024 and 1440×900 with touch/`pointer:coarse` emulated | no horizontal scroll at any width; ARM reachable without scrolling and sticky at the bottom of a 400-line log; one column and a height-capped, self-scrolling log below the breakpoint; every press target ≥ 44 px on a coarse pointer; the five folds open on a tap and stay inside the viewport; no script error; the safety sentences present in *rendered text* — and the same measurement against the pre-phase-04 page, where the sticky/flex-column/log-cap/thumb-floor rows must fail | **ok (browser, not a phone), 2026-11-09 (phase-04 final pass)** — all five viewports pass on this tree's page (59 262 B, both marker branches equal); the same script on the phase-start page (59 286 B) fails exactly the four rows the phase changed: ARM not sticky (`z-index:auto`, scrolled to −1989 px), `main` still `display:grid`, no log cap, and 19 targets at 34 px. **It also found a defect the byte-level suite could not see, now fixed:** the five `<summary>` elements task 02 introduced rendered **17 px** tall on a coarse pointer — the only route to five pinned safety sentences, on a control below the page's own 44 px thumb floor, with every served-byte assertion green. `summary` is now in the `pointer:coarse` rule (page 59 223 → 59 262 B, re-pinned in `config.h`/`test_conn.cpp`/README); the floor is asserted in `test_page.js` (219 → 220) **and** measured here, and deleting the rule fails both (demonstrated, then restored byte-for-byte). This is not the phone row below: no hand-held device was involved, `env(safe-area-inset-*)` is 0 on a desktop screen (so the inset is checked in the declared CSS, where it means something), and iOS Safari's focus-zoom stays unobserved. |
### 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.
+121
View File
@@ -0,0 +1,121 @@
# keyrelay — Master Plan
> Master design document for a phased-execution project. The phase roadmap below was
> derived from `TODO.md` on 2026-11-08; each phase overview and task file cites its
> source lines in that file. The **LOCKED DECISIONS** below are binding: no phase may
> introduce technology outside them without explicit user permission.
>
> `README.md` remains the single source of truth for behaviour, protocol and safety.
> `AGENTS.md` remains the operating manual. This file adds *what is left to build* and
> the anchors those phases may not move. If this plan and the README disagree about
> arming, the wire format or the safety model, the README wins and this file is wrong.
## Assumptions & Design Principles
- **The safety model is not on the roadmap.** Disarmed-is-the-only-default, the release
funnel, state-not-events, nothing-stored, reject-never-guess, fixed `Ctrl+Alt+Del`,
one-frame-one-command, exclusive-keyboard-not-exclusive-board: every phase adds
capability *inside* those rules. A phase that needs one of them relaxed is wrong, not
ahead of schedule.
- **Every new capability has to be cancelable.** Anything that can outlive a disconnect —
a session, a TLS context, a touch capture, a modifier latch — is cancelled in
`releaseEverything()` or on the link change that ends it, exactly like the paste run.
- **Auth is a precondition, never a consequence.** Takeover (`03`) and the soft keyboard
(`05`) build on the authenticated channel (`02`); neither may gain the ability to arm.
- **No secrets in files.** Not in the repo, not in a log, not in a URL, not in shell
history. This is why the TLS identity is generated *on the board* rather than shipped
as a PEM in the tree — the pipeline's own hygiene gate refuses tracked `*.pem|*.key|*.crt`.
- **No unit blocks on hardware.** Board access is best effort: one attempt, wrapped in
`timeout`, outcome recorded in `.agents/HW_VERIFICATION.md`. The gate is host-observed
(test suites + both firmware builds + `curl` against a reachable board *when* one is
reachable). A phase may not hang, retry forever, or leave the board in download mode
waiting for a human who is not there.
- **Docs move in the same change** (README sections named per task; `AGENTS.md` only gets
stale statements fixed, never expanded).
## Architectural Anchors
| COMPONENT | DECISION | RATIONALE | STATUS |
|-----------|----------|-----------|--------|
| Target hardware | M5Stack AtomS3 Lite (ESP32-S3FN8, 8 MB flash, **no PSRAM**), one USB port shared between USB-Serial/JTAG and TinyUSB | `platformio.ini` header, README "The one USB port" | LOCKED |
| Build | PlatformIO `espressif32@8.0.0` (Arduino core 3.3.12), envs `bridge` (HID) and `console` (`ARDUINO_USB_MODE=1`, `KR_HAVE_HID == 0`) | `console` is the only thing that compiles the no-HID branches | LOCKED |
| Control plane | One browser page served from flash (`src/web_page.h`), one WebSocket, ASCII one-frame-one-command, no JSON inbound | invariant 7 | LOCKED |
| Pure modules | `keystate.h`, `proto.h`, `text.h`, `mouse.h`, `output.h`, `ws_codec.h`, `sha1.h` include no Arduino/IDF header, so the identical code runs on a host under ASan/UBSan | `tests/run_tests.sh` | LOCKED |
| Crypto/TLS stack | **mbedTLS + esp-tls exactly as shipped by the installed core** — no new third-party dependency. Verified in `~/.platformio/packages/framework-arduinoespressif32-esp32s3-libs`: `flags/ld_libs` carries `-lmbedtls_2 -lmbedcrypto -lmbedx509 -lmbedtls -lesp-tls -llwip`; `esp_tls_server_session_create/_continue_async/_delete`, `esp_tls_conn_read/write`, `mbedtls_ssl_setup/set_bio/handshake/conf_max_frag_len`, `mbedtls_pk_write_key_pem`, `mbedtls_ecp_gen_key`, `mbedtls_ctr_drbg_seed`, `mbedtls_x509write_crt_init`, `mbedtls_base64_encode` all resolve; sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`, `ECP_DP_SECP256R1`, `KEY_EXCHANGE_ECDHE_ECDSA`, `SSL_ALPN` | the user chose full WSS; nothing new has to be fetched, vendored or audited | LOCKED |
| TLS transport shape | **HTTPS on 443 + `wss://`**, one TLS context for the controller and a short-lived one per read-only request; `:80` becomes redirect-only | a page loaded over `http://` can be replaced on-path by an attacker who then owns the keystroke path, so half-encryption is not a smaller risk — it is a smaller *apparent* risk | LOCKED |
| TLS identity | **Self-signed ECDSA secp256r1 certificate generated on first boot, PEM key+cert in NVS, SHA-256 fingerprint printed once on the serial console** for pinning; regenerated only if NVS is erased | keeps the private key out of the repo (AGENTS.md), survives reflashing (NVS does), and a per-board key means stealing the repo does not buy a MITM position | LOCKED |
| Plaintext rollback | `-D KR_TLS=0` builds the current `ws://`-only behaviour; documented in README as the rollback, same standing as `mouse off` | a device that cannot be reached over TLS after a bad provisioning change must have a way back that is a reflash, not a repair | LOCKED |
| Arming | Unchanged by every phase in this roadmap: TLS, takeover and mobile input are all gated on the same arming as a keystroke today | invariant 2 | LOCKED |
| Host gate | `tests/run_tests.sh` (+ `SANITIZE=1`) and both PlatformIO builds; `pio run -t upload` never runs inside `.agents/validate.sh` | AGENTS.md: an upload in a gate is a device side effect on somebody else's schedule | LOCKED |
## High-Level Architecture
```
laptop / phone browser target machine
src/web_page.h -- https:// + wss:// ---> ┌───────────────────────────────┐
│ CODE_TO_USAGE, Pointer Lock, │ AtomS3 Lite (ESP32-S3) │
│ touch pad, soft keyboard │ src/main.cpp loop(), arming│
▼ │ src/control_server.{h,cpp} │
one ASCII frame (T N X A S C Y P M R K) │ ├ src/tls.{h,cpp} mbedTLS │ USB HID → target
one status frame (S{json}) <---│ ├ src/ws_codec.h SHA-1/WS │
│ ├ src/proto.h → keystate, │
tools/relayctl.py (terminal controller, │ │ text, mouse, output │
stdlib ssl, same frames) │ └ serial console (no typing)│
└───────────────────────────────┘
```
- `ControlServer` owns exactly one controller socket plus one read-only socket, and both
read-only paths go through `serveReadonly()` so they cannot drift (invariant 8). Phase
`02` puts a transport underneath it (plain socket vs mbedTLS session) without splitting
the routing.
- `main.cpp` owns arming, the report queue and `releaseEverything()` — the single funnel
that clears the queue, raises the keys, ends the chord and discards a pasted run.
- The page is one C++ raw string; `tests/test_page.js` executes it against a DOM shim.
## Data Model
- **Key state**: `S=<mods>:<usages>` restates everything; applying the same state twice is
a no-op. No events, no sequence numbers, no resynchronisation timer.
- **Mouse**: `M=<btn>:<dx>:<dy>:<wh>:<pan>`, button byte restated every frame, deltas
signed-byte bounded.
- **Status** (`statusJson()` in `control_server.cpp`): `v up usb armed net ssid ip keys
chords drop roll leds dflt ap typed typing mouse mbtn mframes`. `typed`/`typing` are
counts, never text — that rule is not negotiable for any new field either.
- **NVS** (namespace in `src/main.cpp`, survives reflashing): `sta_ssid`, `sta_pass`,
`token`, `ap_mode`, `mouse`. Phase `02` adds `tlscrt`, `tlskey` (PEM) and `tlscrtfp`.
- **Persistence never holds text.** No macro store, no schedule, no replay, no auto-arm.
## Validation / Verification Workflow
1. **Host unit/integration** — `tests/run_tests.sh`: `test_keystate`, `test_proto`,
`test_text`, `test_mouse`, `test_output`, `test_ws_codec` compiled from the *same*
headers the board runs, plus `test_relayctl.py` (tool↔page↔`config.h` tables) and
`tests/test_page.js` (the page script, executed). `SANITIZE=1` for anything touching a
buffer. Baseline at the start of this roadmap: **3406 checks, all passing**.
2. **Firmware builds** — `uv run --python 3.14 --with platformio python -m platformio
run -e bridge -e console` (the `pio` on PATH is broken; `pio run -t upload` reports
SUCCESS on a stale binary, so build status is checked before anything is trusted).
Baseline: bridge `RAM 26.7% (87 592 B)`, `Flash 36.1% (1 135 293 B)`; console
`RAM 16.8%`, `Flash 33.6%`. Any phase that grows RAM is expected to quote the new pair.
3. **Device observation** — `tools/watch_target.py` on the target (evdev, focus
independent) is the only thing that says what the target received; `curl` against the
served page pins page claims. Best effort per the hardware rule above, logged in
`.agents/HW_VERIFICATION.md`.
4. **Gate** — `.agents/validate.sh` runs 1 + 2 (plus repo hygiene) after every task; a
phase commits only when it exits 0.
## Phase Roadmap
| # | Phase | TODO source | Objective (one line) |
|---|-------|-------------|----------------------|
| 01 | `01_verification_gate` | — (foundation) | `.agents/validate.sh` runs keyrelay's real gates (host suites, sanitiser run, both firmware builds) and the hardware contract is written down so no unit blocks on a board |
| 02 | `02_tls_control_channel` | `TODO.md` L3 | HTTPS + `wss://` with an on-board-generated self-signed EC identity, so the passphrase and the keystrokes are encrypted in transit and the board's identity can be pinned |
| 03 | `03_session_takeover` | `TODO.md` L6 | An authenticated controller can deliberately take the bridge from a session left connected; the incumbent is told, the funnel runs, and the board comes back **disarmed** |
| 04 | `04_controller_page_responsive` | `TODO.md` L4 | Less prose, tighter layout: one-screen controls on a laptop, single-column and thumb-sized on a phone, safety statements kept verbatim |
| 05 | `05_mobile_controller` | `TODO.md` L5 | Arm on a phone and the soft keyboard appears, with an on-screen modifier row; press-and-hold the pad and the target's pointer moves, release and every button lets go |
Excluded (already done, no phase): nothing — `TODO.md` held no `[x]` items.
Deferred, deliberately not in this roadmap (still in README Backlog): OS-wide evdev
capture client, GPIO panic release path, per-connection rate limiting, mDNS on the AP
interface, WPA3/PMF, report-rate telemetry, multi-run pastes, non-US paste maps.
View File
@@ -0,0 +1,49 @@
# Phase 01 — Verification gate and hardware contract
**Source:** `TODO.md` — (foundation, no TODO line). Required before the four TODO items
can be executed by subprocesses behind a pass/fail gate.
**Story:** `n/a`
**Context:** `PLAN.md` "Validation / Verification Workflow"; `tests/run_tests.sh`; `AGENTS.md` "Build, test, flash"
## Objective
Give this repository a `.agents/validate.sh` that actually fails when keyrelay is broken —
host suites (including the sanitiser run and the page suite) plus **both** firmware builds —
and write down the hardware contract so that no later unit ever blocks waiting for a board
that nobody is holding.
## Dependencies
- — (none)
## Tasks
1. `01_adapt_validate_sh.md` — author `.agents/validate.sh` with keyrelay's real checks and
the template's repo-hygiene gates, and prove it fails on a deliberately broken tree.
2. `02_hardware_contract.md` — `.agents/HW_VERIFICATION.md` + `tools/hw_probe.sh`: what can
only be checked on hardware, how to check it, the baseline RAM/flash numbers, and the
rule that a unit attempts a device step once, with a timeout, and records the outcome.
## Testing & Quality
- Unit/integration: the gate itself is the artifact. It must pass on a healthy tree **and**
fail on each injected fault: a failing C++ assertion, a page-suite id mismatch, a firmware
compile error in `src/main.cpp`, and a tracked `*.pem`.
- Coverage: n/a for shell scripts; >90% applies only to any Python added (`tools/hw_probe.sh`
is shell, not Python).
- `.agents/validate.sh` must run the **full** suite — a partial gate defeats the
"no phase commits unless 100% passing" guarantee.
## Completion Criteria
- [ ] `bash .agents/validate.sh` exits 0 on the untouched tree and prints, per check: the
suite results (`RESULT: all suites passed`), the two build results, and the hygiene verdict.
- [ ] `SANITIZE=1` is exercised by the gate (or by a documented, gate-called second pass) and
passes.
- [ ] The gate contains no `pio … -t upload`, no `curl` to a device, no network fetch.
- [ ] Each of the four injected faults makes the gate exit non-zero (demonstrated in the
task's report, then reverted).
- [ ] `.agents/HW_VERIFICATION.md` exists and names, for every device-only claim in this
roadmap, the command that observes it (`tools/watch_target.py`, the serial console,
`curl -k https://keyrelay.local/status`).
- [ ] Full test suite green; `git status` shows only planning artifacts.
@@ -0,0 +1,65 @@
# Task 01 — Author the gate: `.agents/validate.sh`
**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation: no TODO item; the
gate the other four items need)
**Story:** `n/a`
## Objective
`.agents/validate.sh` becomes the authoritative pass/fail gate for this firmware project:
the host suites (normal and sanitised), both PlatformIO environments, and the repo-hygiene
checks. Nothing in it may need a device or the network.
## Work
1. `.agents/validate.sh` — if the pipeline already installed its generic template, adapt it
in place; otherwise write it, keeping the template's three hygiene gates verbatim
(tracked-but-ignored files, key-material shapes `.pem/.key/.crt/…`, `PII_PATTERNS`).
Add, in this order, each one printing its own header and accumulating `rc`:
- `bash tests/run_tests.sh` — the C++ suites, `test_relayctl.py` and `test_page.js`.
- `SANITIZE=1 bash tests/run_tests.sh` — ASan/UBSan, the run that found 5 of the 10 bugs.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
— the exact invocation from `AGENTS.md`. **Never** `-t upload`. If `uv` is missing, the
gate **fails** with the message from `AGENTS.md` (`pio` on PATH is broken; the fallback
probe is `find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`) — a
silent skip here would let a firmware that does not compile reach `complete/`.
- A page-size sanity check that the served page's byte length equals
`kIndexHtmlLen`-by-construction: `python3` (or `grep`) counts the bytes of the raw
string between `R"HTML(` and `)HTML"` in `src/web_page.h` and prints it; the phase
final passes compare it to the README claim. No device.
2. `.gitignore` — add `.agents/phase-sessions/` and `.agents/pipeline.log` if the pipeline
has not already; never ignore `.agents/` itself.
3. Run `bash .agents/validate.sh` and record the output in the task report, including the
bridge `RAM:`/`Flash:` lines, so later phases can quote a delta instead of a guess.
4. Prove the gate bites, one fault at a time, reverting immediately after each and
re-running to confirm it goes green again:
(a) invert one assertion in `tests/test_keystate.cpp`;
(b) rename an element id in `src/web_page.h` that the script reaches for;
(c) introduce a syntax error in `src/main.cpp`;
(d) `touch certs/test.pem && git add certs/test.pem` (then `git rm --cached`).
`AGENTS.md` warns that a multi-line regex rewrite across a source file has destroyed
`main.cpp` before — make these four edits with exact-string edits only.
- ASSUMPTION: the gate is allowed to take ~30 s per task (measured: suites ≈2 s with g++
inside `localhost/ai-sandbox:ubuntu-9a08b1d500b0`, builds ≈2 s incremental). The full
recompile of `bridge` from clean is the worst case and is acceptable.
- ASSUMPTION: a missing `uv`/PlatformIO is a gate failure rather than a skip, because every
phase in this roadmap touches firmware and a skipped build check is the exact failure
`AGENTS.md` records ("`pio run -t upload` reports SUCCESS on a stale binary").
## Testing & Quality
- The four injected faults each make the gate exit non-zero; the untouched tree exits 0.
- The gate is idempotent and safe to run from any cwd (`cd` to the repo root by
`BASH_SOURCE` like `tests/run_tests.sh` does).
- No `set -e` swallowing: use the `rc` accumulation pattern so every check reports, not
just the first failure.
## Completion Criteria
- [ ] `bash .agents/validate.sh` → exit 0, printing suite results, both build results, page
byte count, and the hygiene verdict.
- [ ] Each of the four injected faults produced a non-zero exit (paste the four lines into
the report), and the tree is reverted (`git diff` clean apart from `.agents/`).
- [ ] No `upload`, no `curl`, no `podman pull`, no network access in the gate.
- [ ] Full test suite green.
@@ -0,0 +1,68 @@
# Task 02 — The hardware contract: what the gate cannot see
**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation; `AGENTS.md`
"Verification 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."*)
**Story:** `n/a`
## Objective
Make the device-only half of this roadmap explicit, runnable and non-blocking: a probe that
reports what hardware is reachable right now, and a file that pairs every device-only claim
with the exact command that would confirm it — so a unit running unattended never hangs on
a board and never claims a device result it did not observe.
## Work
1. `.agents/HW_VERIFICATION.md` — three sections:
- **Baseline** (measured, from task 01's gate run): `bridge RAM 26.7% (87 592 B) /
Flash 36.1% (1 135 293 B)`, `console RAM 16.8% (55 200 B) / Flash 33.6% (1 056 081 B)`,
`3406 host checks`. Later phases quote a delta against these numbers.
- **Claims that need hardware**, each with its command and its expected observation, at
least: keystroke/chord delivery and *which* keys a chord really was
(`python3 tools/watch_target.py` on the target); served-page byte agreement
(`curl -sk https://keyrelay.local/ | wc -c` vs the gate's page count — `http` until
phase 02 lands, `https -k` after); TLS handshake + the SHA-256 fingerprint printed once
on the serial console; takeover actually releasing the incumbent's held keys
(`watch_target.py` must show key-up *before* the new session's first key); the mobile
soft keyboard and touch pad (a phone on the board's AP, `watch_target.py` for both the
characters and the pointer deltas).
- **The blocking rule**: a unit attempts a device step **at most once**, wrapped in
`timeout`, and writes the outcome — `ok`, `skipped (no device)`, or `failed: …` — with
the command it ran into the section's table. It never loops on a port, never re-flashes
to "see if it works", and never leaves the board in ROM download mode without saying so
in the report (`tools/to_bootloader.py` drops it there deliberately; a power cycle is
the only way back and nobody may be there to do it).
2. `tools/hw_probe.sh` (executable, `set -uo pipefail`, no side effects) — prints one line
per fact and **always exits 0** (it informs, it does not gate):
`/dev/ttyACM*`/`/dev/ttyUSB*` present?; `keyrelay.local` resolves?; `https` reachable
(`curl -sk -m 2 -o /dev/null -w '%{http_code}'`)?; `http` reachable and does it redirect?;
`uv` on PATH?; `node` on PATH?; is a `watch_target.py`-capable evdev device visible
(informational only). Nothing is opened on a serial port except a `test -e`/`test -r`
check — opening it toggles DTR/RTS and resets the board, so the probe must not.
3. `.agents/validate.sh` — append `bash tools/hw_probe.sh` as the **last, non-affecting**
line (its exit status is deliberately not folded into `rc`), with a comment saying why:
it is context for whoever reads the gate output, not a check.
4. `README.md` — no behaviour change yet; only if the "End-to-end verification" section
contradicts what the probe just printed, fix the stale line. Do not restructure it here.
- ASSUMPTION: at execution time a board *may* be attached and reachable but nobody is
watching it, so "board available" is treated as best-effort. Any phase that needs an
on-target second machine (`watch_target.py`) records `skipped (no device)` rather than
inventing a result. This is the user-facing cost of running the roadmap unattended; the
file is where they pick those checks up.
## Testing & Quality
- `bash tools/hw_probe.sh` exits 0 with and without a board, with and without `uv`, from any
cwd; it never blocks (every probe has a `-m`/`timeout`).
- The gate still passes with the probe line added, and its output cannot flip `rc`.
- `shellcheck`-clean if `shellcheck` is installed; otherwise `-n` syntax check in the report.
## Completion Criteria
- [ ] `bash tools/hw_probe.sh` prints the six facts and exits 0 (paste its output).
- [ ] `.agents/HW_VERIFICATION.md` covers every phase 02–05 device claim listed above, each
with command + expected observation + the rule about one attempt.
- [ ] `bash .agents/validate.sh` still exits 0.
- [ ] Full test suite green; no firmware source touched.
@@ -0,0 +1,95 @@
# Phase 02 — Encrypted control channel: HTTPS + `wss://`
**Source:** `TODO.md` L3 — "Some encryption for password/key input. Doesn't need to be
proper x509, but something"
**Story:** `n/a`
**Context:** `PLAN.md` anchors "Crypto/TLS stack", "TLS transport shape", "TLS identity",
"Plaintext rollback"; `src/control_server.{h,cpp}`, `src/config.h`, `src/main.cpp`,
`src/web_page.h`, `tools/relayctl.py`, README "Security model and trust boundaries"
## Objective
Encrypt the control channel end to end: the board serves the page and the WebSocket over
TLS on 443 with a self-signed ECDSA certificate it generated itself and keeps in NVS, the
browser pins its SHA-256 fingerprint once, and the passphrase never crosses the network in
cleartext again. `:80` stops carrying anything except a redirect, so an on-path attacker can
no longer replace the page or read the keystrokes.
**Why not "just encrypt the passphrase".** A challenge–response would hide the passphrase
and nothing else: the keystrokes, the pasted text and the pointer stay readable on a network
the user does not control, and a page served over `http://` can be replaced on-path by
anybody who can see the traffic — at which point the attacker does not need the passphrase,
they need one keystroke from a page they wrote. The user asked for TLS rather than the
lighter option, and the trust-boundary table in the README only closes under TLS.
## Safety position (unchanged, and load-bearing for every task here)
TLS is **transport**, never an arming input. Arming still needs the passphrase, the board
button or the serial console; a TLS session that is authenticated but not armed types
nothing; every fail-safe in `AGENTS.md` keeps working when TLS drops, because a dropped
session is a lost link, which runs the release funnel and disarms. The rollback `-D
KR_TLS=0` restores today's behaviour and must stay buildable.
## Dependencies
- `01_verification_gate` (todo) — the gate must be running the real checks before a change
this central can be trusted; the hardware contract tells this phase how to observe the
board without blocking on it.
## Tasks
1. `01_tls_identity_module.md` — `src/tls.h`: on-board ECDSA secp256r1 keypair + self-signed
cert via mbedTLS, with the pure PEM/fingerprint helpers host-tested in
`tests/test_tls.cpp`.
2. `02_identity_persistence.md` — NVS storage of the PEM pair, generate-once, serial `cert`
command printing the fingerprint and never the key, `StatusSnapshot` visibility.
3. `03_tls_listener.md` — the 443 listener, non-blocking mbedTLS session over the accepted
socket, session lifetime, heap cost measured against the phase 01 baseline.
4. `04_transport_abstraction.md` — `ControlServer` routes through one transport interface so
`serveReadonly()` stays single-sourced; `:80` becomes redirect-only and plaintext `/ws`
is refused rather than downgraded.
5. `05_page_client_fingerprint.md` — the page's `wss://` path and pinning banner,
`relayctl.py` over stdlib `ssl` (`--ca`, `--insecure`), and the cross-checks that keep
page, tool and firmware agreeing.
6. `06_tls_docs_rollback.md` — README security model/wire table/quick start/backlog,
`AGENTS.md` stale statements, and the `-D KR_TLS=0` rollback build proven to compile.
## Testing & Quality
- Unit/integration: `tests/test_tls_pem.cpp` (new suite registered in `tests/run_tests.sh`
`suites=()`) covering the pure half only — PEM armour round-trip, fingerprint formatting,
SAN/subject string building, PEM truncation and CRLF handling, rejecting a key/cert pair
that does not match. The mbedTLS-bound half is exercised by the firmware build plus the
device check (`openssl s_client`, browser, `watch_target.py`).
- `tests/test_proto.cpp`: nothing on the wire changes shape, so its 2 327 checks must stay
green untouched — that is the point: **this phase does not change the protocol.**
- `tests/test_relayctl.py`: TLS connection setup, refusal to fall back to plaintext
silently, `--insecure` opt-in is loud, and the existing frame/table pins unaffected.
- `tests/test_page.js`: the connect path still builds the right URL for both `http:` and
`https:` origins, the fingerprint banner renders only when the board reports TLS, and
every element id still exists in the markup.
- Coverage: **>90%** on new/modified code (the pure module is fully walked; the socket layer
is measured by the device checks and stated, not faked).
- `.agents/validate.sh` runs the suites, the sanitiser run and **both** firmware environments
after every task; `bridge` must still link, and `console` (`KR_HAVE_HID == 0`) too.
## Completion Criteria
- [ ] `bash .agents/validate.sh` green, and the report quotes `bridge` `RAM:`/`Flash:`
against the phase 01 baseline (`RAM 26.7% / Flash 36.1%`) plus the measured heap
cost of one and two live TLS sessions.
- [ ] A fresh board (or `nvs erase`) generates an identity at first boot and prints its
SHA-256 fingerprint **once**, on the serial console; reflashing keeps the same
fingerprint (NVS survives), and nothing about the key ever appears in a log.
- [ ] `curl -sk https://keyrelay.local/ | wc -c` equals the page byte count the gate prints,
and `curl -s http://keyrelay.local/` no longer serves the page (it redirects).
- [ ] The browser page over `https://` reaches `linked`, arms, types, and `watch_target.py`
on the target sees the keystrokes; `relayctl.py --ca <fingerprint's pem>` and
`--insecure` both connect and type; a plaintext `ws://` upgrade on :80 is refused.
- [ ] `grep -rn "\.pem\|\.key" --include=* .` finds no key material in tracked files; the
hygiene gate in the gate script is still enabled.
- [ ] `-D KR_TLS=0` still builds `bridge` (the rollback path), and README says how to use it.
- [ ] README (intro check count, Safety model, Security model table + the paragraph that
currently *admits* the token is visible in cleartext, Wire protocol, Controller page,
Architecture file map, Tests table, Quick start, Troubleshooting, Design decisions,
Backlog item 2) and `AGENTS.md` stale statements updated in this phase's commit.
@@ -0,0 +1,87 @@
# Task 01 — Device identity: keypair + self-signed certificate on the board
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
`kr::tls` can, on the board, produce an ECDSA secp256r1 keypair and a self-signed X.509
certificate for `keyrelay.local`, as PEM strings, plus a SHA-256 fingerprint of the
certificate — with the string-handling half of that testable on a host.
## Work
1. `src/tls_pem.h` — **new, pure** module (no Arduino, no IDF headers; the rule in
`AGENTS.md` exists so this compiles and runs under sanitisers on the host):
- `wrapPem(const char *label, const uint8_t *der, size_t derLen, char *out, size_t cap)`
→ `-----BEGIN <label>-----\n` + base64 in 64-char lines + `\n-----END <label>-----\n`;
returns the length, `0` when `cap` is too small (never truncates silently — invariant 5).
- `fingerprintHex(const uint8_t *digest, size_t digestLen, char *out, size_t cap)` →
lowercase hex, `out` sized by the caller; refuses a `cap` that cannot hold it.
- `subjectLine()` / `sanList()` builders producing
`CN=keyrelay.local`, `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1` —
the names in `src/config.h` (`kHostname`, `kApGw`), not literals invented here.
- `pemFind(label, haystack, out, cap)` → extracts the DER back out of an armour block
(tolerant of CRLF and of a missing trailing newline), `false` on a malformed block.
2. `src/tls.h` (+ `src/tls.cpp` if it needs a translation unit) — the mbedTLS half:
- `bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap)`: `mbedtls_entropy_init`
+ `mbedtls_ctr_drbg_seed` (personalisation string `"keyrelay"`) →
`mbedtls_pk_setup` + `mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, …)` →
`mbedtls_x509write_crt` (subject == issuer, serial from the DRBG, validity from build
time to +10 years, `MBEDTLS_MD_SHA256`, SAN extension) →
`mbedtls_x509write_crt_der` → `wrapPem("CERTIFICATE")`; key via
`mbedtls_pk_write_key_pem`. Free everything on every exit path, including the error
paths; no early `return` that leaks a context.
- `bool selfCheck(const PemPair &)` — reparse cert+key and prove they match; used at boot
so a half-written NVS record is reported instead of producing a listener that fails
every handshake.
- Header comment must record *how this was verified linkable*: `flags/ld_libs` of
`framework-arduinoespressif32-esp32s3-libs` carries `-lmbedtls_2 -lmbedcrypto
-lmbedx509 -lesp-tls`, and sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`,
`ECP_DP_SECP256R1_ENABLED`, `KEY_EXCHANGE_ECDHE_ECDSA`.
3. `src/config.h` — `kCertCommonName[]`, `kCertValidityYears`, `kPemMax` (sized from the
measured PEM lengths plus headroom, with the numbers in a comment), and
`#ifndef KR_TLS / #define KR_TLS 1` so the rollback flag has one home.
4. `tests/test_tls_pem.cpp` — **new suite**, registered in `tests/run_tests.sh` in
`suites=()`. It includes `src/tls_pem.h` **only** — it must never include `src/tls.h`
or anything pulling in `mbedtls`, or the host build breaks and the pure-module rule is
gone:
PEM round-trip (`wrapPem` → `pemFind` → same bytes) for a DER blob of 0/1/45/46/47/n bytes
around the 64-char line boundary, CRLF and no-trailing-newline tolerance, refusal when
`cap` is one byte short, `fingerprintHex` against the known SHA-256 of `"abc"`, SAN list
containing every name the README tells the operator to expect, and ASan-clean runs of all
of it under `SANITIZE=1`.
5. `platformio.ini` — `-D KR_TLS=1` for `bridge` and `console`, in the existing
`build_flags` blocks, with a comment naming the rollback value.
- ASSUMPTION: **on-board generation rather than a keypair built on the host and flashed.**
The alternatives were rejected on record: committing a PEM would put every board behind
one key *and* trip the pipeline's key-material hygiene gate; a host-generated per-user PEM
makes the first build fail for anybody who has not run a script. Nothing in `src/` may
contain a key.
- ASSUMPTION: the certificate is generated at boot when NVS holds none, and regenerated only
when NVS is erased — see task 02. A changed fingerprint after an erase is expected and
documented, not a fault.
- ASSUMPTION: ECDSA secp256r1 rather than RSA 2048 — this board has no PSRAM, and the EC
pair is a fraction of the RAM and flash cost of RSA while being the stronger key. Browser
support for self-signed EC certs is universal on the platforms the README targets.
## Testing & Quality
- `bash tests/run_tests.sh` → the new suite passes and is listed; `SANITIZE=1` variant clean.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
→ both link (this is the task that proves the mbedTLS symbols resolve in a real image).
- The pure module includes no Arduino/IDF header: `grep -n "Arduino.h\|esp_\|mbedtls"
src/tls_pem.h` finds nothing.
- Coverage **>90%** on `src/tls_pem.h` (walked exhaustively), stated for `src/tls.*`.
## Completion Criteria
- [ ] `tests/test_tls_pem.cpp` runs in the suite and the PEM boundary cases (0/45/46/47 bytes,
one byte short of `cap`) each have a named check.
- [ ] `generateIdentity()` compiles and links into `bridge` **and** `console`; the report
quotes the new `RAM:`/`Flash:` pair against `bridge 26.7% / 36.1%`.
- [ ] `tests/test_proto.cpp` untouched and still at its previous check count — the wire
format did not change in this task.
- [ ] Full test suite green; no key material anywhere in the tree (`git status`, `grep`).
@@ -0,0 +1,73 @@
# Task 02 — Keep the identity across reflashes: NVS, first boot, `cert`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the identity half: an encryption key has to live somewhere that is not
a file in this repo)
**Story:** `n/a`
## Objective
The keypair and certificate are generated once, stored in NVS, reused after a reflash, and
made observable to the operator as a SHA-256 fingerprint — on the serial console and in the
status frame — without the key ever being printed, echoed, or written to a file.
## Work
1. `src/main.cpp` — the NVS half (follow the existing shape exactly: `Preferences prefs;`,
`prefs.begin(kNvsNamespace, true/false)` / `end()`, `constexpr char kKey…[]` next to
`kKeyToken`):
- `kKeyTlsCert[] = "tlscrt"`, `kKeyTlsKey[] = "tlskey"`, `kKeyTlsFp[] = "tlscrtfp"`.
- `loadTlsIdentity()` at boot: load cert+key+fingerprint; if any is missing **or**
`kr::tls::selfCheck()` fails, generate, store, and print the fingerprint **once**
(`[tls] identity generated — pin this certificate:` then the hex, wrapped 2-by-2).
Never `Serial.printf` the key, the cert body, or a length that identifies the text.
- A corrupt/half-written record regenerates rather than booting a listener that cannot
complete a handshake — and says so, once, loudly.
2. `src/main.cpp`, serial console — a `cert` command next to `token` in the help text
(`~main.cpp:893` block) and the dispatch chain (`~main.cpp:1010`): prints the
fingerprint, the certificate's notBefore/notAfter and the SAN list. **No** argument form
that writes a key, **no** subcommand that dumps the PEM. Update the help line and the
`usage` output.
3. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool tls` and
`const char *fp` (the first 8 bytes of the fingerprint as 16 hex chars, sized by a named
constant); `statusJson()` emits `"tls":u,"fp":"…"`; `kStatusJsonCap` grows accordingly
with the arithmetic in the comment (the reason that constant exists is that a truncated
status lies about state).
4. `src/main.cpp` — fill both fields where `StatusSnapshot` is populated (`~main.cpp:522`,
next to `s.defaultToken`).
5. `tests/test_proto.cpp` — extend the status-frame assertions: the new fields render, a
`tls:0` snapshot renders `fp:""`, and the rendered frame still fits
`kStatusJsonCap` for the longest legal SSID (that test exists; make sure the new fields
do not push it over).
6. `README.md` — "First bring-up" / Quick start: the fingerprint appears once at first boot,
`cert` reprints it, an `nvs erase` changes it, and the browser asks you to accept the
certificate once per browser. Security-model wording is task 06's job; do not restate the
whole model here.
- ASSUMPTION: the fingerprint lives in NVS next to the cert rather than being recomputed at
boot, so `cert` costs no hashing and the page can show it from the status frame; it is
public data, so storing it is not a secret-at-rest question.
- ASSUMPTION: `Preferences.putString` is fine for ~1 KB PEM blobs in the existing namespace
(`huge_app.csv` gives NVS 20 KB, already holding SSID/pass/token) — verify with the boot
log and the "nvs: " lines; if it does not fit, the fallback is a dedicated small partition
and that is a stop-and-report, not a silent change of layout.
## Testing & Quality
- `tests/test_proto.cpp`: new status fields pinned, cap arithmetic pinned.
- `bash .agents/validate.sh` green, including `console` (`KR_HAVE_HID == 0`) — the console
environment is what proves the TLS/identity path does not depend on USB HID.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): first boot prints a
fingerprint; reflash prints the same one; `cert` prints it again; `grep` over the serial
transcript for `PRIVATE KEY` finds nothing. Record `ok`/`skipped (no device)`.
## Completion Criteria
- [ ] `statusJson()` output contains `"tls":1` and a 16-hex `fp` on a TLS build; the frame
length is asserted `<= kStatusJsonCap`.
- [ ] `cert` on the serial console prints fingerprint + validity + SANs and no PEM body.
- [ ] Fingerprint survives a reflash (or the report says `skipped (no device)` and the exact
commands to confirm it).
- [ ] No private key material in any file, log, or `Serial.printf` (`git grep -i "private
key" src/` finds only the mbedTLS API call names).
- [ ] Full test suite green.
@@ -0,0 +1,106 @@
# Task 03 — The TLS listener: one session at a time, never blocking the loop
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
A second listener on 443 accepts a socket, drives an mbedTLS **server** handshake from
`tick()` without ever blocking `loop()`, and hands the control server a byte pipe that looks
like the plain socket it already reads. The keyboard path must not wait on the network stack
— that is the reason `tick()` is non-blocking today, and TLS must not break it.
## Work
1. `src/tls_session.h` (+ `.cpp`) — `kr::tls::Session`, one TLS connection over an already
accepted fd:
- `bool open(int fd, const PemPair &id)` — `mbedtls_ssl_init` +
`mbedtls_ssl_config_init` + `mbedtls_ssl_config_defaults(...,
MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)`,
`mbedtls_ssl_conf_endpoint(MBEDTLS_SSL_IS_SERVER)`, `mbedtls_x509_crt_parse` +
`mbedtls_pk_parse_key` + `mbedtls_ssl_conf_own_cert`, and
`mbedtls_ssl_set_bio(&fd, recvCb, sendCb, NULL)` with `recvCb`/`sendCb` wrapping
`::recv`/`::send` on the fd and mapping `EWOULDBLOCK` to
`MBEDTLS_ERR_SSL_WANT_READ`/`WANT_WRITE`. Set the fd non-blocking
(`fcntl(fd, F_SETFL, O_NONBLOCK)`) before the handshake. Client certification is **not**
requested (`MBEDTLS_SSL_VERIFY_NONE`) — the passphrase is still the controller's
credential, not the certificate.
- `Handshake poll()` → `InProgress | Ready | Failed`, calling `mbedtls_ssl_handshake()`
once per `tick()` and returning on `WANT_READ`/`WANT_WRITE`. A session that has not
completed within `kTlsHandshakeMs` is closed — a socket that opens and never finishes a
handshake is the same kind of scan as one that opens and never sends `T<token>`.
- `size_t read(uint8_t *dst, size_t cap)` / `size_t write(const uint8_t *src, size_t len)`
— `mbedtls_ssl_read`/`mbedtls_ssl_write`, returning 0 on `WANT_READ`/`WANT_WRITE` and
never spinning; a short write is returned as a short write (the caller keeps its
framing state, exactly as with `WiFiClient`).
- `bool alive()`, `void close()` — `mbedtls_ssl_session_reset` + `mbedtls_ssl_free` +
`mbedtls_x509_crt_free` + `mbedtls_pk_free` + `::close(fd)` on **every** path,
including the error paths out of `open()`.
- Do **not** use `esp_tls` for this: `esp_tls_server_session_create()` owns the socket
lifetime and its handshake is not driven the way `tick()` needs. Note that in the
header comment along with what was checked in `esp_tls.h`, so the next reader does not
"simplify" it back.
2. `src/control_server.h` / `.cpp` — a second `WiFiServer _tlsServer(kHttpsPort)`;
`acceptNew()` accepts from both listeners; `tick()` gives the TLS path the same shape as
the existing one: `acceptNew → serveHttp/serveWebSocket → timeout checks`. Keep the
controller slot in exactly **one** place: a socket is either HTTP, WebSocket or none, no
matter which listener it came from, so a TLS controller and a plaintext controller can
never both hold the keyboard.
- At most **two** live sessions at once (`kTlsMaxSessions`): the controller's, and one
more — a short-lived read-only request destroyed the moment its response is flushed,
or (phase 03) a pending takeover claim. A third connection is refused **explicitly**
(TLS alert then close, plus a counter), never queued and never half-opened: the
read-only path must fail visibly rather than push the board out of memory. This is a
RAM decision, not a policy one — say so in the comment, and keep the refusal
independent of which listener the socket came from.
3. `src/config.h` — `kHttpsPort = 443`, `kTlsHandshakeMs = 10000`,
`kTlsMaxSessions = 2`, and the RAM note: `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN` is 16384 in
this core, so a session's I/O buffers are ~2×16 KB. Try
`mbedtls_ssl_conf_max_frag_len(&ssl, MBEDTLS_SSL_MAX_FRAG_LEN_4096)` **only** if the
measured heap says it is needed, and record the measurement either way.
4. Measure, in the report and in `.agents/HW_VERIFICATION.md`: `ESP.getFreeHeap()` at boot,
with one session open, with a second (viewer) session open, and after both close —
against the phase 01 baseline (`bridge RAM 26.7% / 87 592 B`). Print the numbers in the
boot log behind `KR_DEBUG` if the project has a debug switch, otherwise log once at
startup and once per session close *as numbers only* (invariant 4: nothing about content).
5. `tools/hw_probe.sh` (from phase 01) — extend with a `openssl s_client -connect
<ip>:443 -servername keyrelay.local` probe wrapped in `timeout 5` reporting protocol and
cipher, informational, exit status unchanged.
- ASSUMPTION: raw mbedTLS rather than `esp_tls` (rationale above). If raw mbedTLS turns out
unable to complete a handshake non-blockingly on this core, the fallback is `esp_tls` in
async mode on the same fd — but that is a design change to be recorded in the task report
and the README's Design decisions, not a quiet swap.
- ASSUMPTION: TLS on 443 only, no plaintext WebSocket on 8443, no dual-stack port list. The
rollback is `-D KR_TLS=0` (task 06), not a second open port.
- ASSUMPTION: 2 concurrent sessions is the ceiling. Today's server already holds at most one
controller plus one one-shot viewer, so the policy is unchanged; only the cost per socket
went up.
## Testing & Quality
- Host: the parsing/timeout arithmetic that can be isolated (handshake-deadline comparison,
"a session that never completes is dropped", "read returns 0 rather than spinning") goes
into a pure helper in `src/tls_session.h` guarded the same way `output.h` is, and is
checked in `tests/test_tls_pem.cpp` or a new `tests/test_tls_session.cpp` if the helper is
big enough — no mbedTLS includes in either.
- Firmware: `bridge` and `console` both build; the report quotes the four heap numbers.
- Device (best effort, one attempt): `openssl s_client` completes a handshake and negotiates
`TLSv1.2`/`TLSv1.3` with `ECDHE-ECDSA-*`; a second concurrent connection is accepted while
the first is open; a connection that never completes the handshake is dropped after
`kTlsHandshakeMs` and the loop keeps typing (verify by watching the LED heartbeat / a
`status` poll during the stalled attempt).
- The keystroke path's latency is unchanged while a *second* client connects — that is the
regression this task can introduce, so it gets named in the report even when the answer is
"not measured".
## Completion Criteria
- [ ] A TLS connection to :443 completes a handshake and survives `kTlsHandshakeMs`-bound
teardown when it does not.
- [ ] `loop()` never blocks on the TLS path: the report states how that was shown (code walk
of every `mbedtls_ssl_*` call site plus the device observation), not asserted.
- [ ] Heap numbers recorded for boot / 1 session / 2 sessions / after close, and no leak over
20 connect/disconnect cycles.
- [ ] `bash .agents/validate.sh` green; full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,89 @@
# Task 04 — One routing path, two transports: TLS is not a second server
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the routing half: the routes must not fork into a secure one and an
insecure one that can disagree)
**Story:** `n/a`
## Objective
Put the transport underneath `ControlServer` instead of beside it: every route — page,
`/status`, `/ws`, the read-only viewer — is served through `serveReadonly()`/`serveHttp()`
once, over either a plain socket or a TLS session, and `:80` stops carrying content: it
redirects and otherwise tells the client to use `https://`. Invariant 8 explicitly requires
that both routes keep going through `serveReadonly()` "so they cannot drift apart"; a
half-encrypted server is the shape that drifts.
## Work
1. `src/conn.h` — a small transport view used by the routing code: `read(dst,cap)`,
`write(src,len)` (may be partial — returns what went), `alive()`, `stop()`, and
`writeProgmem(src,len)` for the page. Implemented by `PlainConn` (wraps `WiFiClient`) and
`TlsConn` (wraps `kr::tls::Session` from task 03). No virtual calls in the keystroke path
if a tagged union is cheaper — measure, then choose, and say which in the comment.
2. `src/control_server.cpp`:
- `readSome()`, `writeTextFrame()`, `httpResponse()`, `httpError()`, `sendStatus()`,
`sendError()` go through the conn. **`readSome()` keeps its rule**: only bytes from an
authenticated controller on the WebSocket refresh `_lastInboundMs`. A TLS session must
not change that — a handshake, a `/status` poll or a viewer refresh is not the operator
being alive (that comment in `readSome()` explains a real bug; carry it over).
- `serveReadonly()` and `serveViewer()` keep serving *both* listeners; a read-only
request over TLS is the same code with a different conn.
- The page write becomes **resumable**: `c.write_P(kIndexHtml, kIndexHtmlLen)` works for a
`Print`, not for a socket. Copy PROGMEM into a bounded RAM chunk (name the size) and
carry a per-connection send cursor (`sent`, `total`) so a 35 KB page drains across
several `tick()`s under a per-tick byte budget. If the client stalls, the existing 10 s
stall rule drops it. **Never** block in `loop()` waiting for a slow page consumer, and
never queue the page in RAM beyond the chunk.
- `closeLink()` stays the single funnel: whichever transport it is handed, it releases on
link change (`wasLinked` semantics, bug 10) and destroys the TLS session. A TLS teardown
failure must not skip the release.
3. `:80` behaviour (`KR_TLS == 1`):
- `GET /` and `/index.html` → `301` with `Location: https://<host>/…`.
- A request carrying a `token` query parameter (i.e. `/status?token=…`) → **`426 Upgrade
Required`** with a one-line body telling the operator to use `https://`, and **no
redirect** — a redirect that echoes `?token=` would expose the passphrase twice instead
of once. `AGENTS.md` says the token stays a query parameter on `/status` because `curl`
is the point of that endpoint; this keeps `curl` working (over TLS) without making the
plaintext path leakier.
- A `/ws` upgrade on :80 → `426`, plaintext controller refused rather than silently
downgraded (invariant 5: never quietly accept the weaker thing).
- All of the above is compiled out by `-D KR_TLS=0`, which restores today's `:80` server
exactly.
4. `src/config.h` — `kHttpRedirectPort`/`kHttpsPort` naming, `kPageChunkBytes`, and the
per-tick page budget, each with the measured numbers that chose it.
5. `tests/test_relayctl.py` + `tests/test_page.js` — assert the *refusal* shapes that can be
checked from the client side: the tool's error text distinguishes "board wants TLS" from
"wrong passphrase", and the page's URL builder picks `wss://` from an `https:` origin and
`ws://` only on a `-D KR_TLS=0` build (pin it by asserting the served page contains no
plaintext `ws://` literal when TLS is on).
- ASSUMPTION: 426 (Upgrade Required) for token-carrying and WebSocket requests on the
plaintext port, 301 for everything else. The alternative — redirect everything including
`?token=` — was rejected for the reason above.
- ASSUMPTION: mDNS stays one service on port 80 plus a second `_https._tcp` advertisement;
`keyrelay.local` must keep resolving, which is the whole point of the README's "IP if the
name fails" note.
## Testing & Quality
- Host: no protocol change, so `tests/test_proto.cpp` (2 327 checks) and `test_output.cpp`
must stay green untouched — if either needs editing, the task has changed the wire or the
funnel and must stop and say what it changed and why.
- The `readSome()` rule gets a **source pin** in `tests/test_relayctl.py` alongside the
existing three: the TLS conn's bytes reach `_lastInboundMs` only when
`_phase == WebSocket && _authed`. (Pinning source sentences is how bug 10 stays dead.)
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
gate's page count; `curl -sI http://keyrelay.local/` is a 301; `curl -s
'http://keyrelay.local/status?token=x'` is 426 and its body contains no token; the page
still reaches `linked` over `wss://` while a second tab watches `/status`.
- RAM: the chunk size + one page cursor, quoted in the report.
## Completion Criteria
- [ ] One `serveReadonly()` implementation reachable from both listeners (a `grep` for a
second HTTP response writer in `src/` comes up empty).
- [ ] Page delivered intact over TLS (byte-for-byte, `wc -c` and a `sha256sum` against the
PROGMEM count) and the send is resumable, not blocking.
- [ ] `:80` serves only the redirect and the two 426s; no keystroke-bearing path listens on 80.
- [ ] `bash .agents/validate.sh` green; the new source pin present; full test suite green.
@@ -0,0 +1,86 @@
# Task 05 — The client side of pinning: page and `relayctl.py`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the half the operator actually touches: a browser that must trust the
board, and the terminal controller)
**Story:** `n/a`
## Objective
The page talks `wss://` when it was served over `https://`, shows the board's certificate
fingerprint so the operator can confirm it against the serial console, and refuses to accept
a passphrase when it was loaded over plaintext; `relayctl.py` speaks TLS over stdlib `ssl`
with **fingerprint pinning** as the default posture, `--ca` for a real CA, and `--insecure`
only as a loud, explicit opt-in.
## Work
1. `src/web_page.h`:
- `connect()` already picks `wss://` from `location.protocol === "https:"` — keep it, and
add the guard the other way round: when the page was loaded over `http:` while the board
reports `tls:1`, do **not** open a socket and do **not** accept the passphrase input;
say why ("this page arrived in the clear; reload over https"). Do not silently fall
back to `ws://`.
- A fingerprint line in the Connection card, driven by the status frame: `certificate
SHA-256 aabbccdd… — confirm it against the `cert` command on the board's serial
console`. Rendered only from `s.fp` (task 02), never assembled from anything the page
invents, and the pill says `tls` vs `plaintext` so the state is visible without reading.
- Keep every element id the script reaches for intact (`tests/test_page.js` checks that
coverage) and add the new ids to the markup in the same change.
2. `tools/relayctl.py`:
- `Relay.__init__`: build the socket through `ssl` when TLS is on — default port 443,
`WS_PATH` unchanged.
- Pinning: `--fingerprint <sha256>` (also honoured as `$KEYRELAY_TLS_FP`) connects with
certificate verification off, then takes `getpeercert(binary_form=True)`, hashes it and
**aborts on mismatch** before a single frame is sent. That is real pinning rather than
TOFU, and it needs no CA distribution for a self-signed device certificate.
- `--ca <pem>` uses `ssl.create_default_context()` with hostname checking on.
- `--insecure` sets `CERT_NONE` and prints a warning line to stderr on every run.
If none of the three is given, refuse to connect and say what to pass — the tool must
not be the component that quietly downgrades (`AGENTS.md` invariant 5).
- Keep `--token` / `$KEYRELAY_TOKEN` behaviour, including the pre-flight check that does
not require a reachable board, and never print the token.
- `-D KR_TLS=0` boards: `--plain` (explicit) selects the old `ws://` on port 80, so the
rollback path stays scriptable and remains opt-in.
3. `tests/test_relayctl.py`: URL/port selection per flag combination; the "no TLS option
given → refuse" rule; a fingerprint mismatch aborts **before** `T<token>` is sent (assert
on the recorded frame list, which the suite already has); `--insecure` emits the warning;
`--plain` is the only path to port 80; and all existing frame/table pins unchanged.
4. `tests/test_page.js`: the `https:` origin builds `wss://`, the `http:` origin refuses and
never sends a frame; the fingerprint line renders exactly the `fp` the status frame
carried and renders nothing when `tls:0`; every id the script touches exists in the markup.
5. `README.md` — Quick start: accept the certificate warning once per browser, or pin;
`curl -sk https://keyrelay.local/status?token=…`; `relayctl.py --fingerprint`; the
fingerprint from `cert`. Troubleshooting: `SSL_ERROR_R…`/`certificate verify failed` means
the board regenerated its identity (`nvs erase`) or you are on the wrong network — which
is exactly the attack the fingerprint is for, so say it in those words.
- ASSUMPTION: fingerprint pinning is the default posture rather than shipping a CA. A CA
distributed with the repo would be one shared key for every board (rejected in task 01);
per-board CA installation on every controller device is more ceremony than a keyboard
bridge is worth. `--ca` remains for the operator who wants a real chain.
- ASSUMPTION: the passphrase stays in `sessionStorage` (as today) and stays a `/status` query
parameter (per `AGENTS.md`). Over TLS both are encrypted in transit; the remaining exposure
is the local browser and shell history, which the README already names.
## Testing & Quality
- Both host suites extended as above; `bash tests/run_tests.sh` and the `SANITIZE=1` variant green.
- `uv … run -e bridge -e console` green, and `curl -sk https://keyrelay.local/ | wc -c` still
equals the gate's page count after the markup change (the page is served from flash — an
unflashed board shows the old page, so say which you observed).
- Device (best effort, one attempt): phone or laptop reaches the page over `https://`,
`linked` appears, the fingerprint shown matches `cert` on the console, and `relayctl.py
--fingerprint …` types while `watch_target.py` on the target confirms it.
- Coverage **>90%** for the changed `relayctl.py` paths (the suite drives the connection
object against a fake socket, as it already does for frames).
## Completion Criteria
- [ ] An `http:`-loaded page refuses to connect and to accept a passphrase (shown by
`test_page.js`, and by the served bytes when the board is reachable).
- [ ] `relayctl.py` with no TLS argument refuses; with a wrong fingerprint it aborts before
sending `T<token>`; with the right one it arms and types.
- [ ] The page displays the board's fingerprint from the status frame, and the pill says
`tls`.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,92 @@
# Task 06 — Tell the truth about it: docs, backlog, and the rollback build
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something" (the documentation half
`AGENTS.md` requires in the same change: *"Docs move with the code."*)
**Story:** `n/a`
## Objective
The README stops describing a plaintext control channel — including the paragraph that
currently *apologises* for it — and Backlog item 2 ("TLS with a pinned certificate") is
closed. The `-D KR_TLS=0` rollback is built, documented and named, so the encryption is a
decision an operator can reverse rather than a dependency they are stuck with.
## Work
1. `README.md`, in place, keeping the reasoning and not just the behaviour:
- **Intro bullets + the check count**: update `**3406 checks**` to the new total and add
one bullet for what is now encrypted in transit.
- **"Security model and trust boundaries"**: the trust table — "Anyone on the board's
network" can no longer read keystrokes or the passphrase; add/adjust rows for
"Anyone who can see the traffic" (sees a TLS handshake to an unnamed host and nothing
else) and "Anyone with a copy of this firmware" (gets no key: the identity is generated
on each board). The paragraph beginning **"What the passphrase does and does not do"**
currently states that the page is plain HTTP, the socket is `ws://` and "the token is
visible at first contact"; rewrite it in place, keeping its structure — what TLS now
buys, what it does **not** buy (an unpinned connection is still an on-path risk until you
confirm the fingerprint; the target still sees whatever you type on it; the local
machine is still not trusted), and the **Practical rule** paragraph adjusted to
"the certificate you did not pin is the one thing to check".
- **Safety model**: one sentence locating TLS where it belongs — transport, not an arming
input. A TLS session that is not armed types nothing, and a dropped TLS session is a
lost link, which runs the funnel and disarms.
- **Wire protocol**: the table is unchanged (same frames), but add the line that the
transport is `wss://` and that `T<token>` no longer crosses the network in the clear;
`426` on the plaintext port documented with the error-code list.
- **Quick start**: first boot prints the fingerprint once, `cert` reprints it, the browser
asks once per browser, `curl -sk https://keyrelay.local/status?token=…`, `relayctl.py
--fingerprint`. Note the `-D KR_TLS=0` rollback right where a stuck operator looks for it.
- **Controller page**: the `tls` pill and the fingerprint line; keep the existing element
table current rather than appending a new section.
- **Architecture** file map: `src/tls_pem.h` (pure), `src/tls.h` (identity),
`src/tls_session.h` (session), `src/conn.h` (transport view), and what each owns.
- **Tests**: new `test_tls_pem` row with its real check count; the totals in the intro and
the table made to agree.
- **Measured behaviour**: the TLS numbers that task 03 measured — handshake time, heap
with 1 and 2 sessions, flash growth — as measurements, with the command that produced them.
- **Backlog**: remove item 2 and renumber the rest; if a narrower remainder exists (e.g.
"a real CA so browsers stop warning", or session-resumption/ticket tuning), that becomes
an item — do not leave a closed item in place.
- **Design decisions**: why TLS instead of a challenge–response over the passphrase; why
the identity is generated on the board rather than shipped as a PEM; why raw mbedTLS
rather than `esp_tls`; why a `token`-carrying request on `:80` is refused with 426
instead of redirected.
2. `AGENTS.md` — **fix stale statements only, do not expand**: the page-size check
`curl -s http://keyrelay.local/ | wc -c` becomes `curl -sk https://keyrelay.local/ |
wc -c` (the page is still served from flash, so the "reflash before you believe the
network" warning stays); "Current defaults worth knowing" gains the TLS identity fact in
the same one-line style (generated on board, in NVS, survives reflashing, `cert` prints
the fingerprint); the "Secrets" bullet keeps its rule and, if it does not already cover
it, the concrete instance that the private key lives in NVS and never in a file. Nothing
else in this file: phase narrative belongs in `.agents/phases/`.
3. `src/tls.h`, `src/tls_pem.h`, `src/tls_session.h`, `src/conn.h`, `tools/relayctl.py` —
the docstring at the top of each module describes *why*, in the house style of
`proto.h`/`web_page.h`/`control_server.h`: what it protects, what it does not, and the
decisions a later editor must not reverse by accident.
4. Build the rollback and prove it exists: `uv run --python 3.14 --with platformio python
-m platformio run -e bridge -e bridge --upload-port "" --build-flags "-D KR_TLS=0"` or
the equivalent temporary env (document the exact working command in the README rather
than inventing prose around it), and record the resulting `RAM:`/`Flash:` pair.
5. `.agents/HW_VERIFICATION.md` — fill in the device rows this phase touched with what was
actually observed, or `skipped (no device)` plus the command. Never a claim that was not
made.
## Testing & Quality
- `bash .agents/validate.sh` green, including both firmware environments and the rollback build.
- Every number quoted in the README matches a command in this repository (check count from
the suite, page bytes from the gate, heap/RAM/flash from the build and the boot log).
- A reader who has never seen the code can bring the board up over TLS from the README alone.
## Completion Criteria
- [ ] `grep -n "plain HTTP\|ws://" README.md` returns only statements about the rollback
build, not about the default.
- [ ] Backlog has no TLS item and the numbering is contiguous.
- [ ] The trust-boundary table reflects the encrypted channel, and the "does and does not"
paragraph names what TLS still does not protect.
- [ ] `-D KR_TLS=0` builds, and the README states the exact command and the trade.
- [ ] README check count and Tests table agree with the suite's own output; `AGENTS.md`
carries no new prose, only corrected facts.
- [ ] Full test suite green.
@@ -0,0 +1,75 @@
# Phase 03 — Take the bridge: session takeover that cannot arm itself
**Source:** `TODO.md` L6 — "Need to be able to take control if another session is connected.
Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
**Context:** `PLAN.md` "Safety position"; `src/control_server.{h,cpp}` (`acceptNew()`,
`serveViewer()`, `closeLink()`), `src/proto.h`, `src/main.cpp` (`releaseEverything()`,
`onMessage()`, `onLinkChange()`), `src/web_page.h`, `tools/relayctl.py`
## Objective
When a controller left connected on the laptop is holding the bridge, the operator on the
phone can take it: the newcomer authenticates on a *pending* claim, deliberately asks for the
bridge, the incumbent is told and dropped, **the release funnel runs**, and the board comes
back **disarmed** so the new session has to arm again on purpose.
## Why this shape
`E3` exists because two controllers typing into one machine is the ambiguity a keyboard must
not have — that stays. What changes is that "no room" was the *only* answer, which turns the
most ordinary accident (a laptop tab that kept the socket open) into a dead board until you
find the other tab or reboot the board. A claim is therefore not a new privilege: it is the
**same** privilege (the passphrase) exercised from a different socket, plus one deliberate
act, plus a guarantee that the handover cannot inherit anything.
The load-bearing rule for every task here: **a takeover can never produce an armed board.**
The incumbent's keys go up before the newcomer is adopted, `A1` from the newcomer is an
ordinary authenticated arm, and nothing in the handover path may call `arm()`.
## Dependencies
- `02_tls_control_channel` (todo) — the claim travels over the encrypted channel, and the
pending slot reuses the *one* extra socket the server already budgets (controller + one
more), so the TLS-session ceiling set in phase 02 (`kTlsMaxSessions = 2`) must exist. When
phase 02 is rolled back with `-D KR_TLS=0` the claim travels over plaintext `ws://`, exactly
like the controller does in that build — the claim adds no new security dependency.
## Tasks
1. `01_pending_claim_socket.md` — the second socket may handshakes and authenticate as a
*pending claim* instead of being refused; its own header buffer, bounded reader and
command whitelist; nothing else changes.
2. `02_takeover_funnel.md` — `X` claims the bridge: `E12` to the incumbent, the funnel, the
disarm, then adoption; silence-clock and `controllerSeen()` semantics kept honest.
3. `03_status_page_takeover_ui.md` — `busy` in the status frame, the page's TAKE OVER affordance,
`E12` handled, and the incumbent visibly disarmed with the reason in its log.
4. `04_takeover_tests_docs.md` — `relayctl.py --take-over`, the frame/pin tests that keep the
safety sentences true, README Safety/Security/Wire/Controller pages.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses (and refuses trailing garbage), `E12` renders, the
round-trip and bounds checks extended, the *existing* chord-label assertions untouched.
- `tests/test_relayctl.py`: the `--take-over` frame sequence (`T` then `X` then `H`) and the
default refusal; plus three **source pins** in the style of the existing three —
(a) the takeover path calls the release funnel before it adopts the newcomer,
(b) no code reachable from the `X` handler calls `arm()`/sets `armed`,
(c) a pending claim cannot exist without `_authed`.
These are the sentences a later edit can undo silently; they are the point of this phase.
- `tests/test_page.js`: refused-then-offered flow (a refused upgrade shows the affordance, it
does not retry-loop), `E12` disarms locally, the takeover button sends nothing until clicked.
- Coverage **>90%** on new/modified logic.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): two controllers, one
arm, one held key, one takeover — `watch_target.py` must show the key **up** before the new
session's first key, and the board must report `armed:0` across the handover.
## Completion Criteria
- [ ] A second controller can authenticate and take the bridge; a third gets `E3`.
- [ ] The handover releases every key and every mouse button and leaves the board disarmed;
demonstrated on hardware or recorded as `skipped (no device)` with the commands.
- [ ] The incumbent's page says it was taken over, disarmingly and in its log.
- [ ] The three source pins exist and fail when the corresponding code is inverted (shown in
the report, then reverted).
- [ ] `bash .agents/validate.sh` green; README safety/security text says takeover cannot arm.
@@ -0,0 +1,80 @@
# Task 01 — A pending claim: the second socket may authenticate
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected. Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
## Objective
A second socket may complete the WebSocket handshake and authenticate as a **pending claim**
while the incumbent keeps the keyboard — instead of getting a `503` and being locked out of
the board entirely. Nothing can be *done* from a pending claim yet; this task only opens the
door far enough to stand in it.
## Work
1. `src/control_server.h` — the pending slot reuses the socket that today is `_viewer`, on
purpose: one incumbent + one more, never one incumbent + two claimants, because that is
already the server's shape and it is the TLS-session ceiling from phase 02
(`kTlsMaxSessions = 2`). Add, beside `_viewer`/`_viewerHttp`/`_viewerLen`:
- `enum class ViewerRole : uint8_t { Readonly, Claim };`
- `Phase _viewerPhase` (`None`/`Http`/`WebSocket`) and its own `ws::Reader` over a
**small** buffer — `kClaimMsgCap = 64` in `config.h`: `T<token>` is at most
`kTokenMax + 1`, `X` is one byte, `K` is one byte. Anything larger is refused, not
buffered. Add a comment naming the RAM each field costs and why the claim buffer is a
sixteenth of the controller's.
- `bool _claimAuthed`, `uint32_t _claimAtMs`, and (task 02) the claim's conn.
2. `src/control_server.cpp`, `serveViewer()`:
- an `/ws` upgrade from the viewer slot is now accepted into `ViewerRole::Claim` and
`Phase::WebSocket` (the same `ws::acceptFor()` handshake as the controller path); the
`503` path stays for what comes *after* the claim slot is occupied (task 02 wires the
counting; here, keep refusing a *third* socket and make `_rejected++` mean "no room").
- A pending claim authenticates exactly like a controller: `T<token>` within
`kAuthTimeoutMs`, the same constant-time compare, the same `_authFails`/`_lockUntilMs`
counters (one lockout for the board, not one per socket), and the same close-with-nothing
on failure.
- While pending and unauthenticated, **only** `T` is honoured. While pending and
authenticated, only `T`, `K` and (from task 02) `X`; every other frame gets
`E6`-shaped refusal and the claim is dropped. No arm, no state, no text, no mouse —
spell that list out in the code comment, because it is the whole safety argument.
- The claim never touches `_lastInboundMs`: a claim attempt is not the operator being
alive (the comment in `readSome()` explains why that distinction exists). It refreshes
`_claimAtMs` instead, and a claim that goes quiet is dropped.
- The read-only routes still work from the same slot when it is *not* in Claim role —
`serveReadonly()` remains the single implementation (invariant 8).
3. `src/config.h` — `kClaimMsgCap`, `kClaimTimeoutMs` (a pending claim that has not asked for
the bridge this long is dropped; 15 s is plenty for a human click, and an unanswered claim
holding a TLS session open is a nuisance attack), with the reasoning in the comment.
4. `tests/test_proto.cpp` / `tests/test_relayctl.py` — nothing on the wire yet, so both
suites must stay green **untouched**; if either needs a change, this task grew the
protocol and must stop and say so.
- ASSUMPTION: the claim is accepted into a pending state rather than the board keeping
`503`-only and adding an HTTP "request takeover" endpoint. A second control channel would
be a second auth path — exactly the drift `AGENTS.md` warns about — while the pending
WebSocket reuses the authentication code that already exists and is already tested.
- ASSUMPTION: one claim slot at a time; a third socket gets the `503`/`E3` it gets today.
- ASSUMPTION: a claim gets a smaller message cap than the controller. A frame that does not
fit is a refusal, consistent with invariant 7 (one frame is one command) and it keeps a
hostile claimant from allocating board RAM.
## Testing & Quality
- Host: existing suites untouched and green (`test_proto` at 2 327 checks, `test_output`
unchanged). New pure logic is limited to the claim state machine's predicates (which role,
which commands allowed, deadline arithmetic) — those go in a pure helper the tests can call,
in the style of `output.h`.
- Firmware: `bridge` + `console` build; report the RAM delta from the added buffers
(`kClaimMsgCap` + one `ws::Reader` + one header buffer), against `bridge 26.7% / 87 592 B`.
- Device (best effort, one attempt): with the controller connected, a second tab's `/ws`
upgrade is accepted and authenticates; a third socket is refused; the controller keeps
typing throughout.
## Completion Criteria
- [ ] A second `/ws` upgrade while a controller is linked reaches `authenticated` as a claim
(log/observation or `skipped (no device)`), and a third is refused.
- [ ] A pending claim can arm nothing: the whitelist is one visible list in the code and the
report names every entry.
- [ ] Claim timeouts and silence drop the claim and free its session; no leak over 20 attempts.
- [ ] `bash .agents/validate.sh` green; `test_proto.cpp`/`test_relayctl.py` unchanged in this task.
@@ -0,0 +1,78 @@
# Task 02 — `X`: hand the bridge over through the funnel
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…"
**Story:** `n/a`
## Objective
One authenticated frame from the pending claim — `X` — takes the bridge: the incumbent is
told (`E12`), the release funnel runs, the board ends up **disarmed**, and only then is the
claimant adopted as the controller. The handover is a link change that the rest of the
firmware recognises, not a silent substitution of one socket for another.
## Work
1. `src/proto.h` — `Cmd::Takeover` for a lone `X`, documented in the header comment's
direction table: `c -> d X claim this bridge for myself; requires authentication, and
nothing else is honoured on a claim socket`. `proto::formatError` unchanged; add
`kErrTakenOver = 12` to `control_server.h` ("your session was taken over by another
controller") and name it in the error list comment.
2. `src/control_server.cpp` — the handover, in this order and no other:
1. `sendError(12, "session taken over")` on the **incumbent** socket, so its page can say
why it stopped. It is best-effort: an unreadable incumbent must not block the handover.
2. `closeLink("taken over")` on the incumbent — the existing funnel. `closeLink()` already
disarms and releases for anything that reached the WebSocket phase (that is bug 10's
fix; do not re-narrow it here) and calls `_onLinkChange(false)`, which in `main.cpp`
runs `releaseEverything()`. **This must happen before adoption**, so the newcomer can
never inherit a held key or an armed bridge.
3. Promote the claim: move its conn into the controller slot, `_phase = WebSocket`,
`_authed = true`, `_controllerSeen = true`, `_lastInboundMs = millis()`,
`_connectedAtMs = nowMs`, reset the controller's reader, then `OK` + `sendStatus()` and
`_onLinkChange(true)`.
4. Clear the claim slot completely (`_claimAuthed = false`, reader reset, buffers zeroed)
so a stale claim cannot be promoted twice.
- `arm()` is not called, and nothing on this path can reach it. State that in a comment at
the top of the handover function and make the function's name say it too
(`handOverClaim()` or similar — not `takeover()` which reads like an arm).
- If the incumbent is already gone by the time `X` arrives, the promotion still runs
through the same steps (the funnel is a no-op when disarmed), so there is only one path.
- `_rejected` keeps meaning "a socket was turned away", and a successful takeover gets its
own counter (`_takeovers`), reported in the status frame (task 03) — counters only, never
addresses or identities (invariant 4).
3. `src/main.cpp` — `onMessage()` handles `Cmd::Takeover` for the case where an *existing*
controller sends `X` (a no-op with `OK`, documented: you already hold it) — do not let it
fall through to `E6` — and `fillStatus()` publishes the counter.
4. `src/config.h` — nothing new; `kClaimTimeoutMs` from task 01 bounds the claim's life.
- ASSUMPTION: `X` is honoured **only** on a claim socket (or is a no-op from the controller).
It is not a general command, so a controller cannot use it to reset its own session.
- ASSUMPTION: the incumbent gets no warning window and no veto. Its operator is by
definition absent — that is the situation the TODO describes — and a veto held by an
absent tab is the lockout we are removing. The protection is that the claimant needed the
passphrase, and that the board lands disarmed.
- ASSUMPTION: `E12` is a new code rather than reusing `E3`. `E3` means "you were refused";
`E12` means "you were dropped". One of those should be alarming on the incumbent's screen
and the other should not.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses to `Cmd::Takeover`; `X` with trailing bytes is `Cmd::Bad`;
`E12` formats; the render/parse round trip covers it; the existing 2 327 checks around chord
labels and state specs stay exactly as they are.
- `tests/test_relayctl.py` — the three source pins named in the phase overview: funnel before
adoption, no `arm()` reachable from the `X` path, no promotion without `_authed`. Written
the way the existing pins are written (a named assertion per sentence, with the sentence in
the output) so the suite says out loud what it checked.
- Device (best effort, one attempt): arm on tab A, hold a key, take over from tab B —
`watch_target.py` shows the key-up and the board reports `armed:0`; tab A shows `E12`.
Record the transcript in `.agents/HW_VERIFICATION.md` or `skipped (no device)`.
## Completion Criteria
- [ ] `X` from an authenticated claim drops the incumbent with `E12` and adopts the claim.
- [ ] The board is disarmed immediately after every takeover, observed or recorded as
unobserved-with-commands — never asserted without a mechanism.
- [ ] The three source pins exist and each one fails when its sentence is broken (shown, then
reverted).
- [ ] `bash .agents/validate.sh` green; `_takeovers` counter visible; full test suite green.
@@ -0,0 +1,76 @@
# Task 03 — Make it visible: `busy` in the status frame, TAKE OVER on the page
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the operator-facing half: the accident has to be fixable
from the screen you are looking at)
**Story:** `n/a`
## Objective
A controller that finds the bridge already held can see that it is held, can see who is
holding it in the only terms the board is allowed to know (a counter and a link state, never
an address), and can take it with one deliberate click. The incumbent's screen stops being
mysterious: it says it was taken over, disarms, and shows that in its log.
## Work
1. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool busy` (a controller holds
the keyboard: `linked()`) and `uint32_t takeovers`; `statusJson()` emits `"busy":u` and
`"tkov":u`; grow `kStatusJsonCap` with the arithmetic in the comment. **No** client
address, no user agent, no timing that would identify the other session — the README's
"never stored" list is about exactly this temptation.
2. `src/web_page.h`:
- The link pill gets a third honest state: `held by another controller` (from `s.busy`)
alongside `linked` and `link down`.
- A refused upgrade no longer retries in a loop. On close-without-auth, if the board
reports `busy`, show a **take over** button next to the passphrase field. Clicking it:
open the socket, authenticate with the passphrase already in the field, then send `X` —
one click, one intent. Before the click nothing is sent; after it the page behaves like
any other controller, starting **disarmed**, with a log line naming what happened.
- `onFrame()` handles `E12`: `setArmed(false, "another controller took over")`, drop the
mouse (`dropMouse`), abort a typing run, mark the link pill, and stop reconnecting
automatically — a page that immediately re-claims would turn two tabs into a fight.
The existing `code === "3"` branch keeps its meaning for "no room for a claim".
- Keep the arming banner, the safety hints and every id the script reaches for intact —
`tests/test_page.js` checks id coverage, and phase 04 is where the prose gets trimmed.
3. `tools/relayctl.py` — `--take-over`: after authentication, if the board reports a
controller already present, send `X` and continue; without the flag, a `busy` board is a
clear refusal naming the flag. `--take-over` on a free board is a no-op, not an error.
(Task 04 finishes the tool's tests; the flag lands here so the page and the tool agree
from the start.)
4. `README.md` — the **Wire protocol** table gains `X` and `E12` (this is a four-file
protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README — all four move), the
**Controller page** element table gains the take-over affordance, and **Security model**
gets the sentence that matters: someone with the passphrase can take the bridge over, and
cannot arm it by doing so.
- ASSUMPTION: the page reconnect policy after a takeover of *your* session is "stay down until
you click". Auto-reclaim is the fastest way to make two devices fight over a keyboard.
- ASSUMPTION: `busy` is a boolean plus a counter, not "who". The README's trust table already
promises the target's user cannot detect a second controller and the board stores nothing
identifying; `busy` keeps that promise while making the state visible to the person who is
locked out.
## Testing & Quality
- `tests/test_page.js`: `busy` renders the third pill state; a refused upgrade shows the
button and sends nothing until clicked; the click sends `T` then `X` in that order and
nothing else; `E12` disarms, releases the mouse, aborts typing and does not reconnect;
every id the script reaches for exists in the markup (the existing coverage check).
- `tests/test_proto.cpp`: the new status fields render and the frame still fits
`kStatusJsonCap` with the longest legal SSID.
- `tests/test_relayctl.py`: `--take-over` frame ordering; refusal without it; no-op on a free
board; every generated frame parseable by the firmware's own parser (already a suite rule).
- Device (best effort, one attempt): the whole gesture from a phone while a laptop tab holds
the bridge, observed with `watch_target.py` on the target.
## Completion Criteria
- [ ] `s.busy` drives a distinct pill state, and the take-over button appears only when the
board says it is held.
- [ ] One click takes the bridge; the page that lost it says so, disarmed, and stays down.
- [ ] `relayctl.py --take-over` works and the default refuses; both go through the same
frames the page sends.
- [ ] README wire table lists `X`/`E12` and all four protocol locations agree (`test_proto`
and `test_relayctl` green).
- [ ] Full test suite green; no client identity anywhere in the status frame.
@@ -0,0 +1,74 @@
# Task 04 — Pin the safety sentences, the procedure, and what `AGENTS.md` now gets wrong
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the half that keeps it true next month)
**Story:** `n/a`
## Objective
Make the takeover's safety claims the kind that fail loudly when someone edits them away:
executable checks in the host suites, a written end-to-end procedure that can be run against
a real target, and the two documents that currently state the old rule corrected so they no
longer describe a refusal that no longer happens.
## Work
1. `tests/test_relayctl.py` — the phase's three source pins, final form, in the style of the
existing three ("the suite says so out loud"):
- the takeover path calls the release funnel (`releaseEverything()` / the `onLinkChange`
that runs it) **before** the newcomer is adopted — assert the ordering in
`control_server.cpp`, not the intention;
- no `arm` / `armed = true` is reachable from the `X` handler;
- a claim socket cannot reach `Phase::WebSocket` promotion without `_authed`.
Also: the existing pins (silence disarm off `controllerSeen()`, `acceptNew()` never stamps
the controller clock, `closeLink()` stamps it only for a controller link) must still pass —
the takeover touched all three of those code paths, so re-read each one and confirm the
sentence is still true rather than trusting green.
2. `tests/test_page.js` — one check per safety sentence: takeover disarms the page that lost
the bridge; a lost page does not auto-reclaim; the claim sends nothing before the click;
the mouse is released on `E12`. Reuse the shim's `fire()`; extend the shim only as far as
`WebSocket` refusal needs (it already shims `WebSocket`, `sessionStorage` and `location`).
3. `README.md`:
- **End-to-end verification**: the takeover procedure in the commands-already-in-this-file
style — terminal A `watch_target.py` on the target, terminal B `relayctl.py … key a`
held, laptop tab holds the bridge, phone takes over, and what must appear in what order
(key-up before the newcomer's first key).
- **Safety model**: a bullet in the "disarmed is the only default" list — a takeover
disarms, releases every key and every mouse button, and cannot arm.
- **Tests**: the new check counts, and the suite descriptions updated to name the takeover
pins the way the table already names bug 10's pins.
- If this phase found a real bug — a stale `wasLinked`, a double promotion, a claim that
survived its own timeout — it goes into **"Ten bugs this verification caught"** with its
root cause and how it was proven, and the section's count changes with it. A bug fixed
silently is a bug that comes back.
4. `AGENTS.md` — **stale statements only**. Invariant 8 currently reads "A second `/ws`
upgrade is refused (`E3`)"; that is now wrong. Rewrite that one sentence to say what is
true: the keyboard stays exclusive, a second `/ws` is a pending claim that must
authenticate, and only `X` moves the keyboard — with `E3` now meaning "no room for a
claim". Nothing else in the file: no phase narrative, no new sections.
5. `.agents/HW_VERIFICATION.md` — the takeover row filled with what was observed (or
`skipped (no device)` + the commands). If it was skipped, the phase overview's completion
criteria must still be honest about it — the phase's final pass restates it, it does not
delete it.
- ASSUMPTION: the pins are source-text assertions, not behavioural ones, because the handover
needs two real sockets and there is no socket harness here. That is the same trade the
project already made for bug 10, and the same caveat is written into the test's comment.
## Testing & Quality
- Each new pin is demonstrated to fail when its sentence is inverted, then reverted (report
shows both runs) — a pin that cannot fail is decoration.
- `bash .agents/validate.sh` green: suites, sanitiser run, both firmware environments.
- Coverage **>90%** on the modified logic; `test_proto.cpp` unchanged apart from the status
fields and `X`.
## Completion Criteria
- [ ] Three takeover source pins present, named in the suite output, each proven to fail.
- [ ] Page safety sentences pinned in `test_page.js` (disarm on `E12`, no auto-reclaim,
nothing sent before the click, mouse released).
- [ ] README: safety bullet, end-to-end takeover procedure, Tests table and totals consistent.
- [ ] `AGENTS.md` invariant 8 describes the claim, not the old refusal, and gained no new prose.
- [ ] Full test suite green; no behaviour change in phases 01–02 (spot-check: TLS handshake
and `busy`-free takeover both still observed or recorded as unobserved-with-commands).
@@ -0,0 +1,70 @@
# Phase 04 — The controller page: less to read, works on a phone
**Source:** `TODO.md` L4 — "Clean up keyrelay interface, don't need as much text, make it
responsive"
**Story:** `n/a`
**Context:** `src/web_page.h` (markup + `<style>` + script), `tests/test_page.js`, README
"Controller page"; `AGENTS.md` "The page is served from flash"
## Objective
Reorganise the page so the controls are the thing you see: single column and thumb-sized on a
phone, unchanged on a laptop, and the explanatory prose folded away to where it is asked for
instead of sitting between the operator and the ARM button. **No safety statement gets
weaker or shorter** — they move, they do not shrink, and each one gets a test that fails if it
disappears.
## Why the prose is load-bearing anyway
Every long hint in this page exists because someone got hurt without it: the arming banner
(Enter goes to another machine), the `Esc`-frees-the-pointer note (Pointer Lock is invisible),
the layout caveat (characters are positions), the KDE paragraph (those three buttons are the
laptop's until the board steals them). Trimming them is the right call for usability and the
wrong call for safety *unless* the trimming is pinned. So this phase writes the assertions
before it deletes the sentences.
## Dependencies
- `03_session_takeover` (todo) — its TAKE OVER affordance and the `tls` pill/fingerprint line
are new elements in the same markup; folding prose around them is easier once they exist,
and the id-coverage check has to be updated once.
## Tasks
1. `01_responsive_layout.md` — CSS and viewport only: breakpoints, thumb-sized targets, sticky
ARM bar, safe-area insets, collapsed log; no markup restructure, no id changes.
2. `02_prose_trim_pinned.md` — the safety assertions first, then the fold: long hints into
`<details>`, the footer shortened, the connection/mouse/paste paragraphs cut to one line
each with the reasoning one tap away.
3. `03_suite_size_and_docs.md` — `test_page.js` extended for the new structure, the README's
page-size claim and its duplicated `test_page` row corrected, totals made to agree, and the
`curl` byte check re-derived.
## Testing & Quality
- `tests/test_page.js` is the only thing that can see this page before a browser does
(`AGENTS.md`: a raw string is invisible to every compiler). It must cover, in addition to
what it covers today:
- **safety strings still present**: the arming warning names Enter and "every key"; the
mouse hint names `Esc` and release; the paste hint names "stop typing"/disarm cancelling a
run; the layout note says positions/US layout; the default-passphrase shout still exists.
- every element id the script reaches for exists in the markup (existing rule, re-verified
after the restructure);
- the pinned page constants (`TEXT_FRAME_MAX`, `TEXT_JOB_MAX`) still equal `config.h`;
- the script still behaves identically: arming, state frames, paste chunking, mouse
capture/movement/button ordering, `E12`.
- Coverage **>90%** on the page script's changed branches; CSS is checked by assertions on the
served bytes (breakpoints present, sticky ARM bar) plus a phone observation.
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
byte count the gate prints, and a phone shows one column with the ARM bar reachable and the
log not pushing it off screen.
## Completion Criteria
- [ ] The page works at 360 px wide and at 1440 px wide without horizontal scroll, ARM is
reachable with a thumb, and the log is collapsible.
- [ ] Every safety statement above is still on the page **and** asserted by `test_page.js`.
- [ ] The page is smaller in bytes than it is today, and README's size claim matches.
- [ ] README Tests table has one `test_page` row with the real count (the duplicate stale row
at 47 checks is removed), and the intro's total check count matches the suite output.
- [ ] `bash .agents/validate.sh` green; no behaviour change in phases 01–03.
@@ -0,0 +1,66 @@
# Task 01 — Responsive layout: CSS and viewport, nothing else
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, don't need as much text, make it responsive" (the layout half)
**Story:** `n/a`
## Objective
The page lays out for the screen it is on: one column and thumb-sized controls on a phone, the
current dense grid on a laptop, the ARM bar always reachable, the viewport configured so the
layout is not scaled-bitmap small.
## Work
1. `src/web_page.h`, `<style>`:
- `main` grid: keep `auto-fit`/`minmax(240px,1fr)` above the breakpoint; at `@media (max-width:
720px)` force one column and reduce padding.
- Thumb targets: `button.k`, `input`, `.pill`, checkboxes get `min-height:44px` (and
`min-width` where a target is narrower) on coarse pointers —
`@media (pointer: coarse)` where that reads better than a width query.
- `input[type=password], input[type=text], textarea { font-size:16px }` on small screens:
below 16 px iOS Safari zooms the page on focus and never zooms back out. Name that in the
comment — it is why the size is not "13 px looks nicer".
- ARM bar: `position:sticky; top:0; z-index` so it survives scrolling on a phone; keep the
armed styling exactly as it is (`body.armed` outline is an indicator, not decoration).
- `#log`: `max-height` on small screens, and `overscroll-behavior:contain` so scrolling the
log does not scroll the page.
- `touch-action:manipulation` on the interactive elements (kills the 300 ms double-tap
zoom) and `user-select:none` where a press is a control, not text.
- `padding-bottom: calc(14px + env(safe-area-inset-bottom))` on `main`, plus
`viewport-fit=cover` added to the existing `<meta name="viewport">` (without it `env()`
is always 0). Do **not** add `maximum-scale`/`user-scalable=no` — pinch zoom stays.
2. `tests/test_page.js` — assertions on the served bytes, in the existing style: the viewport
meta contains `viewport-fit=cover` and does not disable scaling; a `max-width:720px`
breakpoint exists; the ARM bar is sticky; the coarse-pointer target floor exists; the
input font-size floor exists. These are layout claims, so they are checked where the page
can be seen without a browser.
3. `src/web_page.h` header comment — one paragraph: the page is for a laptop *and* a phone
held in one hand over a target you are standing at, and the sticky ARM bar exists because
the state must be unmissable at any scroll position.
- ASSUMPTION: breakpoints at 720 px plus a `pointer: coarse` query, rather than a full design
system or container queries. One breakpoint the README can describe beats five nobody can
remember.
- ASSUMPTION: dark theme only, as today. A light theme is not on anyone's list and doubles
every colour assertion.
- ASSUMPTION: the log stays in the page (collapsed by its own height, not hidden behind a tab)
because the log is where `E9`/`E4` explanations appear; hiding it is hiding the diagnostics.
## Testing & Quality
- `node tests/test_page.js` green, including the new byte-level layout assertions; the id
coverage check unchanged (no ids added or renamed by this task).
- `bash .agents/validate.sh` green; the page byte count printed by the gate changes and is
recorded (the README claim is task 03's job).
- Device (best effort, one attempt): a phone at 360–430 px logical width shows one column, the
ARM bar stays visible while scrolling, focusing the passphrase field does not zoom the page.
## Completion Criteria
- [ ] No horizontal scroll at 360 px, 768 px or 1440 px (observed on a phone or recorded as
`skipped (no device)` with the exact check).
- [ ] ARM reachable without scrolling from the top of the page on a phone, and it is still the
first element in `main`.
- [ ] Focus on the passphrase input does not zoom (or the report says unobserved).
- [ ] All four layout assertions present in `test_page.js`; full test suite green.
@@ -0,0 +1,70 @@
# Task 02 — Cut the reading, keep the warnings: assertions first
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, **don't need as much text**, make it responsive" (the prose half)
**Story:** `n/a`
## Objective
The page stops being a wall of explanation while every safety statement it carries survives
verbatim — each one now guarded by a test that fails if a later edit deletes it. Write the
assertions, then delete the prose.
## Work
1. `tests/test_page.js` — **first**, before touching the markup: add a named check per
statement that must survive, matching on normalised text so a rewrap does not trip it:
- arming forwards **every** key including Enter, and only while armed;
- `Esc` frees the pointer and releasing frees the buttons;
- disarm / stop / focus loss cancels a run of pasted text;
- characters are positions → the target must be on a US layout;
- the default-passphrase shout (driven by `dflt:1`, not static text);
- nothing is stored/scheduled/replayed, in whatever wording survives;
- the takeover cannot arm (added by phase 03 — guard it now so this task cannot lose it).
Run the suite: it must pass *before* the trim (proving the sentences are there) and after
(proving the trim did not remove them).
2. `src/web_page.h` markup:
- Each long paragraph becomes a one-line summary plus `<details><summary>why</summary>…`:
the KDE/snap-left explanation, the mouse "movement is relative" paragraph, the send-text
paragraph, the chords paragraph, and the footer's shortcut list. The **arming banner**
stays visible — it is the one paragraph that must never be behind a tap.
- The Connection card's passphrase paragraph → one line ("the board checks this on every
connection; without it nothing can arm") with the rest in `<details>`.
- Shorten `title` attributes that are currently multi-sentence (they are already duplicated
in the `<details>`); a `title` is a tooltip, not a manual.
- Collapse the Log card's option row and the State card's `kv` rows into a two-line summary
at narrow widths — counters stay visible; they are the "is it typing" evidence.
- Keep the DOM ids and the class hooks the CSS in task 01 targets; keep
`#banner`, `#arm`, `#log`, `#paste`, `#mPad` and the chord buttons' `data-chord`
attributes byte-identical (`test_proto.cpp` asserts every labelled chord against the
combination its label promises).
3. `<details>` in the shim — `tests/test_page.js`'s DOM shim is id-based, so `<details>` needs
nothing to parse, but the safety assertions must read text content across it: make the
matcher pull from the raw markup string (which the suite already has for the id check)
rather than from the shim's element tree.
4. `README.md` — Controller page section: say the hints are folded and that the safety notes
are pinned by `tests/test_page.js` ("the test asserts the warning is still on the page"),
which is the kind of sentence that keeps the next person from tidying them away.
- ASSUMPTION: `<details>` rather than a help modal or a tour: zero script, keyboard
accessible, printable, works with no JS, and it degrades to visible text in a browser that
ignores it — which is the safe direction for a safety note.
- ASSUMPTION: "don't need as much text" means the *explanations*, not the controls. The chord
buttons, the VT keys and the KDE row stay: the operator who does not need them today needs
them the day a window manager steals Alt+Tab.
## Testing & Quality
- The new safety-string assertions pass before and after the trim; both runs in the report.
- Every one of them is demonstrated to fail when the corresponding sentence is deleted (then
restored) — otherwise they are decoration, which is the failure mode `AGENTS.md` calls out.
- `node tests/test_page.js`, `tests/test_proto.cpp` (chord labels!) and the sanitiser run
green; page byte count drops and is recorded for task 03.
## Completion Criteria
- [ ] Safety statements present and pinned; each pin proven to fail on deletion.
- [ ] Visible prose on the laptop view is roughly a third of what it was; every removed
paragraph is reachable in a `<details>` in the served bytes.
- [ ] `data-chord` bytes, `#banner`, `#arm`, `#paste`, `#mPad` and every script-reached id intact.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,61 @@
# Task 03 — Make the numbers agree again: suite coverage, page size, README
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, don't need as much text, make it responsive" (the verification half: a page claim
is only true when something measures it)
**Story:** `n/a`
## Objective
The restructure leaves the documentation and the suite telling the truth about the page as it
is now: one `test_page` row with the real count, the size claim matching the served bytes, and
coverage for the branches the restructure touched.
## Work
1. `tests/test_page.js` — coverage for the restructured page:
- id coverage after the restructure (the rule already exists; make sure it still runs
against the **markup** and not the shim, which hands out an element for any id);
- the `<details>` content is still in the served bytes (a folded note is not a deleted one);
- pinned constants: `TEXT_FRAME_MAX`/`TEXT_JOB_MAX` in the page still equal `kTextFrameMax`
/ `kTextJobCap` in `src/config.h` (the suite does this today — re-run it and confirm it
did not quietly stop matching after the edit);
- behaviour unchanged: arming, `S=` state frames, paste chunking and refusal, mouse capture
and button ordering, `E12`, the fingerprint line, `busy`.
2. `README.md`:
- Tests table: **one** `test_page` row, its real count, description covering the mouse, the
takeover and the folded safety notes. Delete the stale duplicate row that still claims 47
checks — the table currently lists the same suite twice with two different numbers, which
is exactly the "two paths that can disagree" shape `AGENTS.md` warns about.
- Controller page: "~35 KB" becomes the measured number, with the sentence that says how it
was measured (`curl -sk https://keyrelay.local/ | wc -c` against the byte count
`.agents/validate.sh` prints).
- Intro bullet `**3406 checks**` → the new total from the suite output, not arithmetic.
- Quick start / Troubleshooting: any sentence describing the old layout ("the page shows a
banner under the ARM bar", pill lists) corrected to what the page does now.
3. `AGENTS.md` — stale statements only: the page-size check line already changes in phase 02
(`https -k`); if the byte count is quoted anywhere, update it or point at the gate that
prints it rather than restating a number that will drift again.
4. `.agents/HW_VERIFICATION.md` — fill the phone row: 360 px, one column, ARM reachable, no
focus zoom, folded notes expandable — or `skipped (no device)` with the commands.
- ASSUMPTION: the totals in README are taken from a captured suite run pasted into the report,
not added by hand. The intro count and the table sum have disagreed before.
## Testing & Quality
- `bash tests/run_tests.sh` and `SANITIZE=1 tests/run_tests.sh` green; `bash
.agents/validate.sh` green including both firmware builds.
- The README's page-size claim and check count are reproducible from commands in this
repository, and the report shows both commands with their output.
- No change to `src/*.h` other than `src/web_page.h` markup/style; no behaviour change in
phases 01–03 (spot-check TLS, `busy`, and the paste path).
## Completion Criteria
- [ ] One `test_page` row in the Tests table with the real count; no duplicate anywhere.
- [ ] README page size == the served page's byte count (or the report says the board was
unreachable and quotes the PROGMEM count the gate prints).
- [ ] Intro check total matches the suite's own output.
- [ ] `test_page.js` covers: id coverage, folded content present, pinned constants, behaviour
unchanged; full test suite green.
@@ -0,0 +1,96 @@
# Phase 05 — The phone is a controller: soft keyboard and touch pointer
**Source:** `TODO.md` L5 — "Needs to be mobile compatible - click arm and then mobile
keyboard pops up. Mobile trackpad for touch trackpad."
**Story:** `n/a`
**Context:** `src/web_page.h` (the mouse block, `takeMouse()`/`flushMouse()`/`dropMouse()`,
`inPageField()`, `setArmed()`), `src/text.h` (`strokeFor()`), `src/proto.h`, `src/mouse.h`,
`src/config.h`, `tests/test_page.js`, README "Controller page" / "Safety model"
## Objective
Arm the bridge on a phone and the soft keyboard appears so you can actually type into the
target; press and hold the pad and the target's pointer moves, let go and every button is up.
Two capabilities the desktop gets from hardware — a physical keyboard and Pointer Lock — get
honest software equivalents, with the same guarantees restated in touch terms rather than
waived.
## The two equivalences, stated up front
| desktop guarantee | touch equivalent |
|---|---|
| Pointer Lock: the cursor cannot escape the tab, and `Esc` always frees it | **finger down on the pad is the capture, finger up is the release** — movement stops and every button lets go the instant the touch ends, and the Release button is 44 px and on screen. The capture cannot outlive the finger that asked for it. |
| Modifiers are physical keys held with a second hand | an **on-screen latch row** whose state is lit, restated in every frame, and cleared by a tap — never a sticky Ctrl that silently modifies the next six keys. |
Both are *more* conservative than the desktop case, not less: a touch capture ends when you
stop touching, which is stronger than a lock you have to remember to escape.
## What must not be traded away
- Movement is **relative**, one report per frame, deltas capped at ±127 and split so the net
displacement survives (invariant 7: splitting is the controller's job).
- No auto-click, no gesture, no macro, nothing scheduled. The button row is held-while-pressed.
- Capture requires armed **and** the board reporting `mouse:1`, and `E10`/`E11` still refuse
rather than ignore.
- Characters are typed by the board's own map. The page must **not** grow a second
character→usage table: the board owns `strokeFor()`, which is why the new `Q=` frame carries
*modifiers and hex bytes*, not usages. (`AGENTS.md`: when you add a table that decodes
something somebody else defined, test it — better still, do not add it.)
- Anything a touch session can leave behind (a latch, a held button, a focused field) dies in
the same funnel: disarm, blur, visibility change, link loss.
## Dependencies
- `04_controller_page_responsive` (todo) — the single-column layout, 44 px targets and
`touch-action:manipulation` are what make a touch pad usable; the folded prose is what makes
room for the latch row and the button row.
- `03_session_takeover` (todo) — `E12` handling and the `busy` pill, because a phone is exactly
the device that finds the laptop holding the bridge.
## Tasks
1. `01_touch_trackpad.md` — the pad: press-and-hold capture, relative movement from
`touchmove`, two-finger drag as the wheel, an explicit held-while-pressed button row, and
release on `touchend`/`touchcancel`/blur/disarm.
2. `02_soft_keyboard_capture.md` — ARM focuses a forwarding field so the soft keyboard pops;
printable characters leave as `P=0:<hex>` one per frame, named keys (Enter, Backspace, Tab,
arrows…) as a press/release pair; the field is cleared as soon as it is read.
3. `03_modifier_latch_q_frame.md` — the latch row and the `Q=<mods>:<hex>` frame that lets a
latched modifier type a character without the page owning a second character map. A real
four-place protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README.
4. `04_mobile_tests_docs.md` — `test_page.js` touch/latch coverage, the named-key table pinned,
README's Controller page and Safety model rewritten for touch, `AGENTS.md`'s "three tables"
row, and the phone observation recorded.
## Testing & Quality
- `tests/test_page.js` — the shim already records document/window listeners and can `fire()`
them; extend it to dispatch `touchstart`/`touchmove`/`touchend`/`touchcancel` with multiple
touch points and `beforeinput` with `inputType`, then assert:
capture requires armed and `mouse:1`; one frame per report with nothing sent when idle; a
400×300-style drag split so no delta exceeds a signed byte and the net displacement is exact;
two-finger drag becomes wheel/pan not movement; a finger up releases every button **and**
stops movement; the button row's bits match `MOUSE_BTN`; the latch row's byte matches the
modifier names; a character becomes exactly one `P=0:` frame and a latched one becomes one
`Q=` frame; `E9`/`E10`/`E11` land in the log and cancel nothing they should not.
Every frame the page emits is parsed by the firmware's own parser — the suite rule that has
already caught mislabelled chords.
- `tests/test_proto.cpp`: `Q=` parses, bounds-checks (mods byte, cap on hex length), refuses
malformed input, round-trips.
- `tests/test_relayctl.py`: the tool builds `Q=` identically, and the button/name tables and
`MOUSE_BUTTONS` agreement extends to whatever the touch rows introduce.
- Coverage **>90%** on the changed page script and the new parser path.
- Device (best effort, one attempt): a phone on the board's AP — arm, keyboard appears, type a
word and `watch_target.py` shows the characters; hold the pad, move, lift, and the target
shows motion that stops and no button stuck.
## Completion Criteria
- [ ] Arm on a phone brings up the soft keyboard and typed characters land on the target.
- [ ] Hold the pad → the pointer moves; lift → motion stops and every button is up, observed
with `watch_target.py` or recorded as `skipped (no device)` with the exact commands.
- [ ] `Ctrl+C`-equivalent reachable with the latch row and lands as Ctrl+C on the target.
- [ ] `Q=` in the README wire table, `proto.h`, `web_page.h` and `relayctl.py`, with the tests
that pin all four agreeing.
- [ ] No second character→usage table anywhere; the named-key table that does exist is pinned.
- [ ] `bash .agents/validate.sh` green; README and `AGENTS.md` describe the touch model.
@@ -0,0 +1,81 @@
# Task 01 — Touch pad: the capture is the finger
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible
— … **Mobile trackpad for touch trackpad.**"
**Story:** `n/a`
## Objective
`#mPad` becomes a touch pad: press and hold it and the target's pointer moves with your
finger, drag two fingers to scroll, and the moment the touch ends the movement stops and every
button lets go. Pointer Lock does not exist for touch, so the capture is the touch itself.
## Work
1. `src/web_page.h`, the mouse block:
- `touchstart` on `#mPad`: requires `armed` and `mouseAvailable()` (`s.mouse === 1`) — the
same two gates as `takeMouse()`, and the same `E10`/`E11` refusal path if the board
disagrees. Sets `mTouchActive`, records the anchor point, and takes **no** button.
- `touchmove`: compute the delta from the previous sample, feed the existing movement
pipeline (the code behind `flushMouse()`) so the ±127 cap and the multi-frame split are
the ones already tested — do not write a second splitter. Coalesce per report the way
`mousemove` does, and drop movement when there is no link rather than queueing it
(the page's existing rule: a queue of pointer motion delivered after you stopped moving
is the pointer version of a stuck key).
- Two-touch drag → wheel/pan through the same quantiser the desktop `wheel` handler uses
(notches with the remainder carried), not movement. `touchmove` with two fingers must not
also emit dx/dy.
- `touchend`/`touchcancel`, and `blur`/`visibilitychange`/disarm/link loss: end the capture,
`dropMouse()` (which releases every button through the funnel), reset the anchor. `touchcancel`
is the browser taking the gesture away — incoming call, notification shade, a palm — and it
must land exactly like lifting the finger.
- A visible button row beside the pad: **Left / Right / Middle**, held while pressed
(`touchstart` down, `touchend` up), carrying the existing `MOUSE_BTN` bits. Tap on the pad
itself never clicks. The row is 44 px and shows which bits are down, so "left is held" is
a thing you can see before you ask what is stuck.
- The pad's label changes with state (`not captured` / `captured — move to move, lift to
release`) since a phone has no cursor to indicate either.
2. `refreshMouseUi()` / `paintMouse()` / `mouseAvailable()` — teach them the touch state so
the pills say what is true. `mCaptured` (Pointer Lock) and `mTouchActive` are different
states that must render distinctly and never both claim the pointer.
3. Keep the desktop path byte-for-byte identical: `mousemove`, the Pointer Lock request,
`Esc`, `wheel` on a mouse — phase 04's assertions and today's `test_page.js` mouse checks
must stay green without edit.
4. `tests/test_page.js` — extend the shim for multi-point touch and add: capture requires
armed + `mouse:1`; a drag produces one `M=` per flush with no idle frames; a large drag
splits with the net displacement exact; two fingers produce wheel/pan and no dx/dy;
`touchend` and `touchcancel` each release every button and stop movement; button-row bits
equal `MOUSE_BTN`; every emitted frame parses with the firmware's own parser.
5. `README.md` — Controller page: the pad row of the element table describes the touch
behaviour; "What capture does and does not do" gains the touch sentence (this is
elaborated properly in task 04 — here, only what this task changes).
- ASSUMPTION: one finger moves, two fingers scroll, and clicking is an explicit held button
rather than a tap. Tap-to-click on a pad whose whole job is movement produces clicks the
operator did not ask for, and on a remote keyboard a click the target did not expect is the
worst class of surprise.
- ASSUMPTION: no "tap-and-hold to drag" (holding a button while moving). It needs a second
simultaneous gesture and any mis-timing strands a button on the target; the button row gives
the same capability with visible state.
- ASSUMPTION: scale of finger-delta to HID delta is 1:1 (one CSS pixel → one report unit) at
first. If it feels slow on hardware, the tuning knob goes in `config.h`-style naming on the
page side with the measured value in the comment — the board must not invent a pointer speed
of its own.
## Testing & Quality
- `node tests/test_page.js`: the new touch checks pass and the existing mouse checks are
unmodified (prove it: the diff of that test file adds, it does not change, mouse assertions).
- `bash .agents/validate.sh` green; `test_proto.cpp`, `test_mouse.cpp`, `test_output.cpp`
untouched.
- Device (best effort, one attempt): a phone on the board's AP — movement lands, lift stops
it, and `watch_target.py` shows no stuck button across 20 press/release cycles.
## Completion Criteria
- [ ] Press-and-hold on the pad moves the target's pointer; lifting stops it and releases
every button (observed, or recorded `skipped (no device)` with the commands).
- [ ] Two-finger drag scrolls and never moves the pointer.
- [ ] Left/Right/Middle are held-while-pressed and visible; tap on the pad clicks nothing.
- [ ] Desktop Pointer Lock path unchanged and still covered by the pre-existing checks.
- [ ] Full test suite green.
@@ -0,0 +1,94 @@
# Task 02 — Arm on a phone and the keyboard appears
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
**click arm and then mobile keyboard pops up.**"
**Story:** `n/a`
## Objective
Pressing ARM on a touch device focuses a forwarding field so the soft keyboard opens and stays
open, and everything typed into it becomes keystrokes on the target: printable characters via
one `P=0:<hex>` frame each, named keys as a press/release pair — with nothing held in the page
any longer than the frame that carries it.
## Work
1. `src/web_page.h`:
- A forwarding field: `<input id="keyfield" type="text" autocomplete="off" autocapitalize="none"
autocorrect="off" spellcheck="false" enterkeyhint="done" inputmode="text">`, visible while
armed on a coarse pointer (a visible field is honest: you can see the caret is in it),
collapsed to a one-line "keyboard" strip otherwise.
- `$("arm").addEventListener` → after arming is confirmed by the board (`setArmed(true)`),
`focus()` the field when `matchMedia('(pointer: coarse)').matches` — that is the
"keyboard pops up" the TODO asks for. Disarming blurs it and clears it.
- Read it with `beforeinput`, not `keydown`: soft keyboards are unreliable about `keydown`
with a usable `key`/`code` (Gboard and iOS both deliver `beforeinput` with `inputType`),
and `beforeinput` is the one event that says what the keyboard actually inserted:
- `insertText` → for each character, `send("P=0:" + hex(charBytes))` — the board's own
`strokeFor()` does the mapping and the pacing, so **no second character table on the
page** and no `Shift` guess on the laptop side;
- `deleteContentBackward` → Backspace; `insertLineBreak`/`insertParagraph` → Enter;
`deleteWordBackward` → a few Backspaces, documented, not a clever trick;
- then `field.value = ""` immediately. The field is a courier, not a buffer: invariant 4
says nothing typed may outlive the keystroke, and a DOM node full of the last paragraph
you sent is exactly that.
- Named keys (`keydown`, where `ev.key` is a name and not a character): Enter, Tab,
Backspace, Delete, Escape, the four arrows, Home/End/PageUp/PageDown — a small
`KEYNAME_TO_USAGE` map (≤20 entries, usage ids from `CODE_TO_USAGE`), sending
`S=<mods>:<usage>` then `S=<mods>:` (release) — a press/release pair, not an event, and it
self-heals if one of the two is lost.
- `inPageField()`: the passphrase box, the paste textarea and the log stay exempt from
forwarding; `#keyfield` is the **deliberate exception** and the comment must say so —
otherwise this field is "fixed" back into the exemption in six months and the phone stops
working.
- `Escape` from the soft keyboard is forwarded to the target (there is no Pointer Lock to
free on touch). While a desktop Pointer Lock is held, `Esc` stays the browser's — the
existing rule is not relaxed, it simply does not apply to a device that never took the lock.
- The field is never `preventDefault`ed (a soft keyboard that is cancelled does not type at
all), which is also why the desktop shortcut-swallowing logic must stay scoped to
non-coarse input.
2. `src/config.h` / page constants — nothing new for `P=`; assert the existing
`TEXT_FRAME_MAX` still bounds a single character trivially and that a rapid burst of
characters produces one frame each and is refused with `E8` (log it) if the board's queue
is full, rather than the page buffering them.
3. `tests/test_page.js` — extend the shim with `beforeinput`/`inputType` and a focusable
element, then assert: arming focuses the field on coarse pointers only; each inserted
character produces exactly one `P=0:` frame with the right hex; `field.value` is empty after
each event (nothing accumulates); Backspace/Enter produce the press/release pair; the
passphrase field and paste box still swallow input; disarm blurs and clears the field; a
`P=0:` frame the firmware cannot express yields the `E9` log line and nothing else is typed.
4. `README.md` — Controller page: the keyboard field row and the sentence that characters are
typed by the board's map, which is why the phone needs no layout of its own — and the
consequence that a non-US target still means non-US output (`E9` rather than a guess).
- ASSUMPTION: `beforeinput` as the primary source with `keydown` as the fallback for named
keys. Reading `value` on `input` instead would lose the distinction between "inserted" and
"autocompleted", and iOS predictive replacement arrives as `insertReplacementText` — which
this task deliberately **refuses** (log it, type nothing) because accepting a completion the
operator did not press is the same family of failure as silently sanitising a paste.
- ASSUMPTION: emoji, CJK and anything else `strokeFor()` cannot express produce `E9` per
character. That is the reject-never-guess rule arriving on the phone, and the log line says
what happened so it does not look like the board dropped keys.
- ASSUMPTION: the field is visible while armed on a phone. A hidden focused input that pops a
keyboard with no on-screen indication of *what* is receiving the keys is exactly the kind of
invisible state this project keeps having to fix.
## Testing & Quality
- `node tests/test_page.js` with the new focus/`beforeinput` coverage; every frame the page
emits still parsed by the firmware's parser; existing paste assertions unchanged (the paste
path and the keyboard path both use `P=` and must not interfere — assert that a keyboard
frame during a paste run does not disturb the paste's flow control).
- `bash .agents/validate.sh` green; page byte growth recorded against the gate's count.
- Device (best effort, one attempt): phone, arm, keyboard appears, type `relay` and
`watch_target.py` on the target shows `r e l a y` in order with no duplicates; type an emoji →
one `E9` and nothing on the target.
## Completion Criteria
- [ ] ARM on a phone opens the soft keyboard without a second tap.
- [ ] Typed characters land on the target, one frame each, in order, with no duplicates and no
accumulation in the field.
- [ ] Backspace and Enter work as press/release pairs; autocomplete text is refused, not typed.
- [ ] The passphrase box and paste box still swallow input while armed.
- [ ] Full test suite green; `src/text.h` untouched (the map did not move).
@@ -0,0 +1,92 @@
# Task 03 — Modifiers you can see: the latch row, and `Q=` to carry them
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible…"
(a soft keyboard sends no modifier keys, so without this the phone can type letters and
nothing else — no Ctrl+C, no Alt+Tab, no Ctrl+L)
**Story:** `n/a`
## Objective
An on-screen Ctrl / Alt / Shift / Super latch row whose state is visible and restated on every
frame, and one new frame — `Q=<mods>:<hex>` — that says "type these characters with these
modifiers held", so the **board's** character map stays the only character map. This is a
wire-protocol change and therefore four edits plus tests, exactly as `AGENTS.md` demands.
## Work
1. `src/proto.h` — `Cmd::TypedWithMods` for `Q=<mods>:<hex>` and a documented line in the
header comment's direction table:
`c -> d Q=<mods>:<hex> type these characters with <mods> held in addition to whatever
each character's own stroke needs`. Same bounds as `P=` (`kTextFrameMax` characters per
frame, cap on the hex length), same all-or-nothing acceptance, same `E9` for a character the
map cannot express.
2. `src/text.h` — the stroke path gains the modifier overlay: for each character,
`strokeFor()` + `OR` the frame's mods, then the same pacing and the same report sequence.
Two hard refusals, both `E6`/`E9` per the existing convention and **never** silently
clipped (invariant 5):
- **`Ctrl+Alt+Del` cannot be composed this way.** If the overlay plus the stroke's own mods
produce Ctrl+Alt and the usage is `Del` — or the usage set ever contains `0x4c` with
Ctrl+Alt — refuse the whole frame. Invariant 6 says CAD is fixed on the device and never
assembled from wire bytes; `Q=` must not become the door around that rule. Same for the
VT-switch chords the README documents as dangerous on a Linux VT.
- The overlay never releases a key: `Q=` types and lets go, it does not latch state.
3. `src/main.cpp` / `src/output.h` — wire the new command into the same typed-run machinery
`P=` uses (one run, one funnel: `R`, disarm, disconnect cancel it). If the queue is full the
frame is refused with `E8` like `P=`, and `typing` counts it.
4. `src/web_page.h`:
- A latch row: `Ctrl Alt Shift Super`, sticky toggles, using the existing `MOD_CODE` bits,
lit when active, `44px`, grouped with the existing chord buttons so there is one place
for "keys the phone does not have".
- Printable characters: `P=0:<hex>` when no latch is down, `Q=<mods>:<hex>` when one is.
The latch is cleared by tapping it again, by disarm, by blur/visibility change and by link
loss — never left set between sessions (a sticky Ctrl that silently modifies the next six
characters is this project's stuck-key bug with a new face).
- Named keys keep using `S=<mods>:<usage>` (it already carries the modifier byte) and
include the latches in that byte.
- A "held now" echo of the latches in the existing `#held` readout, so what the board is
being told is on screen.
5. `tools/relayctl.py` — `type --with-mods ctrl+alt TEXT` (or the equivalent subcommand shape
the tool already uses) building `Q=` byte-for-byte like the page; the tool's `MOUSE_BUTTONS`
/ modifier name tables extended and pinned against the page.
6. `README.md` — the **Wire protocol** table gains `Q=` with its modifiers column and refusal
rules; the error-code list gets whatever `Q=` refuses with; the Controller page element
table gains the latch row; **Layout caveats** gains one sentence: the phone borrows the
board's map, so the same US-layout caveat applies to it.
- ASSUMPTION: `Q=` rather than making the page resolve characters to usages. A second
character→usage table would have to be tested against `strokeFor()` forever — the
"three tables that must agree" problem `AGENTS.md` exists to prevent, self-inflicted. One
frame with a modifier byte keeps the map in `text.h`, which is already walked across the
whole printable range by `test_text.cpp`.
- ASSUMPTION: sticky (toggle) latches, not press-and-hold. On a phone there is no second hand
free to hold Ctrl while tapping, and a latch whose state is lit and restated is verifiable
in a way a half-held key is not.
- ASSUMPTION: `Q=` inherits `P=`'s pacing (`0` = the board's default). A phone cannot type
faster than the board's pace anyway, and letting the controller choose the pace for a
latched character buys nothing but a new way to mis-time a target.
## Testing & Quality
- `tests/test_text.cpp`: overlay arithmetic (no mods, Ctrl only, Ctrl+Shift where the stroke
already needs Shift), refusal cases including the composed-CAD attempt and the VT chords, the
exact report sequence for `Q=05:61` (Ctrl+Alt+a) asserted byte by byte, cancellation of a `Q=`
run by `R`/disarm, buffer-full behaviour, and the `Q=` render/parse round trip at the largest
legal frame.
- `tests/test_proto.cpp`: `Q=` parses, malformed variants (`Q=`, `Q=zz:`, `Q=05`, over-long
hex) are `Cmd::Bad`, and the existing `P=`/`S=`/`Y=` assertions unchanged.
- `tests/test_relayctl.py`: the tool's `Q=` frames are byte-identical to the page's, parsed by
the firmware's own parser; modifier-name tables agree across page, tool and `config.h`.
- `tests/test_page.js`: latch toggles produce the right byte; a character with a latch produces
one `Q=` frame; disarm/blur clears the latch; the chord buttons still send their
`data-chord` bytes unchanged (`test_proto.cpp`'s label-vs-bytes assertion must stay green).
- Coverage **>90%** on the parser and stroke-overlay changes.
## Completion Criteria
- [ ] `Q=` present and identical in `proto.h`, `web_page.h`, `relayctl.py` and the README table.
- [ ] Ctrl+C on a phone lands as Ctrl+C on the target (observed with `watch_target.py` or
recorded `skipped (no device)` with the commands).
- [ ] `Q=` cannot compose Ctrl+Alt+Del or the VT-switch chords — proven by a test that fails
when the refusal is removed (show both runs), and the reasoning is in `text.h`.
- [ ] Latches clear on disarm, blur and link loss, and are shown in the held-keys readout.
- [ ] Full test suite green, including the sanitiser run (this task changes buffer arithmetic).
@@ -0,0 +1,78 @@
# Task 04 — Make the touch model legible, and prove the tables
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
click arm and then mobile keyboard pops up. Mobile trackpad for touch trackpad." (the half
that keeps it working after this chat is gone)
**Story:** `n/a`
## Objective
The touch equivalences are written down where they will be read before someone "improves"
them, the one new table this phase introduces is pinned against what it maps to, and the phone
observations are recorded as observations.
## Work
1. `tests/test_page.js` — the last touch assertions, added in the suite's existing voice:
every frame the page emits in touch mode parses with the firmware's own parser; the pad's
frames never exceed one signed byte in either axis; `touchcancel` is treated exactly like
`touchend` (a notification shade stealing the gesture must not leave a button down); the
pad capture ending does **not** disarm the keyboard (release and disarm are different acts,
already true for `R`); the mouse capture and the keyboard stay independent — taking the pad
does not arm, and disarming drops the pad.
2. `tests/test_relayctl.py` — the named-key table (`KEYNAME_TO_USAGE`, the ≤20-entry page map
for Enter/Tab/arrows/…) pinned: every entry's usage id checked against the value the
firmware's parser and `CODE_TO_USAGE` agree on, and the set of names checked against a list
in the test — the table decodes something the HID spec defined, so the *table* is tested,
not anybody's confidence in it (`AGENTS.md`, the `REL_*` lesson: codes guessed from field
order were wrong while the feature appeared to work).
3. `README.md`:
- **Controller page**: a short "On a phone" block — arm focuses the forwarding field, the
latch row, the pad's press-and-hold, the button row, `E9` on emoji.
- **Safety model**: the touch equivalences written as rules, not as UI description —
finger-down is the capture and finger-up ends it; the latch is visible and cleared by every
fail-safe; `Esc` cannot be forwarded from a soft keyboard because the phone's panic path is
the Release button, which is 44 px and on screen. Add the sentence that says *why* the
touch capture is stronger than Pointer Lock rather than weaker.
- **Security model**: the trust-boundary row for "Whoever holds the phone" — pointer only
while armed and while the finger is down.
- **Wire protocol**: `Q=` already landed in task 03; here the surrounding prose (the
`<mods>` explanation, the refusal list) is brought in line with what the code actually does.
- **Tests** table: the new counts, and the touch/table pins named the way the mouse pins are
named. Intro total updated from real output.
- **Measured behaviour**: the phone numbers — keyboard latency per character through `P=`,
pad deltas per cm of travel, anything `watch_target.py` showed. Numbers, with the command.
- **Backlog**: anything this phase discovered and deliberately did not do (e.g. gesture-free
right-click on a tablet with a hover-capable pointer, a `beforeinput` path for
autocompletion, a non-US soft keyboard), so the discovery is not lost.
4. `AGENTS.md` — the "Three tables that must agree" table gains the one new row this phase
created (`KEYNAME_TO_USAGE` in `src/web_page.h`, pinned by `tests/test_relayctl.py`), in the
existing format. Nothing else: the file is the operating manual, not a changelog.
5. `.agents/HW_VERIFICATION.md` — the phone rows: keyboard pops on ARM, characters in order,
latch combo on the target, pad move/stop/no stuck button, `E9` on emoji — each `ok` with the
command and what was seen, or `skipped (no device)` with the command to run.
6. `.agents/PLAN.md` — no edits (it is locked); if this phase revealed that an anchor was
wrong, that goes in the phase report and the final pass raises it, not into the file.
- ASSUMPTION: the named-key map stays page-only (the terminal tool does not need a soft
keyboard). The pin exists so the page's map cannot drift from the HID ids it names, not to
synchronise two copies.
## Testing & Quality
- Full gate green: suites, sanitiser run, `bridge` + `console` builds.
- Each new pin demonstrated to fail on an injected break (a wrong usage id in
`KEYNAME_TO_USAGE`, a `touchcancel` handler removed), then reverted.
- README internally consistent: intro counts, Tests table, Controller page, Safety model and
Backlog all describe the same page the board serves (`curl -sk … | wc -c` agrees with the
gate's number).
## Completion Criteria
- [ ] `KEYNAME_TO_USAGE` pinned by name **and** by usage id, with the failure demonstration.
- [ ] Touch safety rules in the README as rules, including why finger-up is stronger than Esc.
- [ ] `AGENTS.md` table row added, nothing else in that file changed.
- [ ] Phone observations recorded honestly (`ok` or `skipped (no device)` + command) — no claim
in that file that nobody made.
- [ ] `bash .agents/validate.sh` green; full test suite green; no regression in phases 01–04
(TLS still terminates, takeover still disarms, layout still single-column).
@@ -0,0 +1,18 @@
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`).
@@ -0,0 +1,151 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 26.7% (used 87592 bytes from 327680 bytes)
Flash: [==== ] 36.1% (used 1135293 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.84 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 16.8% (used 55200 bytes from 327680 bytes)
Flash: [=== ] 33.6% (used 1056081 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.83 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.844
console SUCCESS 00:00:00.833
========================= 2 succeeded in 00:00:01.676 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
== repo hygiene ==
repo hygiene: OK (57 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ unreachable (curl rc 7, code 000) — expected until phase 02 (TLS) lands
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,14 @@
Everything green. Final report:
**Implemented**
- Rewrote `.agents/validate.sh` (adapted the generic template) as keyrelay's real gate, `rc`-accumulating, any-cwd, no `set -e` swallowing: ① `bash tests/run_tests.sh` ② `SANITIZE=1` run ③ `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console` (missing `uv` = FAIL with the AGENTS.md fallback-probe message; never `-t upload`) ④ page byte count from the `R"HTML(` raw string ⑤ the template's three hygiene gates verbatim. Added a python3/node presence check so the suite can't silently skip.
- `.gitignore` already had `.agents/phase-sessions/` + `.agents/pipeline.log` — no edit needed.
**Tests** (`bash .agents/validate.sh`, also from `/tmp`): **exit 0**, ~9 s — `RESULT: all suites passed` (3406 checks: 3162 C++ + 160 relayctl + 84 page) in normal **and** sanitised runs; `bridge SUCCESS RAM 26.7% (87 592 B) / Flash 36.1% (1 135 293 B)`, `console SUCCESS RAM 16.8% (55 200 B) / Flash 33.6% (1 056 081 B)`; `page bytes: 48598`; `validation OK`.
**Fault injection, each → gate exit 1, then reverted + green re-confirmed**: (a) inverted `CHECK(!s.dirty())` → exit 1; (b) markup id `pArmed`→`pArmedZ` → page suite FAIL, exit 1; (c) `int kr_injected_fault_c[2] = "…"` in `main.cpp` → bridge+console FAILED, exit 1; (d) tracked `certs/test.pem` → hygiene FAIL exit 1, `git rm --cached` done, index verified clean, `git diff` empty. Gate contains no upload/curl/pull/network command (grep-verified; only a quoted warning in `echo`).
**Docs**: README "Controller page" said "~35 KB" — stale (measured 48 598 B); now states the exact gate-counted figure. AGENTS.md: no stale facts created; not expanded (its policy).
**Decisions**: missing `uv`/deps fail rather than skip (per task ASSUMPTION); the page-size check fails if the raw-string markers aren't exactly one each; output is full (RAM/Flash captured for task 02's baseline table).
**Next task**: `01_verification_gate/02_hardware_contract.md` (`.agents/HW_VERIFICATION.md` + `tools/hw_probe.sh`).
@@ -0,0 +1,139 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 26.7% (used 87592 bytes from 327680 bytes)
Flash: [==== ] 36.1% (used 1135293 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.84 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 16.8% (used 55200 bytes from 327680 bytes)
Flash: [=== ] 33.6% (used 1056081 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.844
console SUCCESS 00:00:00.856
========================= 2 succeeded in 00:00:01.700 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,10 @@
**Report — task `01_verification_gate/02_hardware_contract.md`**
- **Implemented**
- `.agents/HW_VERIFICATION.md`: Baseline table (bridge RAM 26.7 %/87 592 B, Flash 36.1 %/1 135 293 B; console 16.8 %/55 200 B, 33.6 %/1 056 081 B; 3406 checks; page 48 598 B); per-phase claim tables for the keystroke-path regression baseline and phases 02–05 (chord identity via `watch_target.py`, `curl … | wc -c` vs gate count http→https, TLS handshake + one-time serial fingerprint + `:80` redirect-only + ws refusal + heap delta, takeover key-up-before-first-key + `armed:0` + `E3`, phone layout, soft keyboard/latch/touch-pad), each row with command + expected observation + outcome slot; the blocking rule (one `timeout`-capped attempt, record `ok`/`skipped (no device)`/`failed:`, no port polling, no opportunistic reflash, explicit note if left in ROM download mode, gate stays device-free).
- `tools/hw_probe.sh` (executable, `set -uo pipefail`): 7 fact lines — serial nodes (stat-only, never opened), mDNS, https, http+redirect, `uv`, `node`, board evdev nodes (same USB-id + `capabilities/ev` classification as `watch_target.py`); every probe capped; always exits 0.
- `.agents/validate.sh`: appended `bash tools/hw_probe.sh || true` as the last, explicitly non-`rc` line with the why.
- **Tests** (all green): `bash .agents/validate.sh` → exit 0 — `RESULT: all suites passed` (3406) normal + `SANITIZE=1`; bridge+console SUCCESS with baseline RAM/Flash; page bytes 48598; hygiene OK; probe context printed without affecting rc. Probe: exit 0 from repo root and `/tmp`, with uv stripped, and in a minimal PATH (no `timeout`/`getent`/`dirname` → states reasons, never hangs; 1.8 s full run); wrapper survives a probe that dies non-zero. `shellcheck` (cached podman image): rc 0, zero findings. Live one-attempt check recorded honestly: `curl -s http://keyrelay.local/ | wc -c` = 48598 = gate count (`ok` row).
- **Docs**: no README change — “End-to-end verification” matches every probed fact (http 200 no-redirect, mDNS resolves, no TLS yet); the historical 1.0.0 `Content-Length: 35164` row belongs to phase 04 task 03’s byte-check re-derivation. No AGENTS.md change (no statement went stale; not expanded per its policy).
- **Decisions**: probe never opens serial or blocks (degrades to stated “unknown” when its caps are missing); one real device row filled to demonstrate the outcome format; no firmware source touched.
- **Next task**: `02_tls_control_channel/01_tls_identity_module.md`.
@@ -0,0 +1,148 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 26.7% (used 87592 bytes from 327680 bytes)
Flash: [==== ] 36.1% (used 1135293 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.84 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 16.8% (used 55200 bytes from 327680 bytes)
Flash: [=== ] 33.6% (used 1056081 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.83 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.837
console SUCCESS 00:00:00.834
========================= 2 succeeded in 00:00:01.671 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ unreachable (curl rc 7, code 000) — expected until phase 02 (TLS) lands
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,17 @@
**Verified / implemented (phase 02 final pass)**
- All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label).
- Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no).
- Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page.
**Tests / lint (100 % green)**
- `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`.
- Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2).
- `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free.
**Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓.
**Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`.
**Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit.
**Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
@@ -0,0 +1,205 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
Flash: [==== ] 39.6% (used 1246145 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
Flash: [==== ] 37.1% (used 1166397 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.5% (used 93456 bytes from 327680 bytes)
Flash: [==== ] 36.4% (used 1146137 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.871
console SUCCESS 00:00:00.860
rollback SUCCESS 00:00:00.857
========================= 3 succeeded in 00:00:02.588 =========================
== page size: src/web_page.h raw strings ==
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (4 826 checks quoted and measured in both runs; 11 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,17 @@
**Implemented**
- `src/tls_pem.h` (new, pure — no Arduino/IDF/mbed includes): `wrapPem`/`pemFind` PEM armour both ways, `fingerprintHex`, the single `sanEntries()` name table + `subjectLine()`/`sanList()`, hand-built SAN GeneralNames DER, strict `ipv4Bytes`, and `__DATE__`/`addDays`/`addYears`/`utcTime` validity arithmetic.
- `src/tls.h` + `src/tls.cpp` (mbed TLS half): `generateIdentity()` (secp256r1 → self-signed SHA-256 cert, DRBG personalisation `"keyrelay"`, SAN extension, ±validity window, SHA-256 fingerprint), `selfCheck()` (reparse + `pk_check_pair`), `certificateFingerprint()`, `lastError()`; RAII context wrappers free everything on every error path; `#if KR_TLS == 0` stubs keep the rollback linkable. Header records the ld_libs/sdkconfig/nm evidence.
- `src/config.h`: `KR_TLS` (default 1), `kCertCommonName`, `kCertValidityYears`, `kPemMax=1024` with measured numbers; `platformio.ini`: `-DKR_TLS=1` in both envs with the rollback named.
- SAN is encoded by tested code (OID 2.5.29.17 via `set_extension`) rather than the library's version-varying SAN struct; the 34 DER bytes were verified byte-identical to OpenSSL's for the same names.
**Verification** (all green)
- `bash .agents/validate.sh` → `validation OK`; suites + `SANITIZE=1` both `RESULT: all suites passed`, **4170 checks** (3162 C++ + **764** `test_tls_pem` + 160 relayctl + 84 page), `test_proto` untouched at 2327, page bytes 48 598, hygiene OK, no key-material shapes.
- gcov: **src/tls_pem.h 100.00 % of 305 lines** (requirement >90 %); `src/tls.*` stated, not faked.
- Firmware: `bridge` 26.7 % RAM / 36.1 % Flash and `console` 16.8 % / 33.6 % — **unchanged from baseline** because nothing calls the identity yet (`--gc-sections` drops it). Proved that is not a false pass: an injected undefined symbol in `tls.cpp` linked anyway, so I re-linked a scratch copy with `-Wl,-u,` forcing the three functions in → both envs link all mbed TLS symbols; cost then `bridge` **88 304 B RAM (+712) / 1 170 853 B Flash (+35 560)**, `console` 55 904 / 1 091 181. `-D KR_TLS=0` builds both envs at exactly the pre-phase footprint.
- Found and fixed a real defect via the cross-check: `wrapPem` omitted the newline after a padded final group (1/2 bytes mod 48) — round-trips fine, but not what any other tool writes.
**Docs**: README (check count ×2, architecture map, pure-module count, Tests table row, "gaps" paragraph — explicit that the identity is not on the wire yet). AGENTS.md stale facts only (check count, pure-module list now including `tls_pem.h` + the no-crypto-library rule, a name-table row, the "fingerprint yes, key never" clause).
**Decisions**: no device changes — nothing is reachable over TLS yet, so no `HW_VERIFICATION.md` row was run or recorded; `.agents/HW_VERIFICATION.md`'s 3406 baseline left as the phase-01 delta anchor.
**Next pending task**: `.agents/phases/todo/02_tls_control_channel/02_identity_persistence.md` (NVS storage, generate-once, serial `cert`, `StatusSnapshot` visibility).
@@ -0,0 +1,153 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 764 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2327 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 764 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 26.7% (used 87592 bytes from 327680 bytes)
Flash: [==== ] 36.1% (used 1135293 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.84 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 16.8% (used 55200 bytes from 327680 bytes)
Flash: [=== ] 33.6% (used 1056081 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.85 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.845
console SUCCESS 00:00:00.853
========================= 2 succeeded in 00:00:01.698 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ unreachable (curl rc 7, code 000) — expected until phase 02 (TLS) lands
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,16 @@
**Implementation** (`02_tls_control_channel/02_identity_persistence`)
- `src/main.cpp`: identity now lives in NVS (`tlscrt`/`tlskey`/`tlscrtfp`); `loadTlsIdentity()` runs at boot before `control.begin()` — loads and `selfCheck()`s the stored pair, else generates, stores and prints the fingerprint **once**; a corrupt record is regenerated with two loud `!!` lines before the new value ("any fingerprint you pinned is now wrong"). Store does a read-back (lengths compared, contents never) and prints `IDENTITY NOT STORED … do not pin this fingerprint` if NVS refuses/truncates. All PEM helpers are inside `#if KR_TLS`.
- Serial `cert` (no argument form, in `help`): SHA-256 fingerprint (two 47-char lines, not one 95-char line — 128-byte CDC ring, README bug 1), validity window from the stored cert, and the names from `sanEntries()`, with a `!!` warning when `derContains()` shows the stored cert does not carry that table. No command writes or dumps a key/cert; `git grep -i "private key" src/` returns only prose comments and the `kPemKeyLabel`/mbedTLS API names.
- New pure `src/status.h`: `StatusSnapshot` + `statusJson()` moved out of `control_server.{h,cpp}` so the frame is host-testable, plus `tls` and `fp` (first 8 bytes of the fingerprint in hex, `fp:""` whenever `tls:0`; a non-hex `fp` is cut so a stored value cannot forge a JSON field). `src/tls_pem.h` gained `fingerprintPairs`, `fingerprintPrefix`, `derContains`, `dateTimeText`, `sanEntries()`; `src/tls.{h,cpp}` gained `certificateDetails()` (+ `KR_TLS==0` stub) and the `tls.cpp` docstring.
- Tests: `test_tls_pem.cpp` +97 checks (pinned name table incl. `CN == kHostname + ".local"`, both printed fingerprint shapes rejoining to the digest and refusing at `cap`, `derContains` breaking on one changed byte either side, `dateTimeText`); `test_proto.cpp` + status-frame suite (fields, sanitising, longest legal frame **322 B ≤ `kStatusJsonCap` 384**, renders as nothing when 1 B short).
**Results:** `bash .agents/validate.sh` → `validation OK` (exit 0). `tests/run_tests.sh`: **4267 checks, 0 failures** (proto 2359, tls_pem 829, text 459, relayctl 160, output 153, ws_codec 135, page 84, mouse 48, keystate 40); same under `SANITIZE=1`. Coverage: `src/tls_pem.h` 100.00 % of 367, `src/status.h` 100.00 % of 28. `pio run -e bridge -e console` SUCCESS — bridge RAM 27.6 % (90 432 B) / Flash 37.5 %, console 17.7 %/34.9 %. Rollback `-DKR_TLS=0` (out-of-tree copy) builds both envs: RAM 26.8 %, Flash 36.1 %, no new warnings.
**Docs:** README — check count 3406→4267, Quick-start first-boot fingerprint block + survive-reflashing/re-pin rule, `cert` in the serial-console table, `tls`/`fp` in the Wire-protocol status row, architecture map (`status.h`, `tls_pem.h`, `tls.{h,cpp}`, `test_tls_pem.cpp`), Tests-table rows, NVS design decision, 3 troubleshooting rows, note that the measured transcript predates these fields. AGENTS.md — check count and the pure-module list (`status.h`).
**Deviations:** (1) fingerprint printed as wrapped colon-pair lines, not one 64-hex line; (2) status `fp` is a 16-char label, not the full digest — the cap and the page can't carry 64 hex usefully; (3) `StatusSnapshot`/`statusJson` moved modules (needed for the host-measured cap); (4) added `certificateDetails()` and the four `tls_pem.h` helpers as the tested seams `cert`/status need; (5) boot-time store read-back added to honour "if it does not fit, say so" — the API otherwise fails silently.
**Device pass (next, not run here):** `console.py cert` → `reboot` → same fingerprint + `identity loaded from NVS`; `nvs erase` + reboot → `identity generated`, pin it, reboot again → unchanged; `console.py status` shows `"tls":1,"fp":"<16 hex>"` matching `cert`'s first 8 bytes.
Next task: `.agents/phases/todo/02_tls_control_channel/03_tls_listener.md`.
@@ -0,0 +1,153 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 27.6% (used 90432 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178749 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 17.7% (used 58040 bytes from 327680 bytes)
Flash: [=== ] 34.9% (used 1099237 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.863
console SUCCESS 00:00:00.865
========================= 2 succeeded in 00:00:01.727 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local did not resolve within 4 s (no responder? glibc does not cache mDNS — a retry may still work; the IP is the fallback)
hw-probe https https://keyrelay.local/ unreachable (curl rc 28, code 000) — expected until phase 02 (TLS) lands
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,12 @@
**Task complete: `02_tls_control_channel/03_tls_listener.md`**
**Implemented** — TLS control-channel listener on `:443`, raw mbedTLS driven from `tick()`:
- `src/tls_session.{h,cpp}` — one non-blocking server session per accepted fd; handshake = one `mbedtls_ssl_handshake()` per tick with a `kTlsHandshakeMs` deadline; strict same-buffer write-resume contract; slot-cap refusal renders the 7-byte RFC 5246 fatal alert without building a context. Pure policy half (deadline, admission, return-code table, alert, write queue, frame ring) host-tested; library half behind `KR_TLS && ESP_PLATFORM`.
- `src/control_server.{h,cpp}` — `:443` listener + plaintext `:80` feed **one** controller slot via one `admitConnection()` rule (cap 2, no transport parameter); per-slot response pumps (page leaves 4 KB/tick); WS frames whole-or-dropped via `TxRing`; `KR_TLS=0` rollback intact.
- Device logs only sessions + heap; `hw_probe.sh` gained an informational TLS probe.
**Tests** — `tests/test_tls_session.cpp` (135 checks; 100 % coverage of the pure half, ASan/UBSan clean); suite now **4402 checks**, green normal + sanitised; `validate.sh` **validation OK** (both firmware builds, page bytes, hygiene).
**Device pass** (board flashed and verified live): TLSv1.2/**ECDHE-ECDSA-AES256-GCM-SHA384**; page over `https` = 48598 B = gate count; `/status` 401 without token over TLS; console `cert` == handshake fingerprint, stable across 2 reflashes + 3 reboots; heap 187.9 k boot → 145.5 k (1 session) → 107.9 k (2), 20 cycles no drift; 3rd connection refused with alert 40 in 0.0 s; stalled handshake never blocked `loop()` and died on the ~10 s deadline; `:80` untouched (task 04's territory). Results recorded in `.agents/HW_VERIFICATION.md`.
**Found & fixed during verification**: one silent identity rotation happened on the first boot under the new firmware with nothing observable about it — `loadTlsIdentity()`/`storeTlsIdentity()` now name the failing gate with lengths only. README/AGENTS.md updated (check counts, security model, new pinned-table row). No phase files touched; nothing staged; board left running the new firmware, not in download mode.
@@ -0,0 +1,156 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 135 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 160 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
element ids
page 84 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 29.6% (used 97136 bytes from 327680 bytes)
Flash: [==== ] 39.4% (used 1238321 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 19.8% (used 64744 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1158621 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.863
console SUCCESS 00:00:00.858
========================= 2 succeeded in 00:00:01.721 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 200, no redirect (plaintext page still served: the pre-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,11 @@
**Task 04 (`02_tls_control_channel/04_transport_abstraction.md`) — complete.**
- `src/conn.h`: one `kr::Conn` transport view (tagged union, no virtual — rebinding per slot, inlinable pump) over `WiFiClient`/`tls::Session`; whole-frame-or-nothing `push`, resumable `pump` capped per write (4 KB) and per tick, flash body streamed through one 1460 B RAM chunk.
- `:80` is now redirect-only via pure `plaintextRoute()`: `301 https://…`, `426 Upgrade Required` for a plaintext `/ws` upgrade **and** for any `?token=` request (a `Location` would leak the passphrase twice); full `301`/`426` body pair built by the one `renderHttpResponseHead()`. `serveReadonly()` is the single HTTP answer for both slots.
- Safety invariant kept and pinned: `_lastInboundMs` is stamped only for `WebSocket && _authed` — handshake, `/status` and viewer frames cannot keep a dead controller armed.
- Tests: new `tests/test_conn.cpp` (264 checks, 100 % of the pure half), `test_tls_session` 135→118 (`WriteQueue` deleted; `TxRing` reseat/unseated pinned), `test_relayctl` 160→189 (structural pins: one formatter, one route decision, one clock stamp, page bytes == `kIndexHtmlLen`), `test_page` 84→93 (`https:`→`wss://`, no credential in the connect URL).
- Two real bugs found by the **sanitised** run and fixed, each pinned by the case that triggered it: `memcmp` past a short query, and `octets[4]` overflow on `"192.168.1.1.1"`.
- Gate: `.agents/validate.sh` exits 0 — 4687 checks in both the normal and `SANITIZE=1` runs, `bridge` RAM 30.4 % (99 616)/Flash 39.5 % (1 242 329), `console` 20.5 %/37.0 %, page 48 598 B, hygiene OK.
- Device (flashed, observed): `https://…/` = 48 598 B ×3 with `sha256` == `web_page.h`; `/`→301, `/index.html`→301, `/nope`→301 to `/`; `/status?token=`→426 with no token in the body; `/ws`→426 (`rejected=1` on the board); `wss://` upgrade→101, wrong passphrase→`E1`+close; viewer 200/401 concurrently; heap 140 k/109 k/182 k over 0–2 sessions.
- Docs moved: README (intro count, quick start `https://`, security-model rewrite, measured-behaviour rows, architecture map +`conn.h`, tests table, gaps), AGENTS.md (`curl -sk`, 4687, new defaults), `.agents/HW_VERIFICATION.md` rows.
- Not run, recorded honestly: the keystroke/silence rows need an *authenticated* controller and this board's passphrase is not the default (`dflt:0`) — I did not change a credential I could not restore.
@@ -0,0 +1,162 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 264 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 189 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
element ids
page 93 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 264 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 189 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
element ids
page 93 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
Flash: [==== ] 39.5% (used 1242329 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.88 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
Flash: [==== ] 37.0% (used 1162585 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.882
console SUCCESS 00:00:00.866
========================= 2 succeeded in 00:00:01.748 =========================
== page size: src/web_page.h raw string ==
page bytes: 48598 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of this same string)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,10 @@
**Task 05 (`02_tls_control_channel/05_page_client_fingerprint.md`) — complete.**
- **Page** (`src/web_page.h`): one 19-byte `#if KR_TLS` build marker joins the raw-string chunks (branches equal length → page size stays one number); TLS page on an `http:` origin opens **no socket, sends no frame**, disables the passphrase box + connect button behind a banner ("this page arrived in the clear; reload over https") — never a silent `ws://` fallback; rollback build keeps `ws://`; new `tls`/`plaintext` pill and a certificate line rendered from the status frame's `fp` only (nothing when `tls:0` or no `fp`).
- **`relayctl.py`**: TLS by default on :443 via stdlib `ssl`; `--fingerprint`/`$KEYRELAY_TLS_FP` hashes the peer DER and **aborts before any frame** on mismatch; `--ca` = `create_default_context` + hostname checking; `--insecure` warns on every run; none of them → refuses (exit 2) without a socket; `--plain` is the only, explicit path to :80; 426/301 messages updated (tool now has a TLS client). Token pre-flight, `paste --yes` gate, no token ever printed — kept.
- **Tests**: `test_relayctl` 189→271 (trust-decision matrix, port table, fingerprint normalisation/refusals, fake-board byte recording proving mismatch ⇒ zero bytes sent, `--ca`/`--insecure`/`--plain` shapes, main-level refusals, page assembled from *both* marker branches); `test_page` 93→113 (http: refusal incl. `boot()` disabling inputs, rollback marker keeps `ws://`, pill + exact-`fp` rendering); `test_conn` count 264→268 + `kServedPageBytes` 51 310→**51 626**; `test_proto.cpp` untouched.
- **Gate**: `bash .agents/validate.sh` → **validation OK** — 4793 checks in both normal and `SANITIZE=1` runs; bridge RAM 30.4 % (99 616) / Flash 39.6 % (1 245 353), console 20.5 %/37.1 %; page bytes 51 626; hygiene OK. Coverage of changed/added `relayctl.py` functions: **156/158 = 98.7 %** (stdlib `trace`). `ruff --select F,E9` clean on `tools/`+`tests/`; both marker branches compile (`KR_TLS=0/1` syntax-checked).
- **Device** (flashed `bridge`, board healthy, not in download mode): served page **51 626 B**, sha256 `dd16e195…f04e9` byte-equal to the locally assembled TLS page, marker `=1`, new ids present; `:80` still 301. `--fingerprint 00…00` → `certificate fingerprint mismatch … Nothing was sent`; real pin / `--insecure` / `--ca <exported self-signed>` all passed their trust step and stopped at authentication (`E1` — the passphrase is the operator's, 3 authfails total, not guessed); no argument → exit 2, no socket; `--plain` → the 426 sentence, no passphrase blame. Recorded in `.agents/HW_VERIFICATION.md`; typing over a pinned session stays the operator's confirmation.
- **Docs**: README (4793 counts, Quick start pinning block with `cert`/`curl -sk …/status?token=`/`$KEYRELAY_TLS_FP`, Controller-page byte count + 3 new element rows, architecture map, end-to-end examples, 2 new Troubleshooting rows incl. the wrong-network/identity-rotation wording in full, tests table), AGENTS.md (check count, page-size and raw-string statements now describe the marker, new cross-check table row), `.agents/validate.sh` page-size check now assembles chunks+marker (would otherwise fail on the new layout).
- **Notable**: the page must differ per build (a runtime probe would *guess* whether the board does TLS — invariant 5), hence the equal-length build marker; the pin hash deliberately runs post-handshake on the presented DER so self-signed pinning needs no CA ceremony, while `--ca` keeps OpenSSL's full verification.
- **Next pending task**: `02_tls_control_channel/06_tls_docs_rollback.md`.
@@ -0,0 +1,166 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
Flash: [==== ] 39.6% (used 1245353 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
Flash: [==== ] 37.1% (used 1165609 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.85 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.862
console SUCCESS 00:00:00.854
========================= 2 succeeded in 00:00:01.716 =========================
== page size: src/web_page.h raw strings ==
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console builds + page size + hygiene)
@@ -0,0 +1,14 @@
**Implemented**
- `platformio.ini`: new `[env:rollback]` (`extends env:bridge`, unflags `-DKR_TLS=1`, sets `-DKR_TLS=0`) — `pio run -e rollback` is now the documented, reproducible rollback; `pio run` has no `--build-flags`, so an env is the only unambiguous spelling.
- `.agents/validate.sh`: builds all three envs, plus new check 5 that pins the docs to measurements — check count in README+AGENTS, **every Tests-table row against that suite's own line**, the page byte count, Backlog numbered 1..N, and every `ws://` line must name the rollback on the same line; fixed its stale `curl -s http://` comment.
- README: TLS intro bullet; Safety model "TLS is transport, not an arming input"; trust table gains "who can see the traffic" (with the unpinned window stated) and "anyone with a copy of this firmware"; WiFi-owner row corrected; new "What TLS does not buy" + "the certificate you did not check is the one thing to check"; Wire-protocol transport paragraph + the non-`E<code>` refusals (401/503/301/**426**); new **"The rollback — building it, and what it costs"**; Measured-behaviour rows (footprint vs baseline, TLS cost, handshake time, session heap); Troubleshooting rollback row; 5 Design decisions (challenge–response, on-board identity, raw mbedTLS vs `esp_tls`, 426 vs 301, rollback-as-build); Backlog TLS item removed, renumbered 1–9, narrower "a certificate a browser already trusts" added.
- Docstrings: "what it does NOT protect / must not reverse" added to `tls.h`, `tls_pem.h`, `tls_session.h`, `conn.h`, `relayctl.py`. AGENTS.md: corrected facts only (3-env gate, `conn.h` pure half, gate checks doc numbers, TLS identity defaults, "Three tables"→"Tables", "last row"→"evdev row").
- Fixed earlier-task defects: stale `test_page | 93`→113; two Tests rows missing a closing pipe + a blank line splitting that table; HW_VERIFICATION duplicate blank `/ws` row and a heap row wrapped over 6 lines (broke its table).
**Tests/lint**: `bash .agents/validate.sh` → **exit 0, validation OK**; 4793 checks / 0 failures in both the plain and `SANITIZE=1` runs; page bytes 51 626; RAM/Flash — bridge 30.4 %/99 616 · 39.6 %/1 245 353, console 20.5 %/67 224 · 37.1 %/1 165 609, **rollback 28.5 %/93 456 · 36.4 %/1 146 137**; `ruff --select F,E9 tools/ tests/` clean; `bash -n` clean. No new executable code, so coverage is unchanged (pure modules already 100 %).
**Docs**: README.md and AGENTS.md updated as above.
**Deviations**: rollback proven by build + binary evidence, not by reflashing — `nm` gives 0 `mbedtls_ssl_*`, 0 `mbedtls_x509write`, 0 identity strings in the rollback image vs 115/15/1 in `bridge`; flashing the rollback and back would put a live HID keyboard into download mode on the host running this pipeline, so the device leg is recorded `skipped (not flashed)` in `.agents/HW_VERIFICATION.md` with the exact commands. Handshake time measured this task (0.349–0.423 s warm ×7, 3.94 s cold, page whole in 0.53–0.62 s); heap quoted from the recorded task 03/04 serial readouts, not re-measured (opening the CDC port resets the board).
**Next pending task**: this was phase 02's last task; after the harness moves it, next is `03_session_takeover/01_pending_claim_socket.md`.
@@ -0,0 +1,205 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 829 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99616 bytes from 327680 bytes)
Flash: [==== ] 39.6% (used 1245353 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.5% (used 67224 bytes from 327680 bytes)
Flash: [==== ] 37.1% (used 1165609 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.5% (used 93456 bytes from 327680 bytes)
Flash: [==== ] 36.4% (used 1146137 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.862
console SUCCESS 00:00:00.860
rollback SUCCESS 00:00:00.867
========================= 3 succeeded in 00:00:02.589 =========================
== page size: src/web_page.h raw strings ==
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (4 793 checks quoted and measured in both runs; 11 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (71 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,18 @@
**Phase 03 `session_takeover` — final verification pass. All green.**
All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.
**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.
**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).
**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.
**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.
**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
@@ -0,0 +1,213 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
element ids
page 142 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
element ids
page 142 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257933 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.84 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.85 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.84 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.838
console SUCCESS 00:00:00.848
rollback SUCCESS 00:00:00.837
========================= 3 succeeded in 00:00:02.523 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59286 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,15 @@
All green. Report:
**Implemented (task `03_session_takeover/01_pending_claim_socket.md`)**
- `src/claim.h` (new pure module): `ViewerRole`, `acceptsUpgrade()` (one claim at a time), the **one visible whitelist** `honours()` — allowed: `T<token>` (unauth+auth) and `K` (auth); refused by name: `A1`/`A0`, `S=`, `C`, `Y=`, `P=`, `M=`, `R`, `H`, `Bad`/`None`; `X` reserved for task 02 on one marked line; two-clock `expired()` deadline arithmetic (wrap-safe).
- `control_server.{h,cpp}`: spare slot gained `_viewerRole`/`_viewerPhase`/`_claimAuthed`/`_claimAtMs`/`_claimMsg[64]`+`_claimReader` (~110 B, commented); `/ws` upgrade on the spare is accepted into Claim (same `ws::acceptFor` + one shared 101 writer — the "exactly one status line" pin holds); claim auth = same constant-time compare, same shared `_authFails`/`_lockUntilMs`, E1/E2 + drop on failure; whitelist violation = E6 + drop; claim never touches `_lastInboundMs`/`_controllerSeen`/`_onMessage`; `_claimAtMs` silence/auth timeouts drop and free the session; stopViewer/both accept paths reset claim state; third socket still refused at the slot table.
- `config.h`: `kClaimMsgCap = 64`, `kClaimTimeoutMs = 15000` with reasoning.
- `tests/test_claim.cpp` (95 checks) registered in `run_tests.sh`.
**Verification** — `bash .agents/validate.sh` → **validation OK**: 4921 checks plain and sanitised (12 Tests-table rows match; `test_proto` 2359 and `test_relayctl` 271 **untouched and green** — no wire change, `X` still parses `Bad`); bridge/console/rollback all build; page 51 626 B unchanged; hygiene OK. Bridge RAM 99 728 B vs 99 616 B baseline (**+112 B**, matching the field comment); rollback +112 B likewise.
**Docs** — README: check counts (intro + quick start), new `test_claim` Tests-table row, `claim.h` in the Architecture map (nine→ten pure), rewrote the stale "second upgrade refused 503/E3" Security-model paragraph and the Wire-protocol refusals paragraph. AGENTS.md: invariant 8 restated (claim cannot drive the keyboard; dispatcher never sees a claim frame) + count. `.agents/HW_VERIFICATION.md`: task-01 device row recorded as **skipped (not run on device)** — live board still runs phase-02 firmware and the authenticating half needs the operator's passphrase (guessing feeds the shared 5-fail lockout this task wires); exact close-out commands written into the row.
**Deviations** — task text said the claim buffer is "a sixteenth" of the controller's; 64 vs `kWsMessageCap` 256 is a **quarter** — code, tests and README carry the measured ratio and say so. `ViewerRole` lives in `claim.h` with a `using` alias in `ControlServer` so the host suite walks the same type. No live changes made.
**Next pending task:** `03_session_takeover/02_takeover_funnel.md` (`X` claims the bridge; `E12`; funnel before adoption).
@@ -0,0 +1,207 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
test_claim 95 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2359 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
test_claim 95 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 271 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99728 bytes from 327680 bytes)
Flash: [==== ] 39.7% (used 1249045 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.5% (used 67336 bytes from 327680 bytes)
Flash: [==== ] 37.2% (used 1169305 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.5% (used 1148945 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.870
console SUCCESS 00:00:00.864
rollback SUCCESS 00:00:00.863
========================= 3 succeeded in 00:00:02.597 =========================
== page size: src/web_page.h raw strings ==
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (4 921 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,15 @@
Everything is 100 % green. Final report:
**Implemented (`03_session_takeover/02_takeover_funnel.md`)**
- `proto.h`: `Cmd::Takeover` for a lone `X` (trailing bytes → `Bad`), header direction table + `E12` error-list entry; `formatError` untouched.
- `control_server.{h,cpp}`: `handOverClaim()` — exact order `E12`→incumbent (best-effort) → `closeLink("taken over")` (the bug-10 funnel: disarm + release) → transport transplant → adopt (`_authed/_controllerSeen/_lastInboundMs/_connectedAtMs`, reader reset, `OK`+`sendStatus()`, `_onLinkChange(true)`) → claim slot fully vacated (state + buffers zeroed). `kErrTakenOver=12`, `_takeovers` counter + `takeovers()`, `sendOk()`; `arm()` never called. TLS slot transplant done as a *pointer swap* over two `Session` storage objects (no crypto state moves) + new `Session::forgetPendingWrite()` (documented: mbed TLS resumes half-records from its own buffer; without it the adopted TX path would wedge).
- `claim.h`: `X` joins the whitelist on its marked line, authenticated branch only; `main.cpp`: controller-side `X` = no-op answered `OK` (not `E6`); serial `link … taken=N`.
- Defect fixed (pre-existing, serves `E12`): the page read error codes as one character — `E10`/`E11`/`E12` misread as `E1`; now parsed to the first `:` (page 51 626 → 52 041 B, all pins moved with it).
**Verification** — `bash .agents/validate.sh` → **validation OK**: 4 954 checks plain + sanitised, 0 failures (`test_proto` 2 369, `test_claim` 102, `test_relayctl` 287 incl. the three handover pins — each demonstrated FAILING on an inverted tree, then reverted); `bridge/console/rollback` all build (bridge RAM 99 744 B = +16 B over task 01); page 52 041 B; hygiene OK. Device: **skipped (no device run)** recorded in `.agents/HW_VERIFICATION.md` — live board still runs phase-02 firmware and the procedure needs the operator's passphrase (guessing feeds the shared lockout); no live changes made; close-out commands written into the rows.
**Docs** — README: wire table `X` row, `E12` error code, "Single controller" paragraph now states takeover runs through the funnel and **cannot arm**, refusals/troubleshooting "no takeover" sentences corrected, Tests-table rows + totals, page-size quotes. AGENTS.md: check count + invariant 8's now-false "does nothing else" sentence only.
**Deviations** — the status-frame `takeovers`/JSON field is left to task 03 (whose file explicitly assigns "StatusSnapshot gains … `uint32_t takeovers`"); here the counter is published via the getter and the serial `status` line. Frames pipelined behind `X` become ordinary controller frames after adoption (documented — the handover itself still never arms).
**Next pending task:** `03_session_takeover/03_status_page_takeover_ui.md` (`busy`, TAKE OVER affordance, `E12` page handling).
@@ -0,0 +1,209 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2369 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 287 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2369 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 268 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 287 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
element ids
page 113 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 39.8% (used 1250533 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.2% (used 1170789 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.6% (used 1150201 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.859
console SUCCESS 00:00:00.866
rollback SUCCESS 00:00:00.870
========================= 3 succeeded in 00:00:02.595 =========================
== page size: src/web_page.h raw strings ==
page bytes: 52041 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (4 954 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 52 041 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,13 @@
Everything is 100 % green. Final report:
**Implemented (`03_session_takeover/03_status_page_takeover_ui.md`)**
- `status.h` + `main.cpp`: `StatusSnapshot` gains `busy` (= `control.linked()`) and `takeovers`; `statusJson()` emits `"busy":u` / `"tkov":u` — a link state and an event counter, no address/agent/identity anywhere (pinned). Longest legal frame measured at **349 B**; `kStatusJsonCap` stayed 384 because the test-enforced ≥32-byte headroom rule holds (35 left) — `config.h` comment rewritten with the new arithmetic (measured, not grown by guess; the task's "grow" instruction was conditional on the numbers, which the suite re-measures).
- `web_page.h`: link pill's third state `held by another controller` (from `s.busy` / claim non-adoption); **take over** button appears only after the board says held; one click sends `T` then `X` and nothing else, page lands DISARMED with a log line; `E12` disarms + releases mouse + aborts typing + pill `taken over` + auto-reconnect off (no re-claim fight); `E3` keeps its "no room for a claim" meaning with its own wording; Connection-card hint states takeover cannot arm.
- `relayctl.py`: `--take-over`; adoption probe (`OK`-then-status vs `OK`-then-silence = the board reporting busy; no probing frame sent); default = refusal naming `--take-over` with no `X` on the wire; flag on a free board = stated no-op; `_poll` now drains the whole batch (a status riding behind `OK` is kept, not abandoned — bug found and fixed).
- `claim.h`: the `H`-refusal comment corrected — the claimant's answer *is* its own non-adoption; `busy` serves pollers and post-handover frames.
**Verification** — `bash .agents/validate.sh` → **validation OK, exit 0**: 5 026 checks plain + sanitised, 0 failures (`test_proto` 2 379, `test_page` 142, `test_relayctl` 308 incl. T→X ordering / default refusal / free-board no-op / `T,X,H` through `main()`); `bridge/console/rollback` all build (bridge RAM 99 744 B — unchanged; Flash 1 257 933 B, page +7 245 B); page 59 286 B, `test_conn` chunk math updated (41 chunks / 11 ticks); hygiene OK. Device: **skipped (no device run)** recorded in `.agents/HW_VERIFICATION.md` — a board is reachable but runs pre-phase-03 firmware, and the two-device gesture needs the operator's passphrase (guessing feeds the shared lockout); **no live changes made**; close-out commands written into the row.
**Docs** — README: check count ×2, page 59 286 B, S-frame row (`busy`/`tkov`), trust-table row "someone holding the passphrase … can take the bridge over, cannot arm it by taking it", Controller-page pill/take-over rows, Security-model busy-is-not-identity sentence, CLI `--take-over` docs + example, Troubleshooting page/CLI rows, Tests-table rows/descriptions. AGENTS.md: check count only (its stale-fact test).
**Next pending task:** `03_session_takeover/04_takeover_tests_docs.md` (`relayctl`/frame test completion, pin-inversion demonstration, README final pass).
@@ -0,0 +1,213 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
element ids
page 142 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
element ids
page 142 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257933 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.85 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.866
console SUCCESS 00:00:00.855
rollback SUCCESS 00:00:00.858
========================= 3 succeeded in 00:00:02.579 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59286 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,12 @@
**Report — task 03/04 (`04_takeover_tests_docs.md`)**
- Implemented:
- Verified the three takeover source pins in `tests/test_relayctl.py` (funnel-before-adoption; no `arm(`/`setArmed`/`armed = true` in `handOverClaim` or the controller-side `X` case; whitelist consulted before the Takeover branch and `X` only in `claim.h`'s authenticated branch; `E12` named code + incumbent send) — all present and re-read against `control_server.cpp`/`claim.h`/`main.cpp`.
- Re-read bug 10's three existing pins + the 4-writer `_lastInboundMs` census against the phase-03-touched code: sentences still true (not just green).
- README: takeover bullet in "Disarmed is the only default"; end-to-end takeover procedure (`watch_target` + `key KeyA --hold 30000` + `--take-over status`, key-up-before-first-newcomer-key ordering) + evidence-table row; bugs 12 (page read `E<code>` as one char — `E10/E11/E12` misread as `E1`) and 13 (relayctl `_poll` abandoned the batch behind `OK`, breaking the adoption probe) added; section → "Thirteen bugs", all 6 cross-refs (README ×3, AGENTS.md ×2, `tls_pem.h` comment) updated.
- Page safety sentences confirmed in `test_page.js` (disarm/mouse-release/run-end/`taken over`/no-auto-reclaim on `E12`; nothing sent before the click; `E3` distinct). AGENTS.md: stale bug-count titles fixed only; invariant 8 already described the claim.
- Tests/lint: `bash .agents/validate.sh` → **validation OK**: 5 026 checks ×2 (plain + `SANITIZE=1`), 12 Tests-table rows match suites, page 59 286 B, Backlog contiguous, ws:// sentences clean; `bridge`/`console`/`rollback` builds SUCCESS; hygiene OK.
- Pin inversions proven (then reverted, no leftovers): (a) `closeLink` moved after adoption → exactly 1 FAIL ("funnel runs BEFORE…"); (b) `rearm();` inserted → 1 FAIL ("no `arm(`…"); (c) `claim.h` admits `X` unauthed → pin FAIL + `test_claim` matrix cell FAIL.
- README/AGENTS changes: as above; AGENTS.md gained no prose, only stale-fact fixes.
- Live infra: **no live changes.** A board is reachable but runs pre-phase-03 firmware; the two-controller takeover needs the operator's passphrase (guessing feeds the shared 5-fail lockout) and a reflash is a device side effect — the takeover rows in `.agents/HW_VERIFICATION.md` stand as `skipped (no device run)` with the exact close-out commands.
- Next pending: phase-03 final pass; then `04_controller_page_responsive/01_responsive_layout.md`.
@@ -0,0 +1,213 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
element ids
page 142 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
element ids
page 142 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257933 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.88 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.88 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.879
console SUCCESS 00:00:00.867
rollback SUCCESS 00:00:00.882
========================= 3 succeeded in 00:00:02.628 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59286 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,11 @@
**Final verification pass** (all three task reports PASS; tasks already in `complete/`). One real defect found and fixed.
**Commands / results.** `bash tests/run_tests.sh` → `RESULT: all suites passed`, **5 104** checks (page **220**). `SANITIZE=1 bash tests/run_tests.sh` → passed. `bash .agents/validate.sh` → `validation OK`; bridge/console/rollback `SUCCESS`; `page bytes: 59262`; `README/AGENTS claims: OK`; `repo hygiene: OK`. Real-browser leg (headless Chromium, served assembled bytes): no horizontal scroll at 360/390/768/1440, ARM first-in-`main` and sticky, log height-capped, all 5 folds openable, all 3 changed page statements execute.
**Fix.** The folded notes' `<summary>` — the only route to five pinned safety sentences — rendered **17 px** on coarse pointers, under the page's own 44 px thumb floor, while every byte-level assertion stayed green. `src/web_page.h`: `button.k,.pill,input,summary{min-height:44px}` + `summary{padding:13px 0}`; `tests/test_page.js` +1 check (219→220); mutation-tested (reverting the CSS fails host *and* browser legs, then restored byte-for-byte).
**Criteria.** 1 ✓ browser-measured (physical phone still unobserved). 2 ✓ 220 checks + prose untouched. 3 ✓ page 59 286→**59 262 B** (−24 vs phase start), README = gate figure. 4 ✓ one `test_page` row at 220, intro 5 104 = measured. 5 ✓ gate green; phases 01–03 assertions unchanged apart from the re-pinned byte constant.
**Docs.** Re-pinned 59 262 (README, `config.h`, `test_conn.cpp`), totals 5 103→5 104, README Tests row 220; re-measured footprint rows (flash 1 257 901 / 1 178 141 / 1 157 593, TLS +6 176/+100 308); fixed stale "48 KB"/"~50 KB" (×4 src comments, ×2 README) and the 51 626-byte page-delivery row; added **bug 14** with root cause and renamed *Thirteen*→*Fourteen bugs* in README + AGENTS.md's two stale references; added the Phase 04 browser+device row to `.agents/HW_VERIFICATION.md`.
**Deviations.** Device leg stays open: live board serves 51 626 B (pre-phase image); no reflash attempted, per the standing no-unattended-reflash rule — no byte-equality claim made. `ruff check tools tests`: 29 findings, identical at HEAD and `--isolated`, `E9,F` clean, no repo lint config or gate step → left untouched. `.agents/validate.sh` is **untracked and has never been committed** (`git log --all --` empty) though README/AGENTS/PLAN cite it 13× — harness needs `git add` it; I did not stage anything.
@@ -0,0 +1,223 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
arming and the S= state frames
responsive layout
the safety statements that must never be tidied away
the folded notes are folded, not deleted
the page's constants are the board's constants
element ids
page 220 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
arming and the S= state frames
responsive layout
the safety statements that must never be tidied away
the folded notes are folded, not deleted
the page's constants are the board's constants
element ids
page 220 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257901 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.79 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178141 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.78 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157593 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.79 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.791
console SUCCESS 00:00:00.781
rollback SUCCESS 00:00:00.790
========================= 3 succeeded in 00:00:02.361 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59262 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 104 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 262 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,15 @@
**Task 01 — responsive layout: done.**
**Implemented**
- `src/web_page.h` `<style>`: viewport `viewport-fit=cover` (no `maximum-scale`/`user-scalable`); `main` gains `calc(14px + env(safe-area-inset-bottom))`; `.arm` sticky `top:0;z-index:5` + `touch-action:manipulation`/`user-select:none` (also on `button.k`, `label.opt`, `.mousepad`); `#log` `overscroll-behavior:contain`; `@media (max-width:720px)` → `main` flex column (what makes the sticky ARM bar actually stick — sticky grid items never move), `.grid` forced `1fr`, tighter padding, `font-size:16px` inputs (iOS focus-zoom, named in the comment), log `max-height:40vh`; `@media (pointer:coarse)` → 44 px min-height floor on `button.k`/`.pill`/`input`, min-width floor on chord buttons/checkboxes. No markup/id changes.
- `tests/test_page.js`: +17 byte-level layout checks (viewport-fit, no zoom lock, 720 px breakpoint + one column + flex-column switch, 16 px floor, log cap, coarse floors, sticky ARM, overscroll, safe-area calc, touch-action, user-select, ARM first in `main`).
- Header comment paragraph on laptop+one-handed-phone layout and why the ARM bar is sticky.
- Re-pinned page size everywhere: 59 286 → **60 780 B** (42 chunks/11 ticks) in `test_conn.cpp` `kServedPageBytes` + its loop expectation, `config.h` comments, README; `test_conn` 280→282, `test_page` 142→159, totals 5026→**5045** in README ×2 + AGENTS.md; README Controller-page section gains the responsive paragraph + sticky-ARM row note; `.agents/HW_VERIFICATION.md` Phase 04 rows filled.
**Tests**: `bash tests/run_tests.sh` → 12 suites, 5045 checks, 0 failures ("RESULT: all suites passed"); `bash .agents/validate.sh` → **validation OK** (both suite runs, bridge/console/rollback builds, page bytes 60 780 quoted==measured, hygiene). Bridge RAM 99 744 B / Flash 1 259 421 B.
**Docs**: README + AGENTS.md numbers/paragraphs above.
**Decisions/deviations**: README page-byte/total figures updated now (task 03's scope) because the gate's doc check would otherwise fail. Device legs recorded per the hardware contract: capped `curl` pre-edit returned 51 626/`dd16e195…` (board still on pre-03 firmware); reflash and the 360 px phone observation → `skipped (no device)` / unobserved (live HID keyboard into the build host; no phone headlessly).
**Next pending task**: `04_controller_page_responsive/02_prose_trim_pinned.md`.
@@ -0,0 +1,215 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 282 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
element ids
page 159 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 282 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
element ids
page 159 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1259421 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1179661 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1159113 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.870
console SUCCESS 00:00:00.859
rollback SUCCESS 00:00:00.870
========================= 3 succeeded in 00:00:02.599 =========================
== page size: src/web_page.h raw strings ==
page bytes: 60780 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 045 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 60 780 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,9 @@
**Task 02 — prose trim, pinned: done.**
- **Assertions first** (`tests/test_page.js`, 159→**184**): 21 new safety-sentence pins matched as *text* out of the served markup (tags stripped, entities decoded, case/whitespace folded, so a sentence survives a rewrap and a move into `<details>` but fails when deleted) — every-key/including-Enter + which machine + permission rule, banner before every fold, nothing-forwarded-while-disarmed + what disarms, `Esc` frees cursor *and* releases buttons + never reaches target + button restatement, stop-typing abandons the run, characters-are-positions/US layout, no-history/nothing-saved/nothing-scheduled, takeover **cannot arm — every handover lands disarmed**, clear-page warning + its fix, CAD VT-reboot, `dflt:1`-driven shout (plus "no static default-passphrase text in markup"); +2 folded-structure pins (`kv` wraps, Log option row) and `TEXT_FRAME_MAX`/`TEXT_JOB_MAX` re-pinned to `config.h`.
- **Fold** (`src/web_page.h`): 5 `<details>` (chords/KDE, passphrase, forwarded mouse, paste run, footer shortcuts), one-line summaries left visible, arming banner untouched, multi-sentence `title` tooltips shortened, inline styles → `.mt/.dim/.hot/.dimv`. Visible prose **2 866 → 862 B (~30 %)**; `#banner` byte-identical, all 11 `data-chord` values and every script-reached id intact, HTML balance checked.
- **Pins proven to bite**: 26 mutations (delete/soften each sentence, move the banner behind a fold, drift both constants) each fired its own check; file restored byte-for-byte.
- **Tests**: `node tests/test_page.js` 184/0 — run **before** and **after** the trim; `bash tests/run_tests.sh` and `SANITIZE=1 …` → *all suites passed* (5 068 each); `bash .agents/validate.sh` → **validation OK** (bridge/console/rollback SUCCESS; page bytes 59 223 quoted==measured; hygiene OK). `ruff` is not configured or gated in this repo; its 29 findings are all pre-existing in untouched `tools/*.py`/`tests/test_relayctl.py` — left alone.
- **Docs**: README Controller-page section gains the fold + "pinned by `tests/test_page.js`" paragraph and a table row; re-pinned page size 60 780→**59 223** (README ×2, `test_conn.cpp` `kServedPageBytes` + 41 chunks/11 ticks, `config.h`), footprint flash pair −64 B, Tests rows `test_page` 184 / `test_conn` 280, totals **5 068** in README ×2 and AGENTS.md. AGENTS.md: only the stale check count (per its no-expansion rule).
- **Decisions/deviations**: prose is only 2.5 KB of a 60 KB page and the fold *moves* text rather than deleting it, so meeting "page smaller in bytes" also needed the four longest `<script>` comment blocks and the section-divider rules tightened — every rule/why kept, README stays the durable record. Result 59 223 B: −1 557 vs this task's start, and below the 59 286 B the phase opened at. Device: one capped read-only `curl -sk https://keyrelay.local/` → 51 626 / `dd16e195…` (board still pre-phase-03); reflash and the 360 px phone observation recorded `skipped (no device)` in `.agents/HW_VERIFICATION.md`.
- **Next pending task**: `04_controller_page_responsive/03_suite_size_and_docs.md`.
@@ -0,0 +1,219 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
the safety statements that must never be tidied away
the page's constants are the board's constants
element ids
page 184 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
the safety statements that must never be tidied away
the page's constants are the board's constants
element ids
page 184 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257869 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.91 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178109 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157545 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.85 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.910
console SUCCESS 00:00:00.858
rollback SUCCESS 00:00:00.849
========================= 3 succeeded in 00:00:02.617 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59223 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 068 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 223 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)

Some files were not shown because too many files have changed in this diff Show More