**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/`.
225 lines
17 KiB
Markdown
225 lines
17 KiB
Markdown
# AGENTS.md — working on keyrelay
|
||
|
||
A WiFi-controlled USB HID keyboard: laptop browser → ESP32-S3 board → USB into the target.
|
||
The target runs no software and sees a Logitech G815. Everything the board does is gated on
|
||
one idea: **the difference between a remote keyboard and an unattended injection device is a
|
||
human deciding, live, with the arming light on.** Changes that quietly move that line are the
|
||
ones to catch here.
|
||
|
||
The README is the single source of truth for behaviour, protocol, safety and measurement.
|
||
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, "Thirteen bugs").
|
||
|
||
## Phased execution
|
||
|
||
This repository runs work through `.agents/phases/`:
|
||
|
||
1. Always read `.agents/PLAN.md` first to understand the project context and goals.
|
||
2. Follow the phased execution protocol in `.agents/phases/`.
|
||
3. Never modify `.agents/PLAN.md` or any files in `.agents/phases/complete/`.
|
||
4. If you need to update any file in `.agents/phases/todo/`, you must ask the user for permission first.
|
||
5. Strictly adhere to the **LOCKED DECISIONS** listed in `.agents/PLAN.md`.
|
||
|
||
## The invariants
|
||
|
||
1. **State, not events.** `S=<mods>:<usages>` restates the *entire* desired key state. Applying
|
||
the same state twice is a no-op; any one message resynchronises the target completely. Never
|
||
introduce key-up/key-down events, sequence numbers, or a resynchronisation timer. Every
|
||
stuck-key horror story in HID injection tooling is an event-protocol bug.
|
||
2. **Disarmed is the only default.** Boot, reload, reconnect, focus loss, socket loss, target
|
||
detach, bus suspend, queue overflow and 8 s of controller silence all disarm, and disarming
|
||
releases every key **and every mouse button**. Arming needs the passphrase, the board
|
||
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 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
|
||
controller uncallable exactly when a controller vanished. Same shape: `closeLink()`
|
||
releasing only `if (linked())` skipped the release for every path that cleared `_authed`
|
||
first. If a rule fires when X stops existing, `X still exists` cannot be a precondition
|
||
of it. See README bug 10; `test_relayctl.py` pins the three sentences.
|
||
|
||
`releaseEverything()` in
|
||
`main.cpp` is the single funnel: it clears the report queue, raises the keys, ends the chord
|
||
and discards a pasted run. Any new state that can outlive a disconnect (a queue, a timer, a
|
||
run) must be cancelled there too, or it will keep acting after the operator stopped asking.
|
||
4. **Nothing is stored.** Counters only. No keystrokes, characters, key names or text in NVS,
|
||
logs, `Serial.printf`, error messages or status frames — `src/main.cpp` prints *lengths* for
|
||
text runs, never content. No macro store, no schedule, no replay, no auto-arm.
|
||
5. **Reject, never guess.** A malformed frame leaves the previous state in force. A character
|
||
the map cannot express is `E9` and *none* of that frame is typed — a paste that quietly loses
|
||
a character is worse than one that refuses. Silent sanitising is never the default.
|
||
6. **`Ctrl+Alt+Del` is fixed on the device.** It is never composed from wire bytes; on a Linux
|
||
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 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.
|
||
|
||
## Tables that must agree
|
||
|
||
| table | where | names | pinned by |
|
||
|---|---|---|---|
|
||
| `CODE_TO_USAGE` | `src/web_page.h` | physical position → usage | `tests/test_relayctl.py` |
|
||
| `CODE_TO_USAGE` | `tools/relayctl.py` | same, for the terminal | `tests/test_relayctl.py` |
|
||
| `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 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
|
||
something somebody else defined, test the table, not your confidence in it.**
|
||
|
||
Chord buttons carry their **wire bytes** in `data-chord`, not a name — which is why
|
||
`tests/test_proto.cpp` asserts every labelled chord against the combination its label promises.
|
||
A mislabelled button parses cleanly, is correctly gated, arrives intact and is the wrong key;
|
||
Alt+Tab was `05:04` (Ctrl+Alt+A) until that test existed. `TEXT_FRAME_MAX` / `TEXT_JOB_MAX` in
|
||
the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copied from
|
||
`config.h` and are asserted equal in tests — change one, the suite fails, that is the point.
|
||
|
||
## Build, test, flash
|
||
|
||
```bash
|
||
tests/run_tests.sh # 5026 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 -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
|
||
```
|
||
|
||
Environment facts that will otherwise cost you a cycle:
|
||
|
||
- **`pio` on PATH is broken** here (`ModuleNotFoundError: platformio`), and
|
||
`/tmp/piovenv` is Python 3.15 so it can *build* but not *upload*. Use the uv invocation
|
||
above for both. If it is unavailable, find another interpreter with the package:
|
||
`find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`.
|
||
- **`pio run -t upload` reports SUCCESS on a stale binary if the build before it failed.**
|
||
This happened: a diagnostic build failed to compile, the upload line right after it said
|
||
SUCCESS, and the board was running the previous firmware. Never chain build and upload
|
||
with `;` — check the build's own exit status first, or the verification you run next is
|
||
verifying something that is not in the tree.
|
||
- **No `g++` on the host.** `run_tests.sh` falls back to a local podman image listed in its
|
||
`images[]`. The page suite deliberately runs on the *host* so it works without node in there.
|
||
- **Build `console` too.** `ARDUINO_USB_MODE=1` compiles `KR_HAVE_HID == 0`, which is the only
|
||
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 -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
|
||
the earliest `#if` in the file was elsewhere. The compiler was the only thing that
|
||
noticed. Remove instrumentation with exact-string edits, or per-function slices, and
|
||
compile immediately after. When it happens anyway: the last good `firmware.elf` still
|
||
has the `.rodata` strings (console text, `printf` formats) and the symbol table, which is
|
||
enough to rebuild faithfully against the surviving headers.
|
||
|
||
## Verification standard
|
||
|
||
A feature that lives on the device is not done when the source builds — it is done when the
|
||
device is running it and something observed it. Concretely:
|
||
|
||
- Protocol/page claims → the host suites, plus `curl` against the served page.
|
||
- Keystroke delivery → `tools/watch_target.py` on the target (evdev, focus-independent); it is
|
||
the only thing that says what the target actually received, including *which* keys a chord
|
||
really was.
|
||
- Text runs → check the `typed:` summary and press count, not the scrolling stream.
|
||
|
||
When you write a check, name the mechanism it exercises. "Silence disarms" sat in the README's
|
||
safety list while nothing compared `lastInboundMs()` to `kNoClientTimeoutMs`; the documented
|
||
verification passed because killing the controller process closes the socket, and the
|
||
socket-close path disarms. The check proved a different property from the one it was named after
|
||
and nobody noticed, until a run that keeps typing on its own made the difference matter. It is
|
||
wired now — but ask, of every green check, *what would still pass if this were broken?*
|
||
|
||
## Editing rules
|
||
|
||
- **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`.
|
||
- **Docs move with the code.** Update in the same change: README intro bullets and check count,
|
||
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. `.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 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 "Thirteen 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.
|
||
|
||
## Current defaults worth knowing
|
||
|
||
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
|
||
`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.
|