Compare commits

...
4 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
124 changed files with 13928 additions and 541 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.
@@ -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)
@@ -0,0 +1,8 @@
**Task 03 — suite coverage, page size, README: done.**
- **`tests/test_page.js` 184→219**: new sections — arming & `S=` state frames on the wire (un-linked ARM click sends nothing; `A1` alone, `A0`+`R` in order, no optimistic flip either way, board-status arms/mirrors; disarmed press forwards nothing; whole-state restatements `S=00:04`→`S=02:05`→`S=00:`, autorepeat silent, page-field keys stay local but a modifier released *above* a field is let go; `Ctrl+Alt+Esc` = `R,A0`; every emitted frame matches the parser's wire form); folded-note structure (exactly 5 `<details>`, each with a naming summary + real sentences — folded ≠ deleted); id-coverage self-test proving the markup-vs-shim comparison names a fabricated id; `vm.runInContext` now tags `web_page.js` (stack traces + V8 coverage); stale "no CSS, no layout" header comment corrected.
- **Every new check proven to bite**: 10 mutations of `web_page.h` (delete/hollow/strip a fold, optimistic flip, repeat leak, stranded Shift, `R`-without-`A0`, inverted `A0,R`, broken wire form, config and page-copy constant drift) each fired its check; both files restored byte-for-byte (`sha256sum -c` OK). No `src/*` change from this task.
- **Suite/gate** (captured output): `bash tests/run_tests.sh` and `SANITIZE=1 …` → 12 suites, **5103 checks, 0 failures** each; `bash .agents/validate.sh` → **validation OK** (bridge/console/rollback SUCCESS; `page bytes: 59223` quoted==measured; docs claims OK; hygiene OK). V8 coverage of the page script: 86 % of code chars executed, 73/96 functions entered.
- **README**: one `test_page` row at **219** (no duplicate; the 47-check row is gone), intro + Quick-start totals → **5103**, Controller-page section now names the measurement (`curl -sk https://keyrelay.local/ | wc -c` vs the gate's `page bytes:` line, device leg recorded in HW_VERIFICATION). Quick start/Troubleshooting checked — no old-layout statements left. **AGENTS.md**: only the stale check count (per its no-expansion rule).
- **HW_VERIFICATION.md**: one capped probe ran — `curl -sk https://keyrelay.local/ | wc -c` → 51 626 / `dd16e195…` (board still pre-phase-03; tree's assembled page 59 223 / `dc26b441…b634`); phone row stays `skipped (no device)` with the 360 px / folded-notes-expandable checklist and commands. Byte-equality closes on the next flash (standing no-unattended-reflash call, as tasks 01–02 recorded).
- **Next pending task**: `05_mobile_controller` (this phase's task file moves to complete/ by the harness).
@@ -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 219 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 219 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.80 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.79 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.79 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.798
console SUCCESS 00:00:00.792
rollback SUCCESS 00:00:00.790
========================= 3 succeeded in 00:00:02.380 =========================
== 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 103 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)
+70 -25
View File
@@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea
This file is the operating manual: what must not break, how to build and verify, and where
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
drifts, and two paths that can disagree about arming is the exact failure this project keeps
having to fix (see README, "Ten bugs").
having to fix (see README, "Fourteen bugs").
## Phased execution
@@ -34,7 +34,7 @@ This repository runs work through `.agents/phases/`:
button, or the serial console — nothing else may gain the ability. The mouse is captured,
never grabbed: Pointer Lock, so `Esc` (the browser's own, never forwarded) always frees
it. `mouse off` over serial removes the pointer interface at the descriptor level, which
needs a reboot and is the rollback.
needs a reboot and is the pointer's rollback (the transport rollback is `-D KR_TLS=0`).
3. **Every fail-safe cancels work in flight; nothing "pauses"** — and **no fail-safe may be
gated on the absence of the thing it detects.** `ControlServer::linked()` is false when a
controller is gone, so gating the silence-disarm on it made the backstop for a vanished
@@ -57,11 +57,19 @@ This repository runs work through `.agents/phases/`:
VT it reboots the target. Same reasoning for the VT-switch chords.
7. **One frame is one command.** ASCII, no JSON inbound, ≤ `kWsMessageCap`. Long payloads are
the *controller's* problem to split, with the board reporting how much it still holds.
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade is refused (`E3`);
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
through `serveReadonly()` so they cannot drift apart.
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade cannot drive
the keyboard: it may only stand in the spare slot as a pending claim that
authenticates (same passphrase, same shared lockout) and may then send nothing but
`K` and the deliberate `X` — `X` hands the bridge over *through the release funnel*
(incumbent told with `E12`, every key and button released) and the board lands
**disarmed**; nothing reachable from `X` may arm. Every other frame is refused by
the one whitelist in `src/claim.h`, which drops the claim, and no claim frame ever
reaches the command dispatcher. A second claim, or a third socket, is
refused (`503`/`E3`, or the slot refusal); read-only HTTP is served even while a
controller holds the bridge. Keep both routes going through `serveReadonly()` so they
cannot drift apart.
## Three tables that must agree
## Tables that must agree
| table | where | names | pinned by |
|---|---|---|---|
@@ -70,8 +78,11 @@ This repository runs work through `.agents/phases/`:
| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes |
| mouse button bits | `src/mouse.h`, `src/web_page.h` (`MOUSE_BTN`), `tools/relayctl.py` (`MOUSE_BUTTONS`) | HID bit → DOM `MouseEvent.button` **and** name | `test_proto.cpp` (page↔config), `test_relayctl.py` (tool↔page↔config) |
| evdev axis codes | `tools/watch_target.py` (`REL_*`) | kernel code → label, for the *verifier* | `test_relayctl.py` — pinned to `<linux/input-event-codes.h>`, **not** to the report's byte order |
| certificate names | `src/tls_pem.h` (`sanEntries`, `kSanOidTlv`, `sanExtensionFullDer`) | the CN + SAN the certificate carries, **and the OID that labels the SAN**, and the same list rendered for the operator | `test_tls_pem.cpp` — the CN must be `kHostname` + `.local`, the 34 DER bytes were cross-checked against what OpenSSL writes for those three names, and so were the 43 bytes of the whole extension: the writer takes the OID as **DER content bytes** (`55 1D 11`), and handing it the dotted text produces a valid certificate with no SAN (README bug 11) |
| mbed TLS return codes + the refusal alert | `src/tls_session.h` (`kMbed*`, `mapIo`, `renderFatalAlert`) | "would block" vs "peer gone" vs "fatal" for every session call; the 7-byte RFC 5246 alert record a slot-cap refusal sends | `test_tls_session.cpp` walks the tables; `static_assert`s in `tls_session.cpp` weld the three copied error-code literals to the shipped library's macros — the code values differ between mbed TLS 2.x and 3.x, which is exactly how a confident transplant goes wrong |
| page build marker | `src/web_page.h` (`#if KR_TLS` between the raw-string chunks) | which build served the page — the TLS page refuses an `http:` origin outright; only the rollback page may speak plaintext | `test_page.js`, `test_relayctl.py` and `validate.sh` each assemble the served bytes from the chunks, and the two marker branches are asserted **equal length**, so one page byte count covers both builds |
The last row exists because it was guessed from the HID report's field order and was wrong
The evdev row exists because it was guessed from the HID report's field order and was wrong
(`REL_WHEEL` is 8, not 2): a working wheel looked dead and the summary said no scroll
happened. Codes the verifier has no name for are now counted and printed rather than
dropped — the same fix as giving F13–F24 names. **When you add a table that decodes
@@ -87,11 +98,13 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi
## Build, test, flash
```bash
tests/run_tests.sh # 3406 checks, no hardware
tests/run_tests.sh # 5104 checks, no hardware
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
# PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must
# be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload:
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console
uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console -e rollback
# `rollback` is [env:bridge] with -DKR_TLS=0: the plaintext way back. The gate builds it on
# every run, because a way back that stopped compiling is not a way back.
python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode
uv run --python 3.14 --with platformio python -m platformio run -e bridge -t upload
```
@@ -113,12 +126,17 @@ Environment facts that will otherwise cost you a cycle:
thing that checks the no-HID `#else` branches.
- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network
until the board is reflashed — and a tab that is already open keeps the old page in memory
until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size
of the raw string in `src/web_page.h`, and grep the served bytes for the element you added.
- **This directory is a git repository** (one commit, `Initial import`). Use it: `git diff`
before and after any sweeping edit. Note that the commit predates the mouse and
`output.h` work, so `git checkout` reverts far more than you asked for — restore a
*region*, not a file.
until it is reloaded. Confirm with `curl -sk https://keyrelay.local/ | wc -c` against the
`page bytes:` line the gate prints (the raw-string chunks plus the 19-byte build marker;
equal-length branches make it one number for both builds), and grep the served bytes for
the element you added. Plain `http://` returns a 301 and no page bytes at all (task 04), so a `curl -s
http://…` that prints a redirect body means the transport is working, not that the page is
stale — fetch over `https://`, and `curl -s -L` is not a substitute: it follows to the same
bytes whether or not the flash changed.
- **This directory is a git repository** with a short history that predates most of the
current tree. Use it: `git diff` before and after any sweeping edit, and remember that
`git checkout <file>` reverts far more than you asked for — restore a *region*, not a
file.
- **Never do a multi-line `.*?` regex rewrite across a source file.** One did
`#if KR_DEBUG_LINK.*?#endif` with `DOTALL` to remove a diagnostic block and deleted ~500
lines of `main.cpp` — `onMessage()`, `setup()`, the console, the USB handlers — because
@@ -148,12 +166,23 @@ wired now — but ask, of every green check, *what would still pass if this were
## Editing rules
- **Pure modules** (`keystate.h`, `proto.h`, `text.h`, `ws_codec.h`, `sha1.h`) include no
Arduino or ESP-IDF headers. That is what lets the identical code run on a host under
sanitisers. Keep it.
- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run
`tests/test_page.js` after touching it (it executes the page against a DOM shim and checks
that every element id the script reaches for exists in the markup).
- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `status.h`, `text.h`,
`ws_codec.h`, `sha1.h`, `tls_pem.h`, `claim.h`) include no Arduino or ESP-IDF headers. That is what lets the
identical code run on a host under sanitisers. Keep it — and keep the *crypto library*
out too: `tls_pem.h` handles the identity's strings, `tls.h`/`tls.cpp` handle the
library, and the host suite includes the first and never the second. `tls_session.h`
follows the same split inside one file: its policy half (deadline, admission,
library-return table, alert record, queues) is pure and host-tested, and its
`kr::tls::Session` class hides behind `KR_TLS && ESP_PLATFORM` — with the mbed TLS
includes **outside** the namespace, because inside it they declare `::FILE` as
`kr::tls::FILE` and every later stdio user in the translation unit dies. `conn.h` splits the
same way and for the same reason: `plaintextRoute()`, the HTTP response head, the redirect
builder and the page send cursor are pure and walked by `test_conn.cpp`; only `kr::Conn`
itself needs `WiFiClient`.
- **`web_page.h`** is C++ raw string (plural since task 05: chunks joined by one
`#if KR_TLS` build marker of equal-length branches): no compiler looks at the HTML or JS.
Run `tests/test_page.js` after touching it (it executes the assembled page against a DOM
shim and checks that every element id the script reaches for exists in the markup).
- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header
comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list.
Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`.
@@ -161,12 +190,19 @@ wired now — but ask, of every green check, *what would still pass if this were
Safety model, Security model trust-boundary table, Controller page, Wire protocol,
Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module
you changed. This README documents *why* decisions were made — keep the reasoning, don't just
restate the behaviour.
restate the behaviour. `.agents/validate.sh` checks the mechanical half so nobody has to
remember it: the check count and the page byte count the docs quote must equal what the run
measured, the Backlog must be numbered 1..N with nothing closed left inside it, and every line
that mentions a plaintext `ws://` session must name the rollback build on the same line (wrap
the sentence so it does — the point is that no sentence can read as if plaintext were default).
- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not
echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is
the point of that endpoint — do not add the token to any other URL.
the point of that endpoint — do not add the token to any other URL. The TLS private key is
the same rule with more teeth: it is generated on the board and lives only in NVS, so
nothing may print it, hash-and-print it in a way that reads as the key, or be added to the
tree as a `.pem`. Fingerprint yes, key never.
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
README: a fixed bug belongs in "Ten bugs this verification caught" with its root cause and
README: a fixed bug belongs in "Fourteen bugs this verification caught" with its root cause and
how it was proven, a new capability in the section that describes it, and a rule worth
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
what is left to do.
@@ -174,6 +210,15 @@ wired now — but ask, of every green check, *what would still pass if this were
## Current defaults worth knowing
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace
`keyrelay-ctl` / `keyrelay-ctl-pass`; **`https://keyrelay.local`** or `https://192.168.4.1`
(port 443 — `:80` answers a 301 to it, a 426 to a plaintext `/ws` upgrade or to any request
carrying `?token=`, and nothing else; plaintext only in the `-D KR_TLS=0` rollback, which is
`pio run -e rollback`); the TLS identity is an ECDSA secp256r1 keypair and self-signed
certificate generated on the board's first boot and stored in NVS (`tlscrt`, `tlskey`,
`tlscrtfp`) so it survives reflashing — rotated only by `nvs erase`, printed once at that first
boot and again on demand by the serial `cert` command, which prints the SHA-256 fingerprint, the
validity window and the names, and has no argument form that yields the key; at most 2 live TLS
sessions (`kTlsMaxSessions`, RAM-sized, a third is refused with an alert) and a 10 s handshake
deadline (`kTlsHandshakeMs`); paste pace
12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the
station credentials and passphrase and survives reflashing.
+737 -114
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -50,6 +50,12 @@ lib_deps =
build_flags =
${usb.fs_include}
-DARDUINO_USB_MODE=0
; TLS on: the page and the WebSocket are served over HTTPS/wss with an identity the
; board generated itself (src/tls.h). The rollback value is KR_TLS=0 -- plain http://
; and ws:// only, exactly the pre-phase-02 behaviour, same standing as `mouse off`.
; It is a compile-time switch because a device that cannot be reached over TLS after a
; bad provisioning change needs a way back that is a reflash, not a repair.
-DKR_TLS=1
; CDC_ON_BOOT=0 is what makes the Logitech identity possible: with 1,
; cores/esp32/main.cpp calls USB.begin() from app_main() before setup() runs, the
; first tinyusb_init() fixes the VID/PID/strings as Espressif's, and every
@@ -79,3 +85,31 @@ build_flags =
${usb.fs_include}
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
; Same TLS default as bridge: the control plane is what this environment exists to
; diagnose, so it must behave identically. Roll back with -DKR_TLS=0 (see env:rollback).
-DKR_TLS=1
; ---------------------------------------------------------------------------
; Rollback: the bridge with TLS compiled out, i.e. the pre-phase-02 build -- plain
; http:// page and ws:// socket on :80, no :443, no identity in NVS. PLAN.md locks this
; in as the way back from a provisioning change that left a board unreachable over TLS:
; the fix is a reflash, not a repair, and a way back only counts if it still builds.
; That is why it is an environment and not a remembered command line: `pio run` has no
; --build-flags option in this PlatformIO, and a second -DKR_TLS on the command line
; beside the -DKR_TLS=1 above is a macro redefinition whose winner depends on flag order.
; Unflagging the default and setting 0 is the only spelling with no ambiguity -- and
; .agents/validate.sh builds this env on every run, so the rollback cannot quietly rot.
;
; Nothing about arming moves here: KR_TLS changes which port carries bytes, never who may
; type (src/config.h, README "The rollback"). The page this image serves is the KR_TLS=0
; branch of the marker in src/web_page.h; it is the only build where a plaintext ws://
; session is the normal case, and the only one the README permits it in.
; ---------------------------------------------------------------------------
[env:rollback]
extends = env:bridge
build_flags =
${env:bridge.build_flags}
-DKR_TLS=0
build_unflags =
${env:bridge.build_unflags}
-DKR_TLS=1
+139
View File
@@ -0,0 +1,139 @@
/**
* keyrelay -- the pending claim: the state machine of the second socket that may
* authenticate but can do nothing else. Pure logic, host-tested
* (tests/test_claim.cpp): no Arduino, no IDF, no sockets.
*
* Why this door exists
* --------------------
* "No room" used to be the only answer a second `/ws` upgrade ever got, which turned
* the most ordinary accident -- a laptop tab that kept the socket open -- into a dead
* board until you found the other tab or rebooted. A claim is therefore not a new
* privilege: it is the SAME privilege (the passphrase) exercised from a different
* socket, plus one deliberate act. This module holds the predicates of that act's
* first half -- stand in the doorway -- because the predicates are the whole safety
* argument, and an argument nothing executes is an argument a later edit can undo
* without noticing. control_server.cpp keeps the routing and the sockets; the answer
* to "what may this frame be, in this role, doing that?" lives here and is walked by
* tests under sanitisers, in the style of output.h.
*
* The load-bearing rule of everything here: a pending claim can never produce an
* armed board. Nothing in this file knows that arming exists; the whitelist below is
* what makes that true by construction rather than by care.
*/
#pragma once
#include <stdint.h>
#include "config.h"
#include "proto.h"
#include "tls_session.h" // tls::deadlinePassed -- one wrap-safe clock arithmetic
namespace kr {
namespace claim {
/**
* What the spare slot is for.
*
* `Readonly` is the long-standing behaviour: the read-only routes through
* `serveReadonly()`, one response, close. `Claim` is a pending takeover claim --
* a second controller that has completed the WebSocket upgrade and (if its
* passphrase was right) authenticated, and holds nothing. The spare slot exists
* exactly once -- one incumbent plus one more, never one incumbent plus two
* claimants -- which is the same shape the server already had and the TLS-session
* ceiling from phase 02 (`kTlsMaxSessions` = 2), so a claim costs no new socket,
* no new buffer class and no new auth path.
*/
enum class ViewerRole : uint8_t { Readonly, Claim };
/**
* One claim at a time. A second `/ws` upgrade on a slot that already carries a
* claim gets the `503` the slot always answered with; a third *socket* never gets
* this far -- it is refused at the slot table (`tls::admitConnection`), which is
* RAM policy, not takeover policy, and does not move.
*/
inline bool acceptsUpgrade(ViewerRole role) { return role != ViewerRole::Claim; }
/**
* THE WHITELIST -- the whole safety argument of the pending claim, as one visible
* list. A frame from the spare slot is honoured only when this says so:
*
* allowed, unauthenticated: T<token> -- the credential the claim is made of
* allowed, authenticated: T<token> -- re-present it (same compare, same lockout)
* K -- keepalive, freshness only
* X -- claim the bridge: the one
* deliberate act this door exists for. It
* joins the whitelist HERE, nowhere else,
* and only in the authenticated branch --
* the handover it triggers
* (control_server.cpp::handOverClaim)
* runs the release funnel, lands the
* board DISARMED, and cannot arm: an
* unauthenticated `X` would be the whole
* privilege with none of the proof.
*
* refused in both states, spelled out because this list is why "a pending claim can
* arm nothing" is checkable rather than believed:
*
* A1 / A0 arm / disarm -- arming is never reachable from a claim
* S= key state -- nothing a claim sends reaches the target
* C, Y= chords, Ctrl+Alt+Del included (it is fixed on the device, and the
* device never sees this frame at all)
* P= pasted text
* M= mouse reports
* R release -- a claim must not be able to disturb the
* incumbent's keyboard either; only the funnel
* on a real link change does that
* H status -- the status frame is the controller's, and
* phase 03 does not weaken that even for the
* claimant: the claimant's answer IS its own
* non-adoption (`OK` with no status frame
* behind it means the bridge is held -- the
* `busy` field exists so a /status poller and
* the post-handover frames say the same thing
* in counter-and-link-state terms). Asking
* `H` here earns `E6` and the drop, which is
* exactly what the page's own detection reads
* None/Bad -- an unparseable frame from a non-privileged socket is a drop, not
* a conversation
*
* A `Readonly` slot has no WebSocket at all, so no frame is honoured in that role;
* the parameter is here so the predicate is the single authority over both roles and
* the test can walk the whole matrix.
*/
inline bool honours(ViewerRole role, bool authed, proto::Cmd cmd) {
if (role != ViewerRole::Claim) return false;
if (!authed) return cmd == proto::Cmd::Auth;
switch (cmd) {
case proto::Cmd::Auth:
case proto::Cmd::KeepAlive:
case proto::Cmd::Takeover: // `X` -- the deliberate act, authenticated branch only
return true;
default:
return false;
}
}
/**
* Is the claim past its clock? One anchor (`_claimAtMs`), two limits:
*
* unauthenticated kAuthTimeoutMs -- exactly the controller's rule: open the
* socket, present the passphrase, or go away
* authenticated kClaimTimeoutMs -- a claim that has not asked for the bridge
* by now is dropped and its session freed
* (config.h: an unanswered claim sitting on
* one of two TLS sessions is a nuisance
* attack, and 15 s is plenty for a click)
*
* The anchor is refreshed only by frames of this claim, and it is NEVER
* `_lastInboundMs`: a claim attempt is not the operator being alive, and the
* controller-silence fail-safe must not be held open by a socket that is trying to
* take over. That distinction is `readSome()`'s comment and bug 10; this function is
* where the claim's own clock lives instead. Wrap-safe via deadlinePassed, so the
* 49.7-day `millis()` rollover can neither make a claim immortal nor evict a live one.
*/
inline bool expired(bool authed, uint32_t nowMs, uint32_t anchorMs) {
return tls::deadlinePassed(nowMs, anchorMs, authed ? kClaimTimeoutMs : kAuthTimeoutMs);
}
} // namespace claim
} // namespace kr

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