chore(agent): phase roadmap from TODO.md, 5 phases

The four open items in TODO.md become an executable pipeline: 01 the
verification gate (host suites + sanitiser + both firmware envs, and the
hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS
and wss:// with an identity the board generates itself into NVS, 03 session
takeover that always lands disarmed, 04 a responsive page whose safety
statements are pinned before they are trimmed, 05 the phone as a controller
(press-and-hold pad, soft keyboard, modifier latches over a new Q= frame).

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

LLM-Generated: true
This commit is contained in:
2026-11-08 23:28:25 -05:00
parent ece0313d02
commit 56aa97d60d
28 changed files with 2033 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
# keyrelay — Master Plan
> Master design document for a phased-execution project. The phase roadmap below was
> derived from `TODO.md` on 2026-11-08; each phase overview and task file cites its
> source lines in that file. The **LOCKED DECISIONS** below are binding: no phase may
> introduce technology outside them without explicit user permission.
>
> `README.md` remains the single source of truth for behaviour, protocol and safety.
> `AGENTS.md` remains the operating manual. This file adds *what is left to build* and
> the anchors those phases may not move. If this plan and the README disagree about
> arming, the wire format or the safety model, the README wins and this file is wrong.
## Assumptions & Design Principles
- **The safety model is not on the roadmap.** Disarmed-is-the-only-default, the release
funnel, state-not-events, nothing-stored, reject-never-guess, fixed `Ctrl+Alt+Del`,
one-frame-one-command, exclusive-keyboard-not-exclusive-board: every phase adds
capability *inside* those rules. A phase that needs one of them relaxed is wrong, not
ahead of schedule.
- **Every new capability has to be cancelable.** Anything that can outlive a disconnect —
a session, a TLS context, a touch capture, a modifier latch — is cancelled in
`releaseEverything()` or on the link change that ends it, exactly like the paste run.
- **Auth is a precondition, never a consequence.** Takeover (`03`) and the soft keyboard
(`05`) build on the authenticated channel (`02`); neither may gain the ability to arm.
- **No secrets in files.** Not in the repo, not in a log, not in a URL, not in shell
history. This is why the TLS identity is generated *on the board* rather than shipped
as a PEM in the tree — the pipeline's own hygiene gate refuses tracked `*.pem|*.key|*.crt`.
- **No unit blocks on hardware.** Board access is best effort: one attempt, wrapped in
`timeout`, outcome recorded in `.agents/HW_VERIFICATION.md`. The gate is host-observed
(test suites + both firmware builds + `curl` against a reachable board *when* one is
reachable). A phase may not hang, retry forever, or leave the board in download mode
waiting for a human who is not there.
- **Docs move in the same change** (README sections named per task; `AGENTS.md` only gets
stale statements fixed, never expanded).
## Architectural Anchors
| COMPONENT | DECISION | RATIONALE | STATUS |
|-----------|----------|-----------|--------|
| Target hardware | M5Stack AtomS3 Lite (ESP32-S3FN8, 8 MB flash, **no PSRAM**), one USB port shared between USB-Serial/JTAG and TinyUSB | `platformio.ini` header, README "The one USB port" | LOCKED |
| Build | PlatformIO `espressif32@8.0.0` (Arduino core 3.3.12), envs `bridge` (HID) and `console` (`ARDUINO_USB_MODE=1`, `KR_HAVE_HID == 0`) | `console` is the only thing that compiles the no-HID branches | LOCKED |
| Control plane | One browser page served from flash (`src/web_page.h`), one WebSocket, ASCII one-frame-one-command, no JSON inbound | invariant 7 | LOCKED |
| Pure modules | `keystate.h`, `proto.h`, `text.h`, `mouse.h`, `output.h`, `ws_codec.h`, `sha1.h` include no Arduino/IDF header, so the identical code runs on a host under ASan/UBSan | `tests/run_tests.sh` | LOCKED |
| Crypto/TLS stack | **mbedTLS + esp-tls exactly as shipped by the installed core** — no new third-party dependency. Verified in `~/.platformio/packages/framework-arduinoespressif32-esp32s3-libs`: `flags/ld_libs` carries `-lmbedtls_2 -lmbedcrypto -lmbedx509 -lmbedtls -lesp-tls -llwip`; `esp_tls_server_session_create/_continue_async/_delete`, `esp_tls_conn_read/write`, `mbedtls_ssl_setup/set_bio/handshake/conf_max_frag_len`, `mbedtls_pk_write_key_pem`, `mbedtls_ecp_gen_key`, `mbedtls_ctr_drbg_seed`, `mbedtls_x509write_crt_init`, `mbedtls_base64_encode` all resolve; sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`, `ECP_DP_SECP256R1`, `KEY_EXCHANGE_ECDHE_ECDSA`, `SSL_ALPN` | the user chose full WSS; nothing new has to be fetched, vendored or audited | LOCKED |
| TLS transport shape | **HTTPS on 443 + `wss://`**, one TLS context for the controller and a short-lived one per read-only request; `:80` becomes redirect-only | a page loaded over `http://` can be replaced on-path by an attacker who then owns the keystroke path, so half-encryption is not a smaller risk — it is a smaller *apparent* risk | LOCKED |
| TLS identity | **Self-signed ECDSA secp256r1 certificate generated on first boot, PEM key+cert in NVS, SHA-256 fingerprint printed once on the serial console** for pinning; regenerated only if NVS is erased | keeps the private key out of the repo (AGENTS.md), survives reflashing (NVS does), and a per-board key means stealing the repo does not buy a MITM position | LOCKED |
| Plaintext rollback | `-D KR_TLS=0` builds the current `ws://`-only behaviour; documented in README as the rollback, same standing as `mouse off` | a device that cannot be reached over TLS after a bad provisioning change must have a way back that is a reflash, not a repair | LOCKED |
| Arming | Unchanged by every phase in this roadmap: TLS, takeover and mobile input are all gated on the same arming as a keystroke today | invariant 2 | LOCKED |
| Host gate | `tests/run_tests.sh` (+ `SANITIZE=1`) and both PlatformIO builds; `pio run -t upload` never runs inside `.agents/validate.sh` | AGENTS.md: an upload in a gate is a device side effect on somebody else's schedule | LOCKED |
## High-Level Architecture
```
laptop / phone browser target machine
src/web_page.h -- https:// + wss:// ---> ┌───────────────────────────────┐
│ CODE_TO_USAGE, Pointer Lock, │ AtomS3 Lite (ESP32-S3) │
│ touch pad, soft keyboard │ src/main.cpp loop(), arming│
▼ │ src/control_server.{h,cpp} │
one ASCII frame (T N X A S C Y P M R K) │ ├ src/tls.{h,cpp} mbedTLS │ USB HID → target
one status frame (S{json}) <---│ ├ src/ws_codec.h SHA-1/WS │
│ ├ src/proto.h → keystate, │
tools/relayctl.py (terminal controller, │ │ text, mouse, output │
stdlib ssl, same frames) │ └ serial console (no typing)│
└───────────────────────────────┘
```
- `ControlServer` owns exactly one controller socket plus one read-only socket, and both
read-only paths go through `serveReadonly()` so they cannot drift (invariant 8). Phase
`02` puts a transport underneath it (plain socket vs mbedTLS session) without splitting
the routing.
- `main.cpp` owns arming, the report queue and `releaseEverything()` — the single funnel
that clears the queue, raises the keys, ends the chord and discards a pasted run.
- The page is one C++ raw string; `tests/test_page.js` executes it against a DOM shim.
## Data Model
- **Key state**: `S=<mods>:<usages>` restates everything; applying the same state twice is
a no-op. No events, no sequence numbers, no resynchronisation timer.
- **Mouse**: `M=<btn>:<dx>:<dy>:<wh>:<pan>`, button byte restated every frame, deltas
signed-byte bounded.
- **Status** (`statusJson()` in `control_server.cpp`): `v up usb armed net ssid ip keys
chords drop roll leds dflt ap typed typing mouse mbtn mframes`. `typed`/`typing` are
counts, never text — that rule is not negotiable for any new field either.
- **NVS** (namespace in `src/main.cpp`, survives reflashing): `sta_ssid`, `sta_pass`,
`token`, `ap_mode`, `mouse`. Phase `02` adds `tlscrt`, `tlskey` (PEM) and `tlscrtfp`.
- **Persistence never holds text.** No macro store, no schedule, no replay, no auto-arm.
## Validation / Verification Workflow
1. **Host unit/integration** — `tests/run_tests.sh`: `test_keystate`, `test_proto`,
`test_text`, `test_mouse`, `test_output`, `test_ws_codec` compiled from the *same*
headers the board runs, plus `test_relayctl.py` (tool↔page↔`config.h` tables) and
`tests/test_page.js` (the page script, executed). `SANITIZE=1` for anything touching a
buffer. Baseline at the start of this roadmap: **3406 checks, all passing**.
2. **Firmware builds** — `uv run --python 3.14 --with platformio python -m platformio
run -e bridge -e console` (the `pio` on PATH is broken; `pio run -t upload` reports
SUCCESS on a stale binary, so build status is checked before anything is trusted).
Baseline: bridge `RAM 26.7% (87 592 B)`, `Flash 36.1% (1 135 293 B)`; console
`RAM 16.8%`, `Flash 33.6%`. Any phase that grows RAM is expected to quote the new pair.
3. **Device observation** — `tools/watch_target.py` on the target (evdev, focus
independent) is the only thing that says what the target received; `curl` against the
served page pins page claims. Best effort per the hardware rule above, logged in
`.agents/HW_VERIFICATION.md`.
4. **Gate** — `.agents/validate.sh` runs 1 + 2 (plus repo hygiene) after every task; a
phase commits only when it exits 0.
## Phase Roadmap
| # | Phase | TODO source | Objective (one line) |
|---|-------|-------------|----------------------|
| 01 | `01_verification_gate` | — (foundation) | `.agents/validate.sh` runs keyrelay's real gates (host suites, sanitiser run, both firmware builds) and the hardware contract is written down so no unit blocks on a board |
| 02 | `02_tls_control_channel` | `TODO.md` L3 | HTTPS + `wss://` with an on-board-generated self-signed EC identity, so the passphrase and the keystrokes are encrypted in transit and the board's identity can be pinned |
| 03 | `03_session_takeover` | `TODO.md` L6 | An authenticated controller can deliberately take the bridge from a session left connected; the incumbent is told, the funnel runs, and the board comes back **disarmed** |
| 04 | `04_controller_page_responsive` | `TODO.md` L4 | Less prose, tighter layout: one-screen controls on a laptop, single-column and thumb-sized on a phone, safety statements kept verbatim |
| 05 | `05_mobile_controller` | `TODO.md` L5 | Arm on a phone and the soft keyboard appears, with an on-screen modifier row; press-and-hold the pad and the target's pointer moves, release and every button lets go |
Excluded (already done, no phase): nothing — `TODO.md` held no `[x]` items.
Deferred, deliberately not in this roadmap (still in README Backlog): OS-wide evdev
capture client, GPIO panic release path, per-connection rate limiting, mDNS on the AP
interface, WPA3/PMF, report-rate telemetry, multi-run pastes, non-US paste maps.
View File
@@ -0,0 +1,49 @@
# Phase 01 — Verification gate and hardware contract
**Source:** `TODO.md` — (foundation, no TODO line). Required before the four TODO items
can be executed by subprocesses behind a pass/fail gate.
**Story:** `n/a`
**Context:** `PLAN.md` "Validation / Verification Workflow"; `tests/run_tests.sh`; `AGENTS.md` "Build, test, flash"
## Objective
Give this repository a `.agents/validate.sh` that actually fails when keyrelay is broken —
host suites (including the sanitiser run and the page suite) plus **both** firmware builds —
and write down the hardware contract so that no later unit ever blocks waiting for a board
that nobody is holding.
## Dependencies
- — (none)
## Tasks
1. `01_adapt_validate_sh.md` — author `.agents/validate.sh` with keyrelay's real checks and
the template's repo-hygiene gates, and prove it fails on a deliberately broken tree.
2. `02_hardware_contract.md` — `.agents/HW_VERIFICATION.md` + `tools/hw_probe.sh`: what can
only be checked on hardware, how to check it, the baseline RAM/flash numbers, and the
rule that a unit attempts a device step once, with a timeout, and records the outcome.
## Testing & Quality
- Unit/integration: the gate itself is the artifact. It must pass on a healthy tree **and**
fail on each injected fault: a failing C++ assertion, a page-suite id mismatch, a firmware
compile error in `src/main.cpp`, and a tracked `*.pem`.
- Coverage: n/a for shell scripts; >90% applies only to any Python added (`tools/hw_probe.sh`
is shell, not Python).
- `.agents/validate.sh` must run the **full** suite — a partial gate defeats the
"no phase commits unless 100% passing" guarantee.
## Completion Criteria
- [ ] `bash .agents/validate.sh` exits 0 on the untouched tree and prints, per check: the
suite results (`RESULT: all suites passed`), the two build results, and the hygiene verdict.
- [ ] `SANITIZE=1` is exercised by the gate (or by a documented, gate-called second pass) and
passes.
- [ ] The gate contains no `pio … -t upload`, no `curl` to a device, no network fetch.
- [ ] Each of the four injected faults makes the gate exit non-zero (demonstrated in the
task's report, then reverted).
- [ ] `.agents/HW_VERIFICATION.md` exists and names, for every device-only claim in this
roadmap, the command that observes it (`tools/watch_target.py`, the serial console,
`curl -k https://keyrelay.local/status`).
- [ ] Full test suite green; `git status` shows only planning artifacts.
@@ -0,0 +1,65 @@
# Task 01 — Author the gate: `.agents/validate.sh`
**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation: no TODO item; the
gate the other four items need)
**Story:** `n/a`
## Objective
`.agents/validate.sh` becomes the authoritative pass/fail gate for this firmware project:
the host suites (normal and sanitised), both PlatformIO environments, and the repo-hygiene
checks. Nothing in it may need a device or the network.
## Work
1. `.agents/validate.sh` — if the pipeline already installed its generic template, adapt it
in place; otherwise write it, keeping the template's three hygiene gates verbatim
(tracked-but-ignored files, key-material shapes `.pem/.key/.crt/…`, `PII_PATTERNS`).
Add, in this order, each one printing its own header and accumulating `rc`:
- `bash tests/run_tests.sh` — the C++ suites, `test_relayctl.py` and `test_page.js`.
- `SANITIZE=1 bash tests/run_tests.sh` — ASan/UBSan, the run that found 5 of the 10 bugs.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
— the exact invocation from `AGENTS.md`. **Never** `-t upload`. If `uv` is missing, the
gate **fails** with the message from `AGENTS.md` (`pio` on PATH is broken; the fallback
probe is `find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`) — a
silent skip here would let a firmware that does not compile reach `complete/`.
- A page-size sanity check that the served page's byte length equals
`kIndexHtmlLen`-by-construction: `python3` (or `grep`) counts the bytes of the raw
string between `R"HTML(` and `)HTML"` in `src/web_page.h` and prints it; the phase
final passes compare it to the README claim. No device.
2. `.gitignore` — add `.agents/phase-sessions/` and `.agents/pipeline.log` if the pipeline
has not already; never ignore `.agents/` itself.
3. Run `bash .agents/validate.sh` and record the output in the task report, including the
bridge `RAM:`/`Flash:` lines, so later phases can quote a delta instead of a guess.
4. Prove the gate bites, one fault at a time, reverting immediately after each and
re-running to confirm it goes green again:
(a) invert one assertion in `tests/test_keystate.cpp`;
(b) rename an element id in `src/web_page.h` that the script reaches for;
(c) introduce a syntax error in `src/main.cpp`;
(d) `touch certs/test.pem && git add certs/test.pem` (then `git rm --cached`).
`AGENTS.md` warns that a multi-line regex rewrite across a source file has destroyed
`main.cpp` before — make these four edits with exact-string edits only.
- ASSUMPTION: the gate is allowed to take ~30 s per task (measured: suites ≈2 s with g++
inside `localhost/ai-sandbox:ubuntu-9a08b1d500b0`, builds ≈2 s incremental). The full
recompile of `bridge` from clean is the worst case and is acceptable.
- ASSUMPTION: a missing `uv`/PlatformIO is a gate failure rather than a skip, because every
phase in this roadmap touches firmware and a skipped build check is the exact failure
`AGENTS.md` records ("`pio run -t upload` reports SUCCESS on a stale binary").
## Testing & Quality
- The four injected faults each make the gate exit non-zero; the untouched tree exits 0.
- The gate is idempotent and safe to run from any cwd (`cd` to the repo root by
`BASH_SOURCE` like `tests/run_tests.sh` does).
- No `set -e` swallowing: use the `rc` accumulation pattern so every check reports, not
just the first failure.
## Completion Criteria
- [ ] `bash .agents/validate.sh` → exit 0, printing suite results, both build results, page
byte count, and the hygiene verdict.
- [ ] Each of the four injected faults produced a non-zero exit (paste the four lines into
the report), and the tree is reverted (`git diff` clean apart from `.agents/`).
- [ ] No `upload`, no `curl`, no `podman pull`, no network access in the gate.
- [ ] Full test suite green.
@@ -0,0 +1,68 @@
# Task 02 — The hardware contract: what the gate cannot see
**Phase:** `01_verification_gate` · **Source:** `TODO.md` — (foundation; `AGENTS.md`
"Verification standard": *"A feature that lives on the device is not done when the source
builds — it is done when the device is running it and something observed it."*)
**Story:** `n/a`
## Objective
Make the device-only half of this roadmap explicit, runnable and non-blocking: a probe that
reports what hardware is reachable right now, and a file that pairs every device-only claim
with the exact command that would confirm it — so a unit running unattended never hangs on
a board and never claims a device result it did not observe.
## Work
1. `.agents/HW_VERIFICATION.md` — three sections:
- **Baseline** (measured, from task 01's gate run): `bridge RAM 26.7% (87 592 B) /
Flash 36.1% (1 135 293 B)`, `console RAM 16.8% (55 200 B) / Flash 33.6% (1 056 081 B)`,
`3406 host checks`. Later phases quote a delta against these numbers.
- **Claims that need hardware**, each with its command and its expected observation, at
least: keystroke/chord delivery and *which* keys a chord really was
(`python3 tools/watch_target.py` on the target); served-page byte agreement
(`curl -sk https://keyrelay.local/ | wc -c` vs the gate's page count — `http` until
phase 02 lands, `https -k` after); TLS handshake + the SHA-256 fingerprint printed once
on the serial console; takeover actually releasing the incumbent's held keys
(`watch_target.py` must show key-up *before* the new session's first key); the mobile
soft keyboard and touch pad (a phone on the board's AP, `watch_target.py` for both the
characters and the pointer deltas).
- **The blocking rule**: a unit attempts a device step **at most once**, wrapped in
`timeout`, and writes the outcome — `ok`, `skipped (no device)`, or `failed: …` — with
the command it ran into the section's table. It never loops on a port, never re-flashes
to "see if it works", and never leaves the board in ROM download mode without saying so
in the report (`tools/to_bootloader.py` drops it there deliberately; a power cycle is
the only way back and nobody may be there to do it).
2. `tools/hw_probe.sh` (executable, `set -uo pipefail`, no side effects) — prints one line
per fact and **always exits 0** (it informs, it does not gate):
`/dev/ttyACM*`/`/dev/ttyUSB*` present?; `keyrelay.local` resolves?; `https` reachable
(`curl -sk -m 2 -o /dev/null -w '%{http_code}'`)?; `http` reachable and does it redirect?;
`uv` on PATH?; `node` on PATH?; is a `watch_target.py`-capable evdev device visible
(informational only). Nothing is opened on a serial port except a `test -e`/`test -r`
check — opening it toggles DTR/RTS and resets the board, so the probe must not.
3. `.agents/validate.sh` — append `bash tools/hw_probe.sh` as the **last, non-affecting**
line (its exit status is deliberately not folded into `rc`), with a comment saying why:
it is context for whoever reads the gate output, not a check.
4. `README.md` — no behaviour change yet; only if the "End-to-end verification" section
contradicts what the probe just printed, fix the stale line. Do not restructure it here.
- ASSUMPTION: at execution time a board *may* be attached and reachable but nobody is
watching it, so "board available" is treated as best-effort. Any phase that needs an
on-target second machine (`watch_target.py`) records `skipped (no device)` rather than
inventing a result. This is the user-facing cost of running the roadmap unattended; the
file is where they pick those checks up.
## Testing & Quality
- `bash tools/hw_probe.sh` exits 0 with and without a board, with and without `uv`, from any
cwd; it never blocks (every probe has a `-m`/`timeout`).
- The gate still passes with the probe line added, and its output cannot flip `rc`.
- `shellcheck`-clean if `shellcheck` is installed; otherwise `-n` syntax check in the report.
## Completion Criteria
- [ ] `bash tools/hw_probe.sh` prints the six facts and exits 0 (paste its output).
- [ ] `.agents/HW_VERIFICATION.md` covers every phase 02–05 device claim listed above, each
with command + expected observation + the rule about one attempt.
- [ ] `bash .agents/validate.sh` still exits 0.
- [ ] Full test suite green; no firmware source touched.
@@ -0,0 +1,95 @@
# Phase 02 — Encrypted control channel: HTTPS + `wss://`
**Source:** `TODO.md` L3 — "Some encryption for password/key input. Doesn't need to be
proper x509, but something"
**Story:** `n/a`
**Context:** `PLAN.md` anchors "Crypto/TLS stack", "TLS transport shape", "TLS identity",
"Plaintext rollback"; `src/control_server.{h,cpp}`, `src/config.h`, `src/main.cpp`,
`src/web_page.h`, `tools/relayctl.py`, README "Security model and trust boundaries"
## Objective
Encrypt the control channel end to end: the board serves the page and the WebSocket over
TLS on 443 with a self-signed ECDSA certificate it generated itself and keeps in NVS, the
browser pins its SHA-256 fingerprint once, and the passphrase never crosses the network in
cleartext again. `:80` stops carrying anything except a redirect, so an on-path attacker can
no longer replace the page or read the keystrokes.
**Why not "just encrypt the passphrase".** A challenge–response would hide the passphrase
and nothing else: the keystrokes, the pasted text and the pointer stay readable on a network
the user does not control, and a page served over `http://` can be replaced on-path by
anybody who can see the traffic — at which point the attacker does not need the passphrase,
they need one keystroke from a page they wrote. The user asked for TLS rather than the
lighter option, and the trust-boundary table in the README only closes under TLS.
## Safety position (unchanged, and load-bearing for every task here)
TLS is **transport**, never an arming input. Arming still needs the passphrase, the board
button or the serial console; a TLS session that is authenticated but not armed types
nothing; every fail-safe in `AGENTS.md` keeps working when TLS drops, because a dropped
session is a lost link, which runs the release funnel and disarms. The rollback `-D
KR_TLS=0` restores today's behaviour and must stay buildable.
## Dependencies
- `01_verification_gate` (todo) — the gate must be running the real checks before a change
this central can be trusted; the hardware contract tells this phase how to observe the
board without blocking on it.
## Tasks
1. `01_tls_identity_module.md` — `src/tls.h`: on-board ECDSA secp256r1 keypair + self-signed
cert via mbedTLS, with the pure PEM/fingerprint helpers host-tested in
`tests/test_tls.cpp`.
2. `02_identity_persistence.md` — NVS storage of the PEM pair, generate-once, serial `cert`
command printing the fingerprint and never the key, `StatusSnapshot` visibility.
3. `03_tls_listener.md` — the 443 listener, non-blocking mbedTLS session over the accepted
socket, session lifetime, heap cost measured against the phase 01 baseline.
4. `04_transport_abstraction.md` — `ControlServer` routes through one transport interface so
`serveReadonly()` stays single-sourced; `:80` becomes redirect-only and plaintext `/ws`
is refused rather than downgraded.
5. `05_page_client_fingerprint.md` — the page's `wss://` path and pinning banner,
`relayctl.py` over stdlib `ssl` (`--ca`, `--insecure`), and the cross-checks that keep
page, tool and firmware agreeing.
6. `06_tls_docs_rollback.md` — README security model/wire table/quick start/backlog,
`AGENTS.md` stale statements, and the `-D KR_TLS=0` rollback build proven to compile.
## Testing & Quality
- Unit/integration: `tests/test_tls_pem.cpp` (new suite registered in `tests/run_tests.sh`
`suites=()`) covering the pure half only — PEM armour round-trip, fingerprint formatting,
SAN/subject string building, PEM truncation and CRLF handling, rejecting a key/cert pair
that does not match. The mbedTLS-bound half is exercised by the firmware build plus the
device check (`openssl s_client`, browser, `watch_target.py`).
- `tests/test_proto.cpp`: nothing on the wire changes shape, so its 2 327 checks must stay
green untouched — that is the point: **this phase does not change the protocol.**
- `tests/test_relayctl.py`: TLS connection setup, refusal to fall back to plaintext
silently, `--insecure` opt-in is loud, and the existing frame/table pins unaffected.
- `tests/test_page.js`: the connect path still builds the right URL for both `http:` and
`https:` origins, the fingerprint banner renders only when the board reports TLS, and
every element id still exists in the markup.
- Coverage: **>90%** on new/modified code (the pure module is fully walked; the socket layer
is measured by the device checks and stated, not faked).
- `.agents/validate.sh` runs the suites, the sanitiser run and **both** firmware environments
after every task; `bridge` must still link, and `console` (`KR_HAVE_HID == 0`) too.
## Completion Criteria
- [ ] `bash .agents/validate.sh` green, and the report quotes `bridge` `RAM:`/`Flash:`
against the phase 01 baseline (`RAM 26.7% / Flash 36.1%`) plus the measured heap
cost of one and two live TLS sessions.
- [ ] A fresh board (or `nvs erase`) generates an identity at first boot and prints its
SHA-256 fingerprint **once**, on the serial console; reflashing keeps the same
fingerprint (NVS survives), and nothing about the key ever appears in a log.
- [ ] `curl -sk https://keyrelay.local/ | wc -c` equals the page byte count the gate prints,
and `curl -s http://keyrelay.local/` no longer serves the page (it redirects).
- [ ] The browser page over `https://` reaches `linked`, arms, types, and `watch_target.py`
on the target sees the keystrokes; `relayctl.py --ca <fingerprint's pem>` and
`--insecure` both connect and type; a plaintext `ws://` upgrade on :80 is refused.
- [ ] `grep -rn "\.pem\|\.key" --include=* .` finds no key material in tracked files; the
hygiene gate in the gate script is still enabled.
- [ ] `-D KR_TLS=0` still builds `bridge` (the rollback path), and README says how to use it.
- [ ] README (intro check count, Safety model, Security model table + the paragraph that
currently *admits* the token is visible in cleartext, Wire protocol, Controller page,
Architecture file map, Tests table, Quick start, Troubleshooting, Design decisions,
Backlog item 2) and `AGENTS.md` stale statements updated in this phase's commit.
@@ -0,0 +1,87 @@
# Task 01 — Device identity: keypair + self-signed certificate on the board
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
`kr::tls` can, on the board, produce an ECDSA secp256r1 keypair and a self-signed X.509
certificate for `keyrelay.local`, as PEM strings, plus a SHA-256 fingerprint of the
certificate — with the string-handling half of that testable on a host.
## Work
1. `src/tls_pem.h` — **new, pure** module (no Arduino, no IDF headers; the rule in
`AGENTS.md` exists so this compiles and runs under sanitisers on the host):
- `wrapPem(const char *label, const uint8_t *der, size_t derLen, char *out, size_t cap)`
→ `-----BEGIN <label>-----\n` + base64 in 64-char lines + `\n-----END <label>-----\n`;
returns the length, `0` when `cap` is too small (never truncates silently — invariant 5).
- `fingerprintHex(const uint8_t *digest, size_t digestLen, char *out, size_t cap)` →
lowercase hex, `out` sized by the caller; refuses a `cap` that cannot hold it.
- `subjectLine()` / `sanList()` builders producing
`CN=keyrelay.local`, `DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1` —
the names in `src/config.h` (`kHostname`, `kApGw`), not literals invented here.
- `pemFind(label, haystack, out, cap)` → extracts the DER back out of an armour block
(tolerant of CRLF and of a missing trailing newline), `false` on a malformed block.
2. `src/tls.h` (+ `src/tls.cpp` if it needs a translation unit) — the mbedTLS half:
- `bool generateIdentity(PemPair &out, char *fpHex, size_t fpCap)`: `mbedtls_entropy_init`
+ `mbedtls_ctr_drbg_seed` (personalisation string `"keyrelay"`) →
`mbedtls_pk_setup` + `mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, …)` →
`mbedtls_x509write_crt` (subject == issuer, serial from the DRBG, validity from build
time to +10 years, `MBEDTLS_MD_SHA256`, SAN extension) →
`mbedtls_x509write_crt_der` → `wrapPem("CERTIFICATE")`; key via
`mbedtls_pk_write_key_pem`. Free everything on every exit path, including the error
paths; no early `return` that leaks a context.
- `bool selfCheck(const PemPair &)` — reparse cert+key and prove they match; used at boot
so a half-written NVS record is reported instead of producing a listener that fails
every handshake.
- Header comment must record *how this was verified linkable*: `flags/ld_libs` of
`framework-arduinoespressif32-esp32s3-libs` carries `-lmbedtls_2 -lmbedcrypto
-lmbedx509 -lesp-tls`, and sdkconfig enables `MBEDTLS_ECP_C`, `MBEDTLS_ECDSA_C`,
`ECP_DP_SECP256R1_ENABLED`, `KEY_EXCHANGE_ECDHE_ECDSA`.
3. `src/config.h` — `kCertCommonName[]`, `kCertValidityYears`, `kPemMax` (sized from the
measured PEM lengths plus headroom, with the numbers in a comment), and
`#ifndef KR_TLS / #define KR_TLS 1` so the rollback flag has one home.
4. `tests/test_tls_pem.cpp` — **new suite**, registered in `tests/run_tests.sh` in
`suites=()`. It includes `src/tls_pem.h` **only** — it must never include `src/tls.h`
or anything pulling in `mbedtls`, or the host build breaks and the pure-module rule is
gone:
PEM round-trip (`wrapPem` → `pemFind` → same bytes) for a DER blob of 0/1/45/46/47/n bytes
around the 64-char line boundary, CRLF and no-trailing-newline tolerance, refusal when
`cap` is one byte short, `fingerprintHex` against the known SHA-256 of `"abc"`, SAN list
containing every name the README tells the operator to expect, and ASan-clean runs of all
of it under `SANITIZE=1`.
5. `platformio.ini` — `-D KR_TLS=1` for `bridge` and `console`, in the existing
`build_flags` blocks, with a comment naming the rollback value.
- ASSUMPTION: **on-board generation rather than a keypair built on the host and flashed.**
The alternatives were rejected on record: committing a PEM would put every board behind
one key *and* trip the pipeline's key-material hygiene gate; a host-generated per-user PEM
makes the first build fail for anybody who has not run a script. Nothing in `src/` may
contain a key.
- ASSUMPTION: the certificate is generated at boot when NVS holds none, and regenerated only
when NVS is erased — see task 02. A changed fingerprint after an erase is expected and
documented, not a fault.
- ASSUMPTION: ECDSA secp256r1 rather than RSA 2048 — this board has no PSRAM, and the EC
pair is a fraction of the RAM and flash cost of RSA while being the stronger key. Browser
support for self-signed EC certs is universal on the platforms the README targets.
## Testing & Quality
- `bash tests/run_tests.sh` → the new suite passes and is listed; `SANITIZE=1` variant clean.
- `uv run --python 3.14 --with platformio python -m platformio run -e bridge -e console`
→ both link (this is the task that proves the mbedTLS symbols resolve in a real image).
- The pure module includes no Arduino/IDF header: `grep -n "Arduino.h\|esp_\|mbedtls"
src/tls_pem.h` finds nothing.
- Coverage **>90%** on `src/tls_pem.h` (walked exhaustively), stated for `src/tls.*`.
## Completion Criteria
- [ ] `tests/test_tls_pem.cpp` runs in the suite and the PEM boundary cases (0/45/46/47 bytes,
one byte short of `cap`) each have a named check.
- [ ] `generateIdentity()` compiles and links into `bridge` **and** `console`; the report
quotes the new `RAM:`/`Flash:` pair against `bridge 26.7% / 36.1%`.
- [ ] `tests/test_proto.cpp` untouched and still at its previous check count — the wire
format did not change in this task.
- [ ] Full test suite green; no key material anywhere in the tree (`git status`, `grep`).
@@ -0,0 +1,73 @@
# Task 02 — Keep the identity across reflashes: NVS, first boot, `cert`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the identity half: an encryption key has to live somewhere that is not
a file in this repo)
**Story:** `n/a`
## Objective
The keypair and certificate are generated once, stored in NVS, reused after a reflash, and
made observable to the operator as a SHA-256 fingerprint — on the serial console and in the
status frame — without the key ever being printed, echoed, or written to a file.
## Work
1. `src/main.cpp` — the NVS half (follow the existing shape exactly: `Preferences prefs;`,
`prefs.begin(kNvsNamespace, true/false)` / `end()`, `constexpr char kKey…[]` next to
`kKeyToken`):
- `kKeyTlsCert[] = "tlscrt"`, `kKeyTlsKey[] = "tlskey"`, `kKeyTlsFp[] = "tlscrtfp"`.
- `loadTlsIdentity()` at boot: load cert+key+fingerprint; if any is missing **or**
`kr::tls::selfCheck()` fails, generate, store, and print the fingerprint **once**
(`[tls] identity generated — pin this certificate:` then the hex, wrapped 2-by-2).
Never `Serial.printf` the key, the cert body, or a length that identifies the text.
- A corrupt/half-written record regenerates rather than booting a listener that cannot
complete a handshake — and says so, once, loudly.
2. `src/main.cpp`, serial console — a `cert` command next to `token` in the help text
(`~main.cpp:893` block) and the dispatch chain (`~main.cpp:1010`): prints the
fingerprint, the certificate's notBefore/notAfter and the SAN list. **No** argument form
that writes a key, **no** subcommand that dumps the PEM. Update the help line and the
`usage` output.
3. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool tls` and
`const char *fp` (the first 8 bytes of the fingerprint as 16 hex chars, sized by a named
constant); `statusJson()` emits `"tls":u,"fp":"…"`; `kStatusJsonCap` grows accordingly
with the arithmetic in the comment (the reason that constant exists is that a truncated
status lies about state).
4. `src/main.cpp` — fill both fields where `StatusSnapshot` is populated (`~main.cpp:522`,
next to `s.defaultToken`).
5. `tests/test_proto.cpp` — extend the status-frame assertions: the new fields render, a
`tls:0` snapshot renders `fp:""`, and the rendered frame still fits
`kStatusJsonCap` for the longest legal SSID (that test exists; make sure the new fields
do not push it over).
6. `README.md` — "First bring-up" / Quick start: the fingerprint appears once at first boot,
`cert` reprints it, an `nvs erase` changes it, and the browser asks you to accept the
certificate once per browser. Security-model wording is task 06's job; do not restate the
whole model here.
- ASSUMPTION: the fingerprint lives in NVS next to the cert rather than being recomputed at
boot, so `cert` costs no hashing and the page can show it from the status frame; it is
public data, so storing it is not a secret-at-rest question.
- ASSUMPTION: `Preferences.putString` is fine for ~1 KB PEM blobs in the existing namespace
(`huge_app.csv` gives NVS 20 KB, already holding SSID/pass/token) — verify with the boot
log and the "nvs: " lines; if it does not fit, the fallback is a dedicated small partition
and that is a stop-and-report, not a silent change of layout.
## Testing & Quality
- `tests/test_proto.cpp`: new status fields pinned, cap arithmetic pinned.
- `bash .agents/validate.sh` green, including `console` (`KR_HAVE_HID == 0`) — the console
environment is what proves the TLS/identity path does not depend on USB HID.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): first boot prints a
fingerprint; reflash prints the same one; `cert` prints it again; `grep` over the serial
transcript for `PRIVATE KEY` finds nothing. Record `ok`/`skipped (no device)`.
## Completion Criteria
- [ ] `statusJson()` output contains `"tls":1` and a 16-hex `fp` on a TLS build; the frame
length is asserted `<= kStatusJsonCap`.
- [ ] `cert` on the serial console prints fingerprint + validity + SANs and no PEM body.
- [ ] Fingerprint survives a reflash (or the report says `skipped (no device)` and the exact
commands to confirm it).
- [ ] No private key material in any file, log, or `Serial.printf` (`git grep -i "private
key" src/` finds only the mbedTLS API call names).
- [ ] Full test suite green.
@@ -0,0 +1,106 @@
# Task 03 — The TLS listener: one session at a time, never blocking the loop
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something"
**Story:** `n/a`
## Objective
A second listener on 443 accepts a socket, drives an mbedTLS **server** handshake from
`tick()` without ever blocking `loop()`, and hands the control server a byte pipe that looks
like the plain socket it already reads. The keyboard path must not wait on the network stack
— that is the reason `tick()` is non-blocking today, and TLS must not break it.
## Work
1. `src/tls_session.h` (+ `.cpp`) — `kr::tls::Session`, one TLS connection over an already
accepted fd:
- `bool open(int fd, const PemPair &id)` — `mbedtls_ssl_init` +
`mbedtls_ssl_config_init` + `mbedtls_ssl_config_defaults(...,
MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)`,
`mbedtls_ssl_conf_endpoint(MBEDTLS_SSL_IS_SERVER)`, `mbedtls_x509_crt_parse` +
`mbedtls_pk_parse_key` + `mbedtls_ssl_conf_own_cert`, and
`mbedtls_ssl_set_bio(&fd, recvCb, sendCb, NULL)` with `recvCb`/`sendCb` wrapping
`::recv`/`::send` on the fd and mapping `EWOULDBLOCK` to
`MBEDTLS_ERR_SSL_WANT_READ`/`WANT_WRITE`. Set the fd non-blocking
(`fcntl(fd, F_SETFL, O_NONBLOCK)`) before the handshake. Client certification is **not**
requested (`MBEDTLS_SSL_VERIFY_NONE`) — the passphrase is still the controller's
credential, not the certificate.
- `Handshake poll()` → `InProgress | Ready | Failed`, calling `mbedtls_ssl_handshake()`
once per `tick()` and returning on `WANT_READ`/`WANT_WRITE`. A session that has not
completed within `kTlsHandshakeMs` is closed — a socket that opens and never finishes a
handshake is the same kind of scan as one that opens and never sends `T<token>`.
- `size_t read(uint8_t *dst, size_t cap)` / `size_t write(const uint8_t *src, size_t len)`
— `mbedtls_ssl_read`/`mbedtls_ssl_write`, returning 0 on `WANT_READ`/`WANT_WRITE` and
never spinning; a short write is returned as a short write (the caller keeps its
framing state, exactly as with `WiFiClient`).
- `bool alive()`, `void close()` — `mbedtls_ssl_session_reset` + `mbedtls_ssl_free` +
`mbedtls_x509_crt_free` + `mbedtls_pk_free` + `::close(fd)` on **every** path,
including the error paths out of `open()`.
- Do **not** use `esp_tls` for this: `esp_tls_server_session_create()` owns the socket
lifetime and its handshake is not driven the way `tick()` needs. Note that in the
header comment along with what was checked in `esp_tls.h`, so the next reader does not
"simplify" it back.
2. `src/control_server.h` / `.cpp` — a second `WiFiServer _tlsServer(kHttpsPort)`;
`acceptNew()` accepts from both listeners; `tick()` gives the TLS path the same shape as
the existing one: `acceptNew → serveHttp/serveWebSocket → timeout checks`. Keep the
controller slot in exactly **one** place: a socket is either HTTP, WebSocket or none, no
matter which listener it came from, so a TLS controller and a plaintext controller can
never both hold the keyboard.
- At most **two** live sessions at once (`kTlsMaxSessions`): the controller's, and one
more — a short-lived read-only request destroyed the moment its response is flushed,
or (phase 03) a pending takeover claim. A third connection is refused **explicitly**
(TLS alert then close, plus a counter), never queued and never half-opened: the
read-only path must fail visibly rather than push the board out of memory. This is a
RAM decision, not a policy one — say so in the comment, and keep the refusal
independent of which listener the socket came from.
3. `src/config.h` — `kHttpsPort = 443`, `kTlsHandshakeMs = 10000`,
`kTlsMaxSessions = 2`, and the RAM note: `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN` is 16384 in
this core, so a session's I/O buffers are ~2×16 KB. Try
`mbedtls_ssl_conf_max_frag_len(&ssl, MBEDTLS_SSL_MAX_FRAG_LEN_4096)` **only** if the
measured heap says it is needed, and record the measurement either way.
4. Measure, in the report and in `.agents/HW_VERIFICATION.md`: `ESP.getFreeHeap()` at boot,
with one session open, with a second (viewer) session open, and after both close —
against the phase 01 baseline (`bridge RAM 26.7% / 87 592 B`). Print the numbers in the
boot log behind `KR_DEBUG` if the project has a debug switch, otherwise log once at
startup and once per session close *as numbers only* (invariant 4: nothing about content).
5. `tools/hw_probe.sh` (from phase 01) — extend with a `openssl s_client -connect
<ip>:443 -servername keyrelay.local` probe wrapped in `timeout 5` reporting protocol and
cipher, informational, exit status unchanged.
- ASSUMPTION: raw mbedTLS rather than `esp_tls` (rationale above). If raw mbedTLS turns out
unable to complete a handshake non-blockingly on this core, the fallback is `esp_tls` in
async mode on the same fd — but that is a design change to be recorded in the task report
and the README's Design decisions, not a quiet swap.
- ASSUMPTION: TLS on 443 only, no plaintext WebSocket on 8443, no dual-stack port list. The
rollback is `-D KR_TLS=0` (task 06), not a second open port.
- ASSUMPTION: 2 concurrent sessions is the ceiling. Today's server already holds at most one
controller plus one one-shot viewer, so the policy is unchanged; only the cost per socket
went up.
## Testing & Quality
- Host: the parsing/timeout arithmetic that can be isolated (handshake-deadline comparison,
"a session that never completes is dropped", "read returns 0 rather than spinning") goes
into a pure helper in `src/tls_session.h` guarded the same way `output.h` is, and is
checked in `tests/test_tls_pem.cpp` or a new `tests/test_tls_session.cpp` if the helper is
big enough — no mbedTLS includes in either.
- Firmware: `bridge` and `console` both build; the report quotes the four heap numbers.
- Device (best effort, one attempt): `openssl s_client` completes a handshake and negotiates
`TLSv1.2`/`TLSv1.3` with `ECDHE-ECDSA-*`; a second concurrent connection is accepted while
the first is open; a connection that never completes the handshake is dropped after
`kTlsHandshakeMs` and the loop keeps typing (verify by watching the LED heartbeat / a
`status` poll during the stalled attempt).
- The keystroke path's latency is unchanged while a *second* client connects — that is the
regression this task can introduce, so it gets named in the report even when the answer is
"not measured".
## Completion Criteria
- [ ] A TLS connection to :443 completes a handshake and survives `kTlsHandshakeMs`-bound
teardown when it does not.
- [ ] `loop()` never blocks on the TLS path: the report states how that was shown (code walk
of every `mbedtls_ssl_*` call site plus the device observation), not asserted.
- [ ] Heap numbers recorded for boot / 1 session / 2 sessions / after close, and no leak over
20 connect/disconnect cycles.
- [ ] `bash .agents/validate.sh` green; full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,89 @@
# Task 04 — One routing path, two transports: TLS is not a second server
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the routing half: the routes must not fork into a secure one and an
insecure one that can disagree)
**Story:** `n/a`
## Objective
Put the transport underneath `ControlServer` instead of beside it: every route — page,
`/status`, `/ws`, the read-only viewer — is served through `serveReadonly()`/`serveHttp()`
once, over either a plain socket or a TLS session, and `:80` stops carrying content: it
redirects and otherwise tells the client to use `https://`. Invariant 8 explicitly requires
that both routes keep going through `serveReadonly()` "so they cannot drift apart"; a
half-encrypted server is the shape that drifts.
## Work
1. `src/conn.h` — a small transport view used by the routing code: `read(dst,cap)`,
`write(src,len)` (may be partial — returns what went), `alive()`, `stop()`, and
`writeProgmem(src,len)` for the page. Implemented by `PlainConn` (wraps `WiFiClient`) and
`TlsConn` (wraps `kr::tls::Session` from task 03). No virtual calls in the keystroke path
if a tagged union is cheaper — measure, then choose, and say which in the comment.
2. `src/control_server.cpp`:
- `readSome()`, `writeTextFrame()`, `httpResponse()`, `httpError()`, `sendStatus()`,
`sendError()` go through the conn. **`readSome()` keeps its rule**: only bytes from an
authenticated controller on the WebSocket refresh `_lastInboundMs`. A TLS session must
not change that — a handshake, a `/status` poll or a viewer refresh is not the operator
being alive (that comment in `readSome()` explains a real bug; carry it over).
- `serveReadonly()` and `serveViewer()` keep serving *both* listeners; a read-only
request over TLS is the same code with a different conn.
- The page write becomes **resumable**: `c.write_P(kIndexHtml, kIndexHtmlLen)` works for a
`Print`, not for a socket. Copy PROGMEM into a bounded RAM chunk (name the size) and
carry a per-connection send cursor (`sent`, `total`) so a 35 KB page drains across
several `tick()`s under a per-tick byte budget. If the client stalls, the existing 10 s
stall rule drops it. **Never** block in `loop()` waiting for a slow page consumer, and
never queue the page in RAM beyond the chunk.
- `closeLink()` stays the single funnel: whichever transport it is handed, it releases on
link change (`wasLinked` semantics, bug 10) and destroys the TLS session. A TLS teardown
failure must not skip the release.
3. `:80` behaviour (`KR_TLS == 1`):
- `GET /` and `/index.html` → `301` with `Location: https://<host>/…`.
- A request carrying a `token` query parameter (i.e. `/status?token=…`) → **`426 Upgrade
Required`** with a one-line body telling the operator to use `https://`, and **no
redirect** — a redirect that echoes `?token=` would expose the passphrase twice instead
of once. `AGENTS.md` says the token stays a query parameter on `/status` because `curl`
is the point of that endpoint; this keeps `curl` working (over TLS) without making the
plaintext path leakier.
- A `/ws` upgrade on :80 → `426`, plaintext controller refused rather than silently
downgraded (invariant 5: never quietly accept the weaker thing).
- All of the above is compiled out by `-D KR_TLS=0`, which restores today's `:80` server
exactly.
4. `src/config.h` — `kHttpRedirectPort`/`kHttpsPort` naming, `kPageChunkBytes`, and the
per-tick page budget, each with the measured numbers that chose it.
5. `tests/test_relayctl.py` + `tests/test_page.js` — assert the *refusal* shapes that can be
checked from the client side: the tool's error text distinguishes "board wants TLS" from
"wrong passphrase", and the page's URL builder picks `wss://` from an `https:` origin and
`ws://` only on a `-D KR_TLS=0` build (pin it by asserting the served page contains no
plaintext `ws://` literal when TLS is on).
- ASSUMPTION: 426 (Upgrade Required) for token-carrying and WebSocket requests on the
plaintext port, 301 for everything else. The alternative — redirect everything including
`?token=` — was rejected for the reason above.
- ASSUMPTION: mDNS stays one service on port 80 plus a second `_https._tcp` advertisement;
`keyrelay.local` must keep resolving, which is the whole point of the README's "IP if the
name fails" note.
## Testing & Quality
- Host: no protocol change, so `tests/test_proto.cpp` (2 327 checks) and `test_output.cpp`
must stay green untouched — if either needs editing, the task has changed the wire or the
funnel and must stop and say what it changed and why.
- The `readSome()` rule gets a **source pin** in `tests/test_relayctl.py` alongside the
existing three: the TLS conn's bytes reach `_lastInboundMs` only when
`_phase == WebSocket && _authed`. (Pinning source sentences is how bug 10 stays dead.)
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
gate's page count; `curl -sI http://keyrelay.local/` is a 301; `curl -s
'http://keyrelay.local/status?token=x'` is 426 and its body contains no token; the page
still reaches `linked` over `wss://` while a second tab watches `/status`.
- RAM: the chunk size + one page cursor, quoted in the report.
## Completion Criteria
- [ ] One `serveReadonly()` implementation reachable from both listeners (a `grep` for a
second HTTP response writer in `src/` comes up empty).
- [ ] Page delivered intact over TLS (byte-for-byte, `wc -c` and a `sha256sum` against the
PROGMEM count) and the send is resumable, not blocking.
- [ ] `:80` serves only the redirect and the two 426s; no keystroke-bearing path listens on 80.
- [ ] `bash .agents/validate.sh` green; the new source pin present; full test suite green.
@@ -0,0 +1,86 @@
# Task 05 — The client side of pinning: page and `relayctl.py`
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input…" (the half the operator actually touches: a browser that must trust the
board, and the terminal controller)
**Story:** `n/a`
## Objective
The page talks `wss://` when it was served over `https://`, shows the board's certificate
fingerprint so the operator can confirm it against the serial console, and refuses to accept
a passphrase when it was loaded over plaintext; `relayctl.py` speaks TLS over stdlib `ssl`
with **fingerprint pinning** as the default posture, `--ca` for a real CA, and `--insecure`
only as a loud, explicit opt-in.
## Work
1. `src/web_page.h`:
- `connect()` already picks `wss://` from `location.protocol === "https:"` — keep it, and
add the guard the other way round: when the page was loaded over `http:` while the board
reports `tls:1`, do **not** open a socket and do **not** accept the passphrase input;
say why ("this page arrived in the clear; reload over https"). Do not silently fall
back to `ws://`.
- A fingerprint line in the Connection card, driven by the status frame: `certificate
SHA-256 aabbccdd… — confirm it against the `cert` command on the board's serial
console`. Rendered only from `s.fp` (task 02), never assembled from anything the page
invents, and the pill says `tls` vs `plaintext` so the state is visible without reading.
- Keep every element id the script reaches for intact (`tests/test_page.js` checks that
coverage) and add the new ids to the markup in the same change.
2. `tools/relayctl.py`:
- `Relay.__init__`: build the socket through `ssl` when TLS is on — default port 443,
`WS_PATH` unchanged.
- Pinning: `--fingerprint <sha256>` (also honoured as `$KEYRELAY_TLS_FP`) connects with
certificate verification off, then takes `getpeercert(binary_form=True)`, hashes it and
**aborts on mismatch** before a single frame is sent. That is real pinning rather than
TOFU, and it needs no CA distribution for a self-signed device certificate.
- `--ca <pem>` uses `ssl.create_default_context()` with hostname checking on.
- `--insecure` sets `CERT_NONE` and prints a warning line to stderr on every run.
If none of the three is given, refuse to connect and say what to pass — the tool must
not be the component that quietly downgrades (`AGENTS.md` invariant 5).
- Keep `--token` / `$KEYRELAY_TOKEN` behaviour, including the pre-flight check that does
not require a reachable board, and never print the token.
- `-D KR_TLS=0` boards: `--plain` (explicit) selects the old `ws://` on port 80, so the
rollback path stays scriptable and remains opt-in.
3. `tests/test_relayctl.py`: URL/port selection per flag combination; the "no TLS option
given → refuse" rule; a fingerprint mismatch aborts **before** `T<token>` is sent (assert
on the recorded frame list, which the suite already has); `--insecure` emits the warning;
`--plain` is the only path to port 80; and all existing frame/table pins unchanged.
4. `tests/test_page.js`: the `https:` origin builds `wss://`, the `http:` origin refuses and
never sends a frame; the fingerprint line renders exactly the `fp` the status frame
carried and renders nothing when `tls:0`; every id the script touches exists in the markup.
5. `README.md` — Quick start: accept the certificate warning once per browser, or pin;
`curl -sk https://keyrelay.local/status?token=…`; `relayctl.py --fingerprint`; the
fingerprint from `cert`. Troubleshooting: `SSL_ERROR_R…`/`certificate verify failed` means
the board regenerated its identity (`nvs erase`) or you are on the wrong network — which
is exactly the attack the fingerprint is for, so say it in those words.
- ASSUMPTION: fingerprint pinning is the default posture rather than shipping a CA. A CA
distributed with the repo would be one shared key for every board (rejected in task 01);
per-board CA installation on every controller device is more ceremony than a keyboard
bridge is worth. `--ca` remains for the operator who wants a real chain.
- ASSUMPTION: the passphrase stays in `sessionStorage` (as today) and stays a `/status` query
parameter (per `AGENTS.md`). Over TLS both are encrypted in transit; the remaining exposure
is the local browser and shell history, which the README already names.
## Testing & Quality
- Both host suites extended as above; `bash tests/run_tests.sh` and the `SANITIZE=1` variant green.
- `uv … run -e bridge -e console` green, and `curl -sk https://keyrelay.local/ | wc -c` still
equals the gate's page count after the markup change (the page is served from flash — an
unflashed board shows the old page, so say which you observed).
- Device (best effort, one attempt): phone or laptop reaches the page over `https://`,
`linked` appears, the fingerprint shown matches `cert` on the console, and `relayctl.py
--fingerprint …` types while `watch_target.py` on the target confirms it.
- Coverage **>90%** for the changed `relayctl.py` paths (the suite drives the connection
object against a fake socket, as it already does for frames).
## Completion Criteria
- [ ] An `http:`-loaded page refuses to connect and to accept a passphrase (shown by
`test_page.js`, and by the served bytes when the board is reachable).
- [ ] `relayctl.py` with no TLS argument refuses; with a wrong fingerprint it aborts before
sending `T<token>`; with the right one it arms and types.
- [ ] The page displays the board's fingerprint from the status frame, and the pill says
`tls`.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,92 @@
# Task 06 — Tell the truth about it: docs, backlog, and the rollback build
**Phase:** `02_tls_control_channel` · **Source:** `TODO.md:3` — "Some encryption for
password/key input. Doesn't need to be proper x509, but something" (the documentation half
`AGENTS.md` requires in the same change: *"Docs move with the code."*)
**Story:** `n/a`
## Objective
The README stops describing a plaintext control channel — including the paragraph that
currently *apologises* for it — and Backlog item 2 ("TLS with a pinned certificate") is
closed. The `-D KR_TLS=0` rollback is built, documented and named, so the encryption is a
decision an operator can reverse rather than a dependency they are stuck with.
## Work
1. `README.md`, in place, keeping the reasoning and not just the behaviour:
- **Intro bullets + the check count**: update `**3406 checks**` to the new total and add
one bullet for what is now encrypted in transit.
- **"Security model and trust boundaries"**: the trust table — "Anyone on the board's
network" can no longer read keystrokes or the passphrase; add/adjust rows for
"Anyone who can see the traffic" (sees a TLS handshake to an unnamed host and nothing
else) and "Anyone with a copy of this firmware" (gets no key: the identity is generated
on each board). The paragraph beginning **"What the passphrase does and does not do"**
currently states that the page is plain HTTP, the socket is `ws://` and "the token is
visible at first contact"; rewrite it in place, keeping its structure — what TLS now
buys, what it does **not** buy (an unpinned connection is still an on-path risk until you
confirm the fingerprint; the target still sees whatever you type on it; the local
machine is still not trusted), and the **Practical rule** paragraph adjusted to
"the certificate you did not pin is the one thing to check".
- **Safety model**: one sentence locating TLS where it belongs — transport, not an arming
input. A TLS session that is not armed types nothing, and a dropped TLS session is a
lost link, which runs the funnel and disarms.
- **Wire protocol**: the table is unchanged (same frames), but add the line that the
transport is `wss://` and that `T<token>` no longer crosses the network in the clear;
`426` on the plaintext port documented with the error-code list.
- **Quick start**: first boot prints the fingerprint once, `cert` reprints it, the browser
asks once per browser, `curl -sk https://keyrelay.local/status?token=…`, `relayctl.py
--fingerprint`. Note the `-D KR_TLS=0` rollback right where a stuck operator looks for it.
- **Controller page**: the `tls` pill and the fingerprint line; keep the existing element
table current rather than appending a new section.
- **Architecture** file map: `src/tls_pem.h` (pure), `src/tls.h` (identity),
`src/tls_session.h` (session), `src/conn.h` (transport view), and what each owns.
- **Tests**: new `test_tls_pem` row with its real check count; the totals in the intro and
the table made to agree.
- **Measured behaviour**: the TLS numbers that task 03 measured — handshake time, heap
with 1 and 2 sessions, flash growth — as measurements, with the command that produced them.
- **Backlog**: remove item 2 and renumber the rest; if a narrower remainder exists (e.g.
"a real CA so browsers stop warning", or session-resumption/ticket tuning), that becomes
an item — do not leave a closed item in place.
- **Design decisions**: why TLS instead of a challenge–response over the passphrase; why
the identity is generated on the board rather than shipped as a PEM; why raw mbedTLS
rather than `esp_tls`; why a `token`-carrying request on `:80` is refused with 426
instead of redirected.
2. `AGENTS.md` — **fix stale statements only, do not expand**: the page-size check
`curl -s http://keyrelay.local/ | wc -c` becomes `curl -sk https://keyrelay.local/ |
wc -c` (the page is still served from flash, so the "reflash before you believe the
network" warning stays); "Current defaults worth knowing" gains the TLS identity fact in
the same one-line style (generated on board, in NVS, survives reflashing, `cert` prints
the fingerprint); the "Secrets" bullet keeps its rule and, if it does not already cover
it, the concrete instance that the private key lives in NVS and never in a file. Nothing
else in this file: phase narrative belongs in `.agents/phases/`.
3. `src/tls.h`, `src/tls_pem.h`, `src/tls_session.h`, `src/conn.h`, `tools/relayctl.py` —
the docstring at the top of each module describes *why*, in the house style of
`proto.h`/`web_page.h`/`control_server.h`: what it protects, what it does not, and the
decisions a later editor must not reverse by accident.
4. Build the rollback and prove it exists: `uv run --python 3.14 --with platformio python
-m platformio run -e bridge -e bridge --upload-port "" --build-flags "-D KR_TLS=0"` or
the equivalent temporary env (document the exact working command in the README rather
than inventing prose around it), and record the resulting `RAM:`/`Flash:` pair.
5. `.agents/HW_VERIFICATION.md` — fill in the device rows this phase touched with what was
actually observed, or `skipped (no device)` plus the command. Never a claim that was not
made.
## Testing & Quality
- `bash .agents/validate.sh` green, including both firmware environments and the rollback build.
- Every number quoted in the README matches a command in this repository (check count from
the suite, page bytes from the gate, heap/RAM/flash from the build and the boot log).
- A reader who has never seen the code can bring the board up over TLS from the README alone.
## Completion Criteria
- [ ] `grep -n "plain HTTP\|ws://" README.md` returns only statements about the rollback
build, not about the default.
- [ ] Backlog has no TLS item and the numbering is contiguous.
- [ ] The trust-boundary table reflects the encrypted channel, and the "does and does not"
paragraph names what TLS still does not protect.
- [ ] `-D KR_TLS=0` builds, and the README states the exact command and the trade.
- [ ] README check count and Tests table agree with the suite's own output; `AGENTS.md`
carries no new prose, only corrected facts.
- [ ] Full test suite green.
@@ -0,0 +1,75 @@
# Phase 03 — Take the bridge: session takeover that cannot arm itself
**Source:** `TODO.md` L6 — "Need to be able to take control if another session is connected.
Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
**Context:** `PLAN.md` "Safety position"; `src/control_server.{h,cpp}` (`acceptNew()`,
`serveViewer()`, `closeLink()`), `src/proto.h`, `src/main.cpp` (`releaseEverything()`,
`onMessage()`, `onLinkChange()`), `src/web_page.h`, `tools/relayctl.py`
## Objective
When a controller left connected on the laptop is holding the bridge, the operator on the
phone can take it: the newcomer authenticates on a *pending* claim, deliberately asks for the
bridge, the incumbent is told and dropped, **the release funnel runs**, and the board comes
back **disarmed** so the new session has to arm again on purpose.
## Why this shape
`E3` exists because two controllers typing into one machine is the ambiguity a keyboard must
not have — that stays. What changes is that "no room" was the *only* answer, which turns the
most ordinary accident (a laptop tab that kept the socket open) into a dead board until you
find the other tab or reboot the board. A claim is therefore not a new privilege: it is the
**same** privilege (the passphrase) exercised from a different socket, plus one deliberate
act, plus a guarantee that the handover cannot inherit anything.
The load-bearing rule for every task here: **a takeover can never produce an armed board.**
The incumbent's keys go up before the newcomer is adopted, `A1` from the newcomer is an
ordinary authenticated arm, and nothing in the handover path may call `arm()`.
## Dependencies
- `02_tls_control_channel` (todo) — the claim travels over the encrypted channel, and the
pending slot reuses the *one* extra socket the server already budgets (controller + one
more), so the TLS-session ceiling set in phase 02 (`kTlsMaxSessions = 2`) must exist. When
phase 02 is rolled back with `-D KR_TLS=0` the claim travels over plaintext `ws://`, exactly
like the controller does in that build — the claim adds no new security dependency.
## Tasks
1. `01_pending_claim_socket.md` — the second socket may handshakes and authenticate as a
*pending claim* instead of being refused; its own header buffer, bounded reader and
command whitelist; nothing else changes.
2. `02_takeover_funnel.md` — `X` claims the bridge: `E12` to the incumbent, the funnel, the
disarm, then adoption; silence-clock and `controllerSeen()` semantics kept honest.
3. `03_status_page_takeover_ui.md` — `busy` in the status frame, the page's TAKE OVER affordance,
`E12` handled, and the incumbent visibly disarmed with the reason in its log.
4. `04_takeover_tests_docs.md` — `relayctl.py --take-over`, the frame/pin tests that keep the
safety sentences true, README Safety/Security/Wire/Controller pages.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses (and refuses trailing garbage), `E12` renders, the
round-trip and bounds checks extended, the *existing* chord-label assertions untouched.
- `tests/test_relayctl.py`: the `--take-over` frame sequence (`T` then `X` then `H`) and the
default refusal; plus three **source pins** in the style of the existing three —
(a) the takeover path calls the release funnel before it adopts the newcomer,
(b) no code reachable from the `X` handler calls `arm()`/sets `armed`,
(c) a pending claim cannot exist without `_authed`.
These are the sentences a later edit can undo silently; they are the point of this phase.
- `tests/test_page.js`: refused-then-offered flow (a refused upgrade shows the affordance, it
does not retry-loop), `E12` disarms locally, the takeover button sends nothing until clicked.
- Coverage **>90%** on new/modified logic.
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): two controllers, one
arm, one held key, one takeover — `watch_target.py` must show the key **up** before the new
session's first key, and the board must report `armed:0` across the handover.
## Completion Criteria
- [ ] A second controller can authenticate and take the bridge; a third gets `E3`.
- [ ] The handover releases every key and every mouse button and leaves the board disarmed;
demonstrated on hardware or recorded as `skipped (no device)` with the commands.
- [ ] The incumbent's page says it was taken over, disarmingly and in its log.
- [ ] The three source pins exist and fail when the corresponding code is inverted (shown in
the report, then reverted).
- [ ] `bash .agents/validate.sh` green; README safety/security text says takeover cannot arm.
@@ -0,0 +1,80 @@
# Task 01 — A pending claim: the second socket may authenticate
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected. Sometimes I accidentally leave my laptop connected."
**Story:** `n/a`
## Objective
A second socket may complete the WebSocket handshake and authenticate as a **pending claim**
while the incumbent keeps the keyboard — instead of getting a `503` and being locked out of
the board entirely. Nothing can be *done* from a pending claim yet; this task only opens the
door far enough to stand in it.
## Work
1. `src/control_server.h` — the pending slot reuses the socket that today is `_viewer`, on
purpose: one incumbent + one more, never one incumbent + two claimants, because that is
already the server's shape and it is the TLS-session ceiling from phase 02
(`kTlsMaxSessions = 2`). Add, beside `_viewer`/`_viewerHttp`/`_viewerLen`:
- `enum class ViewerRole : uint8_t { Readonly, Claim };`
- `Phase _viewerPhase` (`None`/`Http`/`WebSocket`) and its own `ws::Reader` over a
**small** buffer — `kClaimMsgCap = 64` in `config.h`: `T<token>` is at most
`kTokenMax + 1`, `X` is one byte, `K` is one byte. Anything larger is refused, not
buffered. Add a comment naming the RAM each field costs and why the claim buffer is a
sixteenth of the controller's.
- `bool _claimAuthed`, `uint32_t _claimAtMs`, and (task 02) the claim's conn.
2. `src/control_server.cpp`, `serveViewer()`:
- an `/ws` upgrade from the viewer slot is now accepted into `ViewerRole::Claim` and
`Phase::WebSocket` (the same `ws::acceptFor()` handshake as the controller path); the
`503` path stays for what comes *after* the claim slot is occupied (task 02 wires the
counting; here, keep refusing a *third* socket and make `_rejected++` mean "no room").
- A pending claim authenticates exactly like a controller: `T<token>` within
`kAuthTimeoutMs`, the same constant-time compare, the same `_authFails`/`_lockUntilMs`
counters (one lockout for the board, not one per socket), and the same close-with-nothing
on failure.
- While pending and unauthenticated, **only** `T` is honoured. While pending and
authenticated, only `T`, `K` and (from task 02) `X`; every other frame gets
`E6`-shaped refusal and the claim is dropped. No arm, no state, no text, no mouse —
spell that list out in the code comment, because it is the whole safety argument.
- The claim never touches `_lastInboundMs`: a claim attempt is not the operator being
alive (the comment in `readSome()` explains why that distinction exists). It refreshes
`_claimAtMs` instead, and a claim that goes quiet is dropped.
- The read-only routes still work from the same slot when it is *not* in Claim role —
`serveReadonly()` remains the single implementation (invariant 8).
3. `src/config.h` — `kClaimMsgCap`, `kClaimTimeoutMs` (a pending claim that has not asked for
the bridge this long is dropped; 15 s is plenty for a human click, and an unanswered claim
holding a TLS session open is a nuisance attack), with the reasoning in the comment.
4. `tests/test_proto.cpp` / `tests/test_relayctl.py` — nothing on the wire yet, so both
suites must stay green **untouched**; if either needs a change, this task grew the
protocol and must stop and say so.
- ASSUMPTION: the claim is accepted into a pending state rather than the board keeping
`503`-only and adding an HTTP "request takeover" endpoint. A second control channel would
be a second auth path — exactly the drift `AGENTS.md` warns about — while the pending
WebSocket reuses the authentication code that already exists and is already tested.
- ASSUMPTION: one claim slot at a time; a third socket gets the `503`/`E3` it gets today.
- ASSUMPTION: a claim gets a smaller message cap than the controller. A frame that does not
fit is a refusal, consistent with invariant 7 (one frame is one command) and it keeps a
hostile claimant from allocating board RAM.
## Testing & Quality
- Host: existing suites untouched and green (`test_proto` at 2 327 checks, `test_output`
unchanged). New pure logic is limited to the claim state machine's predicates (which role,
which commands allowed, deadline arithmetic) — those go in a pure helper the tests can call,
in the style of `output.h`.
- Firmware: `bridge` + `console` build; report the RAM delta from the added buffers
(`kClaimMsgCap` + one `ws::Reader` + one header buffer), against `bridge 26.7% / 87 592 B`.
- Device (best effort, one attempt): with the controller connected, a second tab's `/ws`
upgrade is accepted and authenticates; a third socket is refused; the controller keeps
typing throughout.
## Completion Criteria
- [ ] A second `/ws` upgrade while a controller is linked reaches `authenticated` as a claim
(log/observation or `skipped (no device)`), and a third is refused.
- [ ] A pending claim can arm nothing: the whitelist is one visible list in the code and the
report names every entry.
- [ ] Claim timeouts and silence drop the claim and free its session; no leak over 20 attempts.
- [ ] `bash .agents/validate.sh` green; `test_proto.cpp`/`test_relayctl.py` unchanged in this task.
@@ -0,0 +1,78 @@
# Task 02 — `X`: hand the bridge over through the funnel
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…"
**Story:** `n/a`
## Objective
One authenticated frame from the pending claim — `X` — takes the bridge: the incumbent is
told (`E12`), the release funnel runs, the board ends up **disarmed**, and only then is the
claimant adopted as the controller. The handover is a link change that the rest of the
firmware recognises, not a silent substitution of one socket for another.
## Work
1. `src/proto.h` — `Cmd::Takeover` for a lone `X`, documented in the header comment's
direction table: `c -> d X claim this bridge for myself; requires authentication, and
nothing else is honoured on a claim socket`. `proto::formatError` unchanged; add
`kErrTakenOver = 12` to `control_server.h` ("your session was taken over by another
controller") and name it in the error list comment.
2. `src/control_server.cpp` — the handover, in this order and no other:
1. `sendError(12, "session taken over")` on the **incumbent** socket, so its page can say
why it stopped. It is best-effort: an unreadable incumbent must not block the handover.
2. `closeLink("taken over")` on the incumbent — the existing funnel. `closeLink()` already
disarms and releases for anything that reached the WebSocket phase (that is bug 10's
fix; do not re-narrow it here) and calls `_onLinkChange(false)`, which in `main.cpp`
runs `releaseEverything()`. **This must happen before adoption**, so the newcomer can
never inherit a held key or an armed bridge.
3. Promote the claim: move its conn into the controller slot, `_phase = WebSocket`,
`_authed = true`, `_controllerSeen = true`, `_lastInboundMs = millis()`,
`_connectedAtMs = nowMs`, reset the controller's reader, then `OK` + `sendStatus()` and
`_onLinkChange(true)`.
4. Clear the claim slot completely (`_claimAuthed = false`, reader reset, buffers zeroed)
so a stale claim cannot be promoted twice.
- `arm()` is not called, and nothing on this path can reach it. State that in a comment at
the top of the handover function and make the function's name say it too
(`handOverClaim()` or similar — not `takeover()` which reads like an arm).
- If the incumbent is already gone by the time `X` arrives, the promotion still runs
through the same steps (the funnel is a no-op when disarmed), so there is only one path.
- `_rejected` keeps meaning "a socket was turned away", and a successful takeover gets its
own counter (`_takeovers`), reported in the status frame (task 03) — counters only, never
addresses or identities (invariant 4).
3. `src/main.cpp` — `onMessage()` handles `Cmd::Takeover` for the case where an *existing*
controller sends `X` (a no-op with `OK`, documented: you already hold it) — do not let it
fall through to `E6` — and `fillStatus()` publishes the counter.
4. `src/config.h` — nothing new; `kClaimTimeoutMs` from task 01 bounds the claim's life.
- ASSUMPTION: `X` is honoured **only** on a claim socket (or is a no-op from the controller).
It is not a general command, so a controller cannot use it to reset its own session.
- ASSUMPTION: the incumbent gets no warning window and no veto. Its operator is by
definition absent — that is the situation the TODO describes — and a veto held by an
absent tab is the lockout we are removing. The protection is that the claimant needed the
passphrase, and that the board lands disarmed.
- ASSUMPTION: `E12` is a new code rather than reusing `E3`. `E3` means "you were refused";
`E12` means "you were dropped". One of those should be alarming on the incumbent's screen
and the other should not.
## Testing & Quality
- `tests/test_proto.cpp`: `X` parses to `Cmd::Takeover`; `X` with trailing bytes is `Cmd::Bad`;
`E12` formats; the render/parse round trip covers it; the existing 2 327 checks around chord
labels and state specs stay exactly as they are.
- `tests/test_relayctl.py` — the three source pins named in the phase overview: funnel before
adoption, no `arm()` reachable from the `X` path, no promotion without `_authed`. Written
the way the existing pins are written (a named assertion per sentence, with the sentence in
the output) so the suite says out loud what it checked.
- Device (best effort, one attempt): arm on tab A, hold a key, take over from tab B —
`watch_target.py` shows the key-up and the board reports `armed:0`; tab A shows `E12`.
Record the transcript in `.agents/HW_VERIFICATION.md` or `skipped (no device)`.
## Completion Criteria
- [ ] `X` from an authenticated claim drops the incumbent with `E12` and adopts the claim.
- [ ] The board is disarmed immediately after every takeover, observed or recorded as
unobserved-with-commands — never asserted without a mechanism.
- [ ] The three source pins exist and each one fails when its sentence is broken (shown, then
reverted).
- [ ] `bash .agents/validate.sh` green; `_takeovers` counter visible; full test suite green.
@@ -0,0 +1,76 @@
# Task 03 — Make it visible: `busy` in the status frame, TAKE OVER on the page
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the operator-facing half: the accident has to be fixable
from the screen you are looking at)
**Story:** `n/a`
## Objective
A controller that finds the bridge already held can see that it is held, can see who is
holding it in the only terms the board is allowed to know (a counter and a link state, never
an address), and can take it with one deliberate click. The incumbent's screen stops being
mysterious: it says it was taken over, disarms, and shows that in its log.
## Work
1. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool busy` (a controller holds
the keyboard: `linked()`) and `uint32_t takeovers`; `statusJson()` emits `"busy":u` and
`"tkov":u`; grow `kStatusJsonCap` with the arithmetic in the comment. **No** client
address, no user agent, no timing that would identify the other session — the README's
"never stored" list is about exactly this temptation.
2. `src/web_page.h`:
- The link pill gets a third honest state: `held by another controller` (from `s.busy`)
alongside `linked` and `link down`.
- A refused upgrade no longer retries in a loop. On close-without-auth, if the board
reports `busy`, show a **take over** button next to the passphrase field. Clicking it:
open the socket, authenticate with the passphrase already in the field, then send `X` —
one click, one intent. Before the click nothing is sent; after it the page behaves like
any other controller, starting **disarmed**, with a log line naming what happened.
- `onFrame()` handles `E12`: `setArmed(false, "another controller took over")`, drop the
mouse (`dropMouse`), abort a typing run, mark the link pill, and stop reconnecting
automatically — a page that immediately re-claims would turn two tabs into a fight.
The existing `code === "3"` branch keeps its meaning for "no room for a claim".
- Keep the arming banner, the safety hints and every id the script reaches for intact —
`tests/test_page.js` checks id coverage, and phase 04 is where the prose gets trimmed.
3. `tools/relayctl.py` — `--take-over`: after authentication, if the board reports a
controller already present, send `X` and continue; without the flag, a `busy` board is a
clear refusal naming the flag. `--take-over` on a free board is a no-op, not an error.
(Task 04 finishes the tool's tests; the flag lands here so the page and the tool agree
from the start.)
4. `README.md` — the **Wire protocol** table gains `X` and `E12` (this is a four-file
protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README — all four move), the
**Controller page** element table gains the take-over affordance, and **Security model**
gets the sentence that matters: someone with the passphrase can take the bridge over, and
cannot arm it by doing so.
- ASSUMPTION: the page reconnect policy after a takeover of *your* session is "stay down until
you click". Auto-reclaim is the fastest way to make two devices fight over a keyboard.
- ASSUMPTION: `busy` is a boolean plus a counter, not "who". The README's trust table already
promises the target's user cannot detect a second controller and the board stores nothing
identifying; `busy` keeps that promise while making the state visible to the person who is
locked out.
## Testing & Quality
- `tests/test_page.js`: `busy` renders the third pill state; a refused upgrade shows the
button and sends nothing until clicked; the click sends `T` then `X` in that order and
nothing else; `E12` disarms, releases the mouse, aborts typing and does not reconnect;
every id the script reaches for exists in the markup (the existing coverage check).
- `tests/test_proto.cpp`: the new status fields render and the frame still fits
`kStatusJsonCap` with the longest legal SSID.
- `tests/test_relayctl.py`: `--take-over` frame ordering; refusal without it; no-op on a free
board; every generated frame parseable by the firmware's own parser (already a suite rule).
- Device (best effort, one attempt): the whole gesture from a phone while a laptop tab holds
the bridge, observed with `watch_target.py` on the target.
## Completion Criteria
- [ ] `s.busy` drives a distinct pill state, and the take-over button appears only when the
board says it is held.
- [ ] One click takes the bridge; the page that lost it says so, disarmed, and stays down.
- [ ] `relayctl.py --take-over` works and the default refuses; both go through the same
frames the page sends.
- [ ] README wire table lists `X`/`E12` and all four protocol locations agree (`test_proto`
and `test_relayctl` green).
- [ ] Full test suite green; no client identity anywhere in the status frame.
@@ -0,0 +1,74 @@
# Task 04 — Pin the safety sentences, the procedure, and what `AGENTS.md` now gets wrong
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
if another session is connected…" (the half that keeps it true next month)
**Story:** `n/a`
## Objective
Make the takeover's safety claims the kind that fail loudly when someone edits them away:
executable checks in the host suites, a written end-to-end procedure that can be run against
a real target, and the two documents that currently state the old rule corrected so they no
longer describe a refusal that no longer happens.
## Work
1. `tests/test_relayctl.py` — the phase's three source pins, final form, in the style of the
existing three ("the suite says so out loud"):
- the takeover path calls the release funnel (`releaseEverything()` / the `onLinkChange`
that runs it) **before** the newcomer is adopted — assert the ordering in
`control_server.cpp`, not the intention;
- no `arm` / `armed = true` is reachable from the `X` handler;
- a claim socket cannot reach `Phase::WebSocket` promotion without `_authed`.
Also: the existing pins (silence disarm off `controllerSeen()`, `acceptNew()` never stamps
the controller clock, `closeLink()` stamps it only for a controller link) must still pass —
the takeover touched all three of those code paths, so re-read each one and confirm the
sentence is still true rather than trusting green.
2. `tests/test_page.js` — one check per safety sentence: takeover disarms the page that lost
the bridge; a lost page does not auto-reclaim; the claim sends nothing before the click;
the mouse is released on `E12`. Reuse the shim's `fire()`; extend the shim only as far as
`WebSocket` refusal needs (it already shims `WebSocket`, `sessionStorage` and `location`).
3. `README.md`:
- **End-to-end verification**: the takeover procedure in the commands-already-in-this-file
style — terminal A `watch_target.py` on the target, terminal B `relayctl.py … key a`
held, laptop tab holds the bridge, phone takes over, and what must appear in what order
(key-up before the newcomer's first key).
- **Safety model**: a bullet in the "disarmed is the only default" list — a takeover
disarms, releases every key and every mouse button, and cannot arm.
- **Tests**: the new check counts, and the suite descriptions updated to name the takeover
pins the way the table already names bug 10's pins.
- If this phase found a real bug — a stale `wasLinked`, a double promotion, a claim that
survived its own timeout — it goes into **"Ten bugs this verification caught"** with its
root cause and how it was proven, and the section's count changes with it. A bug fixed
silently is a bug that comes back.
4. `AGENTS.md` — **stale statements only**. Invariant 8 currently reads "A second `/ws`
upgrade is refused (`E3`)"; that is now wrong. Rewrite that one sentence to say what is
true: the keyboard stays exclusive, a second `/ws` is a pending claim that must
authenticate, and only `X` moves the keyboard — with `E3` now meaning "no room for a
claim". Nothing else in the file: no phase narrative, no new sections.
5. `.agents/HW_VERIFICATION.md` — the takeover row filled with what was observed (or
`skipped (no device)` + the commands). If it was skipped, the phase overview's completion
criteria must still be honest about it — the phase's final pass restates it, it does not
delete it.
- ASSUMPTION: the pins are source-text assertions, not behavioural ones, because the handover
needs two real sockets and there is no socket harness here. That is the same trade the
project already made for bug 10, and the same caveat is written into the test's comment.
## Testing & Quality
- Each new pin is demonstrated to fail when its sentence is inverted, then reverted (report
shows both runs) — a pin that cannot fail is decoration.
- `bash .agents/validate.sh` green: suites, sanitiser run, both firmware environments.
- Coverage **>90%** on the modified logic; `test_proto.cpp` unchanged apart from the status
fields and `X`.
## Completion Criteria
- [ ] Three takeover source pins present, named in the suite output, each proven to fail.
- [ ] Page safety sentences pinned in `test_page.js` (disarm on `E12`, no auto-reclaim,
nothing sent before the click, mouse released).
- [ ] README: safety bullet, end-to-end takeover procedure, Tests table and totals consistent.
- [ ] `AGENTS.md` invariant 8 describes the claim, not the old refusal, and gained no new prose.
- [ ] Full test suite green; no behaviour change in phases 01–02 (spot-check: TLS handshake
and `busy`-free takeover both still observed or recorded as unobserved-with-commands).
@@ -0,0 +1,70 @@
# Phase 04 — The controller page: less to read, works on a phone
**Source:** `TODO.md` L4 — "Clean up keyrelay interface, don't need as much text, make it
responsive"
**Story:** `n/a`
**Context:** `src/web_page.h` (markup + `<style>` + script), `tests/test_page.js`, README
"Controller page"; `AGENTS.md` "The page is served from flash"
## Objective
Reorganise the page so the controls are the thing you see: single column and thumb-sized on a
phone, unchanged on a laptop, and the explanatory prose folded away to where it is asked for
instead of sitting between the operator and the ARM button. **No safety statement gets
weaker or shorter** — they move, they do not shrink, and each one gets a test that fails if it
disappears.
## Why the prose is load-bearing anyway
Every long hint in this page exists because someone got hurt without it: the arming banner
(Enter goes to another machine), the `Esc`-frees-the-pointer note (Pointer Lock is invisible),
the layout caveat (characters are positions), the KDE paragraph (those three buttons are the
laptop's until the board steals them). Trimming them is the right call for usability and the
wrong call for safety *unless* the trimming is pinned. So this phase writes the assertions
before it deletes the sentences.
## Dependencies
- `03_session_takeover` (todo) — its TAKE OVER affordance and the `tls` pill/fingerprint line
are new elements in the same markup; folding prose around them is easier once they exist,
and the id-coverage check has to be updated once.
## Tasks
1. `01_responsive_layout.md` — CSS and viewport only: breakpoints, thumb-sized targets, sticky
ARM bar, safe-area insets, collapsed log; no markup restructure, no id changes.
2. `02_prose_trim_pinned.md` — the safety assertions first, then the fold: long hints into
`<details>`, the footer shortened, the connection/mouse/paste paragraphs cut to one line
each with the reasoning one tap away.
3. `03_suite_size_and_docs.md` — `test_page.js` extended for the new structure, the README's
page-size claim and its duplicated `test_page` row corrected, totals made to agree, and the
`curl` byte check re-derived.
## Testing & Quality
- `tests/test_page.js` is the only thing that can see this page before a browser does
(`AGENTS.md`: a raw string is invisible to every compiler). It must cover, in addition to
what it covers today:
- **safety strings still present**: the arming warning names Enter and "every key"; the
mouse hint names `Esc` and release; the paste hint names "stop typing"/disarm cancelling a
run; the layout note says positions/US layout; the default-passphrase shout still exists.
- every element id the script reaches for exists in the markup (existing rule, re-verified
after the restructure);
- the pinned page constants (`TEXT_FRAME_MAX`, `TEXT_JOB_MAX`) still equal `config.h`;
- the script still behaves identically: arming, state frames, paste chunking, mouse
capture/movement/button ordering, `E12`.
- Coverage **>90%** on the page script's changed branches; CSS is checked by assertions on the
served bytes (breakpoints present, sticky ARM bar) plus a phone observation.
- Device (best effort, one attempt): `curl -sk https://keyrelay.local/ | wc -c` equals the
byte count the gate prints, and a phone shows one column with the ARM bar reachable and the
log not pushing it off screen.
## Completion Criteria
- [ ] The page works at 360 px wide and at 1440 px wide without horizontal scroll, ARM is
reachable with a thumb, and the log is collapsible.
- [ ] Every safety statement above is still on the page **and** asserted by `test_page.js`.
- [ ] The page is smaller in bytes than it is today, and README's size claim matches.
- [ ] README Tests table has one `test_page` row with the real count (the duplicate stale row
at 47 checks is removed), and the intro's total check count matches the suite output.
- [ ] `bash .agents/validate.sh` green; no behaviour change in phases 01–03.
@@ -0,0 +1,66 @@
# Task 01 — Responsive layout: CSS and viewport, nothing else
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, don't need as much text, make it responsive" (the layout half)
**Story:** `n/a`
## Objective
The page lays out for the screen it is on: one column and thumb-sized controls on a phone, the
current dense grid on a laptop, the ARM bar always reachable, the viewport configured so the
layout is not scaled-bitmap small.
## Work
1. `src/web_page.h`, `<style>`:
- `main` grid: keep `auto-fit`/`minmax(240px,1fr)` above the breakpoint; at `@media (max-width:
720px)` force one column and reduce padding.
- Thumb targets: `button.k`, `input`, `.pill`, checkboxes get `min-height:44px` (and
`min-width` where a target is narrower) on coarse pointers —
`@media (pointer: coarse)` where that reads better than a width query.
- `input[type=password], input[type=text], textarea { font-size:16px }` on small screens:
below 16 px iOS Safari zooms the page on focus and never zooms back out. Name that in the
comment — it is why the size is not "13 px looks nicer".
- ARM bar: `position:sticky; top:0; z-index` so it survives scrolling on a phone; keep the
armed styling exactly as it is (`body.armed` outline is an indicator, not decoration).
- `#log`: `max-height` on small screens, and `overscroll-behavior:contain` so scrolling the
log does not scroll the page.
- `touch-action:manipulation` on the interactive elements (kills the 300 ms double-tap
zoom) and `user-select:none` where a press is a control, not text.
- `padding-bottom: calc(14px + env(safe-area-inset-bottom))` on `main`, plus
`viewport-fit=cover` added to the existing `<meta name="viewport">` (without it `env()`
is always 0). Do **not** add `maximum-scale`/`user-scalable=no` — pinch zoom stays.
2. `tests/test_page.js` — assertions on the served bytes, in the existing style: the viewport
meta contains `viewport-fit=cover` and does not disable scaling; a `max-width:720px`
breakpoint exists; the ARM bar is sticky; the coarse-pointer target floor exists; the
input font-size floor exists. These are layout claims, so they are checked where the page
can be seen without a browser.
3. `src/web_page.h` header comment — one paragraph: the page is for a laptop *and* a phone
held in one hand over a target you are standing at, and the sticky ARM bar exists because
the state must be unmissable at any scroll position.
- ASSUMPTION: breakpoints at 720 px plus a `pointer: coarse` query, rather than a full design
system or container queries. One breakpoint the README can describe beats five nobody can
remember.
- ASSUMPTION: dark theme only, as today. A light theme is not on anyone's list and doubles
every colour assertion.
- ASSUMPTION: the log stays in the page (collapsed by its own height, not hidden behind a tab)
because the log is where `E9`/`E4` explanations appear; hiding it is hiding the diagnostics.
## Testing & Quality
- `node tests/test_page.js` green, including the new byte-level layout assertions; the id
coverage check unchanged (no ids added or renamed by this task).
- `bash .agents/validate.sh` green; the page byte count printed by the gate changes and is
recorded (the README claim is task 03's job).
- Device (best effort, one attempt): a phone at 360–430 px logical width shows one column, the
ARM bar stays visible while scrolling, focusing the passphrase field does not zoom the page.
## Completion Criteria
- [ ] No horizontal scroll at 360 px, 768 px or 1440 px (observed on a phone or recorded as
`skipped (no device)` with the exact check).
- [ ] ARM reachable without scrolling from the top of the page on a phone, and it is still the
first element in `main`.
- [ ] Focus on the passphrase input does not zoom (or the report says unobserved).
- [ ] All four layout assertions present in `test_page.js`; full test suite green.
@@ -0,0 +1,70 @@
# Task 02 — Cut the reading, keep the warnings: assertions first
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, **don't need as much text**, make it responsive" (the prose half)
**Story:** `n/a`
## Objective
The page stops being a wall of explanation while every safety statement it carries survives
verbatim — each one now guarded by a test that fails if a later edit deletes it. Write the
assertions, then delete the prose.
## Work
1. `tests/test_page.js` — **first**, before touching the markup: add a named check per
statement that must survive, matching on normalised text so a rewrap does not trip it:
- arming forwards **every** key including Enter, and only while armed;
- `Esc` frees the pointer and releasing frees the buttons;
- disarm / stop / focus loss cancels a run of pasted text;
- characters are positions → the target must be on a US layout;
- the default-passphrase shout (driven by `dflt:1`, not static text);
- nothing is stored/scheduled/replayed, in whatever wording survives;
- the takeover cannot arm (added by phase 03 — guard it now so this task cannot lose it).
Run the suite: it must pass *before* the trim (proving the sentences are there) and after
(proving the trim did not remove them).
2. `src/web_page.h` markup:
- Each long paragraph becomes a one-line summary plus `<details><summary>why</summary>…`:
the KDE/snap-left explanation, the mouse "movement is relative" paragraph, the send-text
paragraph, the chords paragraph, and the footer's shortcut list. The **arming banner**
stays visible — it is the one paragraph that must never be behind a tap.
- The Connection card's passphrase paragraph → one line ("the board checks this on every
connection; without it nothing can arm") with the rest in `<details>`.
- Shorten `title` attributes that are currently multi-sentence (they are already duplicated
in the `<details>`); a `title` is a tooltip, not a manual.
- Collapse the Log card's option row and the State card's `kv` rows into a two-line summary
at narrow widths — counters stay visible; they are the "is it typing" evidence.
- Keep the DOM ids and the class hooks the CSS in task 01 targets; keep
`#banner`, `#arm`, `#log`, `#paste`, `#mPad` and the chord buttons' `data-chord`
attributes byte-identical (`test_proto.cpp` asserts every labelled chord against the
combination its label promises).
3. `<details>` in the shim — `tests/test_page.js`'s DOM shim is id-based, so `<details>` needs
nothing to parse, but the safety assertions must read text content across it: make the
matcher pull from the raw markup string (which the suite already has for the id check)
rather than from the shim's element tree.
4. `README.md` — Controller page section: say the hints are folded and that the safety notes
are pinned by `tests/test_page.js` ("the test asserts the warning is still on the page"),
which is the kind of sentence that keeps the next person from tidying them away.
- ASSUMPTION: `<details>` rather than a help modal or a tour: zero script, keyboard
accessible, printable, works with no JS, and it degrades to visible text in a browser that
ignores it — which is the safe direction for a safety note.
- ASSUMPTION: "don't need as much text" means the *explanations*, not the controls. The chord
buttons, the VT keys and the KDE row stay: the operator who does not need them today needs
them the day a window manager steals Alt+Tab.
## Testing & Quality
- The new safety-string assertions pass before and after the trim; both runs in the report.
- Every one of them is demonstrated to fail when the corresponding sentence is deleted (then
restored) — otherwise they are decoration, which is the failure mode `AGENTS.md` calls out.
- `node tests/test_page.js`, `tests/test_proto.cpp` (chord labels!) and the sanitiser run
green; page byte count drops and is recorded for task 03.
## Completion Criteria
- [ ] Safety statements present and pinned; each pin proven to fail on deletion.
- [ ] Visible prose on the laptop view is roughly a third of what it was; every removed
paragraph is reachable in a `<details>` in the served bytes.
- [ ] `data-chord` bytes, `#banner`, `#arm`, `#paste`, `#mPad` and every script-reached id intact.
- [ ] Full test suite green; `test_proto.cpp` untouched.
@@ -0,0 +1,61 @@
# Task 03 — Make the numbers agree again: suite coverage, page size, README
**Phase:** `04_controller_page_responsive` · **Source:** `TODO.md:4` — "Clean up keyrelay
interface, don't need as much text, make it responsive" (the verification half: a page claim
is only true when something measures it)
**Story:** `n/a`
## Objective
The restructure leaves the documentation and the suite telling the truth about the page as it
is now: one `test_page` row with the real count, the size claim matching the served bytes, and
coverage for the branches the restructure touched.
## Work
1. `tests/test_page.js` — coverage for the restructured page:
- id coverage after the restructure (the rule already exists; make sure it still runs
against the **markup** and not the shim, which hands out an element for any id);
- the `<details>` content is still in the served bytes (a folded note is not a deleted one);
- pinned constants: `TEXT_FRAME_MAX`/`TEXT_JOB_MAX` in the page still equal `kTextFrameMax`
/ `kTextJobCap` in `src/config.h` (the suite does this today — re-run it and confirm it
did not quietly stop matching after the edit);
- behaviour unchanged: arming, `S=` state frames, paste chunking and refusal, mouse capture
and button ordering, `E12`, the fingerprint line, `busy`.
2. `README.md`:
- Tests table: **one** `test_page` row, its real count, description covering the mouse, the
takeover and the folded safety notes. Delete the stale duplicate row that still claims 47
checks — the table currently lists the same suite twice with two different numbers, which
is exactly the "two paths that can disagree" shape `AGENTS.md` warns about.
- Controller page: "~35 KB" becomes the measured number, with the sentence that says how it
was measured (`curl -sk https://keyrelay.local/ | wc -c` against the byte count
`.agents/validate.sh` prints).
- Intro bullet `**3406 checks**` → the new total from the suite output, not arithmetic.
- Quick start / Troubleshooting: any sentence describing the old layout ("the page shows a
banner under the ARM bar", pill lists) corrected to what the page does now.
3. `AGENTS.md` — stale statements only: the page-size check line already changes in phase 02
(`https -k`); if the byte count is quoted anywhere, update it or point at the gate that
prints it rather than restating a number that will drift again.
4. `.agents/HW_VERIFICATION.md` — fill the phone row: 360 px, one column, ARM reachable, no
focus zoom, folded notes expandable — or `skipped (no device)` with the commands.
- ASSUMPTION: the totals in README are taken from a captured suite run pasted into the report,
not added by hand. The intro count and the table sum have disagreed before.
## Testing & Quality
- `bash tests/run_tests.sh` and `SANITIZE=1 tests/run_tests.sh` green; `bash
.agents/validate.sh` green including both firmware builds.
- The README's page-size claim and check count are reproducible from commands in this
repository, and the report shows both commands with their output.
- No change to `src/*.h` other than `src/web_page.h` markup/style; no behaviour change in
phases 01–03 (spot-check TLS, `busy`, and the paste path).
## Completion Criteria
- [ ] One `test_page` row in the Tests table with the real count; no duplicate anywhere.
- [ ] README page size == the served page's byte count (or the report says the board was
unreachable and quotes the PROGMEM count the gate prints).
- [ ] Intro check total matches the suite's own output.
- [ ] `test_page.js` covers: id coverage, folded content present, pinned constants, behaviour
unchanged; full test suite green.
@@ -0,0 +1,96 @@
# Phase 05 — The phone is a controller: soft keyboard and touch pointer
**Source:** `TODO.md` L5 — "Needs to be mobile compatible - click arm and then mobile
keyboard pops up. Mobile trackpad for touch trackpad."
**Story:** `n/a`
**Context:** `src/web_page.h` (the mouse block, `takeMouse()`/`flushMouse()`/`dropMouse()`,
`inPageField()`, `setArmed()`), `src/text.h` (`strokeFor()`), `src/proto.h`, `src/mouse.h`,
`src/config.h`, `tests/test_page.js`, README "Controller page" / "Safety model"
## Objective
Arm the bridge on a phone and the soft keyboard appears so you can actually type into the
target; press and hold the pad and the target's pointer moves, let go and every button is up.
Two capabilities the desktop gets from hardware — a physical keyboard and Pointer Lock — get
honest software equivalents, with the same guarantees restated in touch terms rather than
waived.
## The two equivalences, stated up front
| desktop guarantee | touch equivalent |
|---|---|
| Pointer Lock: the cursor cannot escape the tab, and `Esc` always frees it | **finger down on the pad is the capture, finger up is the release** — movement stops and every button lets go the instant the touch ends, and the Release button is 44 px and on screen. The capture cannot outlive the finger that asked for it. |
| Modifiers are physical keys held with a second hand | an **on-screen latch row** whose state is lit, restated in every frame, and cleared by a tap — never a sticky Ctrl that silently modifies the next six keys. |
Both are *more* conservative than the desktop case, not less: a touch capture ends when you
stop touching, which is stronger than a lock you have to remember to escape.
## What must not be traded away
- Movement is **relative**, one report per frame, deltas capped at ±127 and split so the net
displacement survives (invariant 7: splitting is the controller's job).
- No auto-click, no gesture, no macro, nothing scheduled. The button row is held-while-pressed.
- Capture requires armed **and** the board reporting `mouse:1`, and `E10`/`E11` still refuse
rather than ignore.
- Characters are typed by the board's own map. The page must **not** grow a second
character→usage table: the board owns `strokeFor()`, which is why the new `Q=` frame carries
*modifiers and hex bytes*, not usages. (`AGENTS.md`: when you add a table that decodes
something somebody else defined, test it — better still, do not add it.)
- Anything a touch session can leave behind (a latch, a held button, a focused field) dies in
the same funnel: disarm, blur, visibility change, link loss.
## Dependencies
- `04_controller_page_responsive` (todo) — the single-column layout, 44 px targets and
`touch-action:manipulation` are what make a touch pad usable; the folded prose is what makes
room for the latch row and the button row.
- `03_session_takeover` (todo) — `E12` handling and the `busy` pill, because a phone is exactly
the device that finds the laptop holding the bridge.
## Tasks
1. `01_touch_trackpad.md` — the pad: press-and-hold capture, relative movement from
`touchmove`, two-finger drag as the wheel, an explicit held-while-pressed button row, and
release on `touchend`/`touchcancel`/blur/disarm.
2. `02_soft_keyboard_capture.md` — ARM focuses a forwarding field so the soft keyboard pops;
printable characters leave as `P=0:<hex>` one per frame, named keys (Enter, Backspace, Tab,
arrows…) as a press/release pair; the field is cleared as soon as it is read.
3. `03_modifier_latch_q_frame.md` — the latch row and the `Q=<mods>:<hex>` frame that lets a
latched modifier type a character without the page owning a second character map. A real
four-place protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README.
4. `04_mobile_tests_docs.md` — `test_page.js` touch/latch coverage, the named-key table pinned,
README's Controller page and Safety model rewritten for touch, `AGENTS.md`'s "three tables"
row, and the phone observation recorded.
## Testing & Quality
- `tests/test_page.js` — the shim already records document/window listeners and can `fire()`
them; extend it to dispatch `touchstart`/`touchmove`/`touchend`/`touchcancel` with multiple
touch points and `beforeinput` with `inputType`, then assert:
capture requires armed and `mouse:1`; one frame per report with nothing sent when idle; a
400×300-style drag split so no delta exceeds a signed byte and the net displacement is exact;
two-finger drag becomes wheel/pan not movement; a finger up releases every button **and**
stops movement; the button row's bits match `MOUSE_BTN`; the latch row's byte matches the
modifier names; a character becomes exactly one `P=0:` frame and a latched one becomes one
`Q=` frame; `E9`/`E10`/`E11` land in the log and cancel nothing they should not.
Every frame the page emits is parsed by the firmware's own parser — the suite rule that has
already caught mislabelled chords.
- `tests/test_proto.cpp`: `Q=` parses, bounds-checks (mods byte, cap on hex length), refuses
malformed input, round-trips.
- `tests/test_relayctl.py`: the tool builds `Q=` identically, and the button/name tables and
`MOUSE_BUTTONS` agreement extends to whatever the touch rows introduce.
- Coverage **>90%** on the changed page script and the new parser path.
- Device (best effort, one attempt): a phone on the board's AP — arm, keyboard appears, type a
word and `watch_target.py` shows the characters; hold the pad, move, lift, and the target
shows motion that stops and no button stuck.
## Completion Criteria
- [ ] Arm on a phone brings up the soft keyboard and typed characters land on the target.
- [ ] Hold the pad → the pointer moves; lift → motion stops and every button is up, observed
with `watch_target.py` or recorded as `skipped (no device)` with the exact commands.
- [ ] `Ctrl+C`-equivalent reachable with the latch row and lands as Ctrl+C on the target.
- [ ] `Q=` in the README wire table, `proto.h`, `web_page.h` and `relayctl.py`, with the tests
that pin all four agreeing.
- [ ] No second character→usage table anywhere; the named-key table that does exist is pinned.
- [ ] `bash .agents/validate.sh` green; README and `AGENTS.md` describe the touch model.
@@ -0,0 +1,81 @@
# Task 01 — Touch pad: the capture is the finger
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible
— … **Mobile trackpad for touch trackpad.**"
**Story:** `n/a`
## Objective
`#mPad` becomes a touch pad: press and hold it and the target's pointer moves with your
finger, drag two fingers to scroll, and the moment the touch ends the movement stops and every
button lets go. Pointer Lock does not exist for touch, so the capture is the touch itself.
## Work
1. `src/web_page.h`, the mouse block:
- `touchstart` on `#mPad`: requires `armed` and `mouseAvailable()` (`s.mouse === 1`) — the
same two gates as `takeMouse()`, and the same `E10`/`E11` refusal path if the board
disagrees. Sets `mTouchActive`, records the anchor point, and takes **no** button.
- `touchmove`: compute the delta from the previous sample, feed the existing movement
pipeline (the code behind `flushMouse()`) so the ±127 cap and the multi-frame split are
the ones already tested — do not write a second splitter. Coalesce per report the way
`mousemove` does, and drop movement when there is no link rather than queueing it
(the page's existing rule: a queue of pointer motion delivered after you stopped moving
is the pointer version of a stuck key).
- Two-touch drag → wheel/pan through the same quantiser the desktop `wheel` handler uses
(notches with the remainder carried), not movement. `touchmove` with two fingers must not
also emit dx/dy.
- `touchend`/`touchcancel`, and `blur`/`visibilitychange`/disarm/link loss: end the capture,
`dropMouse()` (which releases every button through the funnel), reset the anchor. `touchcancel`
is the browser taking the gesture away — incoming call, notification shade, a palm — and it
must land exactly like lifting the finger.
- A visible button row beside the pad: **Left / Right / Middle**, held while pressed
(`touchstart` down, `touchend` up), carrying the existing `MOUSE_BTN` bits. Tap on the pad
itself never clicks. The row is 44 px and shows which bits are down, so "left is held" is
a thing you can see before you ask what is stuck.
- The pad's label changes with state (`not captured` / `captured — move to move, lift to
release`) since a phone has no cursor to indicate either.
2. `refreshMouseUi()` / `paintMouse()` / `mouseAvailable()` — teach them the touch state so
the pills say what is true. `mCaptured` (Pointer Lock) and `mTouchActive` are different
states that must render distinctly and never both claim the pointer.
3. Keep the desktop path byte-for-byte identical: `mousemove`, the Pointer Lock request,
`Esc`, `wheel` on a mouse — phase 04's assertions and today's `test_page.js` mouse checks
must stay green without edit.
4. `tests/test_page.js` — extend the shim for multi-point touch and add: capture requires
armed + `mouse:1`; a drag produces one `M=` per flush with no idle frames; a large drag
splits with the net displacement exact; two fingers produce wheel/pan and no dx/dy;
`touchend` and `touchcancel` each release every button and stop movement; button-row bits
equal `MOUSE_BTN`; every emitted frame parses with the firmware's own parser.
5. `README.md` — Controller page: the pad row of the element table describes the touch
behaviour; "What capture does and does not do" gains the touch sentence (this is
elaborated properly in task 04 — here, only what this task changes).
- ASSUMPTION: one finger moves, two fingers scroll, and clicking is an explicit held button
rather than a tap. Tap-to-click on a pad whose whole job is movement produces clicks the
operator did not ask for, and on a remote keyboard a click the target did not expect is the
worst class of surprise.
- ASSUMPTION: no "tap-and-hold to drag" (holding a button while moving). It needs a second
simultaneous gesture and any mis-timing strands a button on the target; the button row gives
the same capability with visible state.
- ASSUMPTION: scale of finger-delta to HID delta is 1:1 (one CSS pixel → one report unit) at
first. If it feels slow on hardware, the tuning knob goes in `config.h`-style naming on the
page side with the measured value in the comment — the board must not invent a pointer speed
of its own.
## Testing & Quality
- `node tests/test_page.js`: the new touch checks pass and the existing mouse checks are
unmodified (prove it: the diff of that test file adds, it does not change, mouse assertions).
- `bash .agents/validate.sh` green; `test_proto.cpp`, `test_mouse.cpp`, `test_output.cpp`
untouched.
- Device (best effort, one attempt): a phone on the board's AP — movement lands, lift stops
it, and `watch_target.py` shows no stuck button across 20 press/release cycles.
## Completion Criteria
- [ ] Press-and-hold on the pad moves the target's pointer; lifting stops it and releases
every button (observed, or recorded `skipped (no device)` with the commands).
- [ ] Two-finger drag scrolls and never moves the pointer.
- [ ] Left/Right/Middle are held-while-pressed and visible; tap on the pad clicks nothing.
- [ ] Desktop Pointer Lock path unchanged and still covered by the pre-existing checks.
- [ ] Full test suite green.
@@ -0,0 +1,94 @@
# Task 02 — Arm on a phone and the keyboard appears
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
**click arm and then mobile keyboard pops up.**"
**Story:** `n/a`
## Objective
Pressing ARM on a touch device focuses a forwarding field so the soft keyboard opens and stays
open, and everything typed into it becomes keystrokes on the target: printable characters via
one `P=0:<hex>` frame each, named keys as a press/release pair — with nothing held in the page
any longer than the frame that carries it.
## Work
1. `src/web_page.h`:
- A forwarding field: `<input id="keyfield" type="text" autocomplete="off" autocapitalize="none"
autocorrect="off" spellcheck="false" enterkeyhint="done" inputmode="text">`, visible while
armed on a coarse pointer (a visible field is honest: you can see the caret is in it),
collapsed to a one-line "keyboard" strip otherwise.
- `$("arm").addEventListener` → after arming is confirmed by the board (`setArmed(true)`),
`focus()` the field when `matchMedia('(pointer: coarse)').matches` — that is the
"keyboard pops up" the TODO asks for. Disarming blurs it and clears it.
- Read it with `beforeinput`, not `keydown`: soft keyboards are unreliable about `keydown`
with a usable `key`/`code` (Gboard and iOS both deliver `beforeinput` with `inputType`),
and `beforeinput` is the one event that says what the keyboard actually inserted:
- `insertText` → for each character, `send("P=0:" + hex(charBytes))` — the board's own
`strokeFor()` does the mapping and the pacing, so **no second character table on the
page** and no `Shift` guess on the laptop side;
- `deleteContentBackward` → Backspace; `insertLineBreak`/`insertParagraph` → Enter;
`deleteWordBackward` → a few Backspaces, documented, not a clever trick;
- then `field.value = ""` immediately. The field is a courier, not a buffer: invariant 4
says nothing typed may outlive the keystroke, and a DOM node full of the last paragraph
you sent is exactly that.
- Named keys (`keydown`, where `ev.key` is a name and not a character): Enter, Tab,
Backspace, Delete, Escape, the four arrows, Home/End/PageUp/PageDown — a small
`KEYNAME_TO_USAGE` map (≤20 entries, usage ids from `CODE_TO_USAGE`), sending
`S=<mods>:<usage>` then `S=<mods>:` (release) — a press/release pair, not an event, and it
self-heals if one of the two is lost.
- `inPageField()`: the passphrase box, the paste textarea and the log stay exempt from
forwarding; `#keyfield` is the **deliberate exception** and the comment must say so —
otherwise this field is "fixed" back into the exemption in six months and the phone stops
working.
- `Escape` from the soft keyboard is forwarded to the target (there is no Pointer Lock to
free on touch). While a desktop Pointer Lock is held, `Esc` stays the browser's — the
existing rule is not relaxed, it simply does not apply to a device that never took the lock.
- The field is never `preventDefault`ed (a soft keyboard that is cancelled does not type at
all), which is also why the desktop shortcut-swallowing logic must stay scoped to
non-coarse input.
2. `src/config.h` / page constants — nothing new for `P=`; assert the existing
`TEXT_FRAME_MAX` still bounds a single character trivially and that a rapid burst of
characters produces one frame each and is refused with `E8` (log it) if the board's queue
is full, rather than the page buffering them.
3. `tests/test_page.js` — extend the shim with `beforeinput`/`inputType` and a focusable
element, then assert: arming focuses the field on coarse pointers only; each inserted
character produces exactly one `P=0:` frame with the right hex; `field.value` is empty after
each event (nothing accumulates); Backspace/Enter produce the press/release pair; the
passphrase field and paste box still swallow input; disarm blurs and clears the field; a
`P=0:` frame the firmware cannot express yields the `E9` log line and nothing else is typed.
4. `README.md` — Controller page: the keyboard field row and the sentence that characters are
typed by the board's map, which is why the phone needs no layout of its own — and the
consequence that a non-US target still means non-US output (`E9` rather than a guess).
- ASSUMPTION: `beforeinput` as the primary source with `keydown` as the fallback for named
keys. Reading `value` on `input` instead would lose the distinction between "inserted" and
"autocompleted", and iOS predictive replacement arrives as `insertReplacementText` — which
this task deliberately **refuses** (log it, type nothing) because accepting a completion the
operator did not press is the same family of failure as silently sanitising a paste.
- ASSUMPTION: emoji, CJK and anything else `strokeFor()` cannot express produce `E9` per
character. That is the reject-never-guess rule arriving on the phone, and the log line says
what happened so it does not look like the board dropped keys.
- ASSUMPTION: the field is visible while armed on a phone. A hidden focused input that pops a
keyboard with no on-screen indication of *what* is receiving the keys is exactly the kind of
invisible state this project keeps having to fix.
## Testing & Quality
- `node tests/test_page.js` with the new focus/`beforeinput` coverage; every frame the page
emits still parsed by the firmware's parser; existing paste assertions unchanged (the paste
path and the keyboard path both use `P=` and must not interfere — assert that a keyboard
frame during a paste run does not disturb the paste's flow control).
- `bash .agents/validate.sh` green; page byte growth recorded against the gate's count.
- Device (best effort, one attempt): phone, arm, keyboard appears, type `relay` and
`watch_target.py` on the target shows `r e l a y` in order with no duplicates; type an emoji →
one `E9` and nothing on the target.
## Completion Criteria
- [ ] ARM on a phone opens the soft keyboard without a second tap.
- [ ] Typed characters land on the target, one frame each, in order, with no duplicates and no
accumulation in the field.
- [ ] Backspace and Enter work as press/release pairs; autocomplete text is refused, not typed.
- [ ] The passphrase box and paste box still swallow input while armed.
- [ ] Full test suite green; `src/text.h` untouched (the map did not move).
@@ -0,0 +1,92 @@
# Task 03 — Modifiers you can see: the latch row, and `Q=` to carry them
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible…"
(a soft keyboard sends no modifier keys, so without this the phone can type letters and
nothing else — no Ctrl+C, no Alt+Tab, no Ctrl+L)
**Story:** `n/a`
## Objective
An on-screen Ctrl / Alt / Shift / Super latch row whose state is visible and restated on every
frame, and one new frame — `Q=<mods>:<hex>` — that says "type these characters with these
modifiers held", so the **board's** character map stays the only character map. This is a
wire-protocol change and therefore four edits plus tests, exactly as `AGENTS.md` demands.
## Work
1. `src/proto.h` — `Cmd::TypedWithMods` for `Q=<mods>:<hex>` and a documented line in the
header comment's direction table:
`c -> d Q=<mods>:<hex> type these characters with <mods> held in addition to whatever
each character's own stroke needs`. Same bounds as `P=` (`kTextFrameMax` characters per
frame, cap on the hex length), same all-or-nothing acceptance, same `E9` for a character the
map cannot express.
2. `src/text.h` — the stroke path gains the modifier overlay: for each character,
`strokeFor()` + `OR` the frame's mods, then the same pacing and the same report sequence.
Two hard refusals, both `E6`/`E9` per the existing convention and **never** silently
clipped (invariant 5):
- **`Ctrl+Alt+Del` cannot be composed this way.** If the overlay plus the stroke's own mods
produce Ctrl+Alt and the usage is `Del` — or the usage set ever contains `0x4c` with
Ctrl+Alt — refuse the whole frame. Invariant 6 says CAD is fixed on the device and never
assembled from wire bytes; `Q=` must not become the door around that rule. Same for the
VT-switch chords the README documents as dangerous on a Linux VT.
- The overlay never releases a key: `Q=` types and lets go, it does not latch state.
3. `src/main.cpp` / `src/output.h` — wire the new command into the same typed-run machinery
`P=` uses (one run, one funnel: `R`, disarm, disconnect cancel it). If the queue is full the
frame is refused with `E8` like `P=`, and `typing` counts it.
4. `src/web_page.h`:
- A latch row: `Ctrl Alt Shift Super`, sticky toggles, using the existing `MOD_CODE` bits,
lit when active, `44px`, grouped with the existing chord buttons so there is one place
for "keys the phone does not have".
- Printable characters: `P=0:<hex>` when no latch is down, `Q=<mods>:<hex>` when one is.
The latch is cleared by tapping it again, by disarm, by blur/visibility change and by link
loss — never left set between sessions (a sticky Ctrl that silently modifies the next six
characters is this project's stuck-key bug with a new face).
- Named keys keep using `S=<mods>:<usage>` (it already carries the modifier byte) and
include the latches in that byte.
- A "held now" echo of the latches in the existing `#held` readout, so what the board is
being told is on screen.
5. `tools/relayctl.py` — `type --with-mods ctrl+alt TEXT` (or the equivalent subcommand shape
the tool already uses) building `Q=` byte-for-byte like the page; the tool's `MOUSE_BUTTONS`
/ modifier name tables extended and pinned against the page.
6. `README.md` — the **Wire protocol** table gains `Q=` with its modifiers column and refusal
rules; the error-code list gets whatever `Q=` refuses with; the Controller page element
table gains the latch row; **Layout caveats** gains one sentence: the phone borrows the
board's map, so the same US-layout caveat applies to it.
- ASSUMPTION: `Q=` rather than making the page resolve characters to usages. A second
character→usage table would have to be tested against `strokeFor()` forever — the
"three tables that must agree" problem `AGENTS.md` exists to prevent, self-inflicted. One
frame with a modifier byte keeps the map in `text.h`, which is already walked across the
whole printable range by `test_text.cpp`.
- ASSUMPTION: sticky (toggle) latches, not press-and-hold. On a phone there is no second hand
free to hold Ctrl while tapping, and a latch whose state is lit and restated is verifiable
in a way a half-held key is not.
- ASSUMPTION: `Q=` inherits `P=`'s pacing (`0` = the board's default). A phone cannot type
faster than the board's pace anyway, and letting the controller choose the pace for a
latched character buys nothing but a new way to mis-time a target.
## Testing & Quality
- `tests/test_text.cpp`: overlay arithmetic (no mods, Ctrl only, Ctrl+Shift where the stroke
already needs Shift), refusal cases including the composed-CAD attempt and the VT chords, the
exact report sequence for `Q=05:61` (Ctrl+Alt+a) asserted byte by byte, cancellation of a `Q=`
run by `R`/disarm, buffer-full behaviour, and the `Q=` render/parse round trip at the largest
legal frame.
- `tests/test_proto.cpp`: `Q=` parses, malformed variants (`Q=`, `Q=zz:`, `Q=05`, over-long
hex) are `Cmd::Bad`, and the existing `P=`/`S=`/`Y=` assertions unchanged.
- `tests/test_relayctl.py`: the tool's `Q=` frames are byte-identical to the page's, parsed by
the firmware's own parser; modifier-name tables agree across page, tool and `config.h`.
- `tests/test_page.js`: latch toggles produce the right byte; a character with a latch produces
one `Q=` frame; disarm/blur clears the latch; the chord buttons still send their
`data-chord` bytes unchanged (`test_proto.cpp`'s label-vs-bytes assertion must stay green).
- Coverage **>90%** on the parser and stroke-overlay changes.
## Completion Criteria
- [ ] `Q=` present and identical in `proto.h`, `web_page.h`, `relayctl.py` and the README table.
- [ ] Ctrl+C on a phone lands as Ctrl+C on the target (observed with `watch_target.py` or
recorded `skipped (no device)` with the commands).
- [ ] `Q=` cannot compose Ctrl+Alt+Del or the VT-switch chords — proven by a test that fails
when the refusal is removed (show both runs), and the reasoning is in `text.h`.
- [ ] Latches clear on disarm, blur and link loss, and are shown in the held-keys readout.
- [ ] Full test suite green, including the sanitiser run (this task changes buffer arithmetic).
@@ -0,0 +1,78 @@
# Task 04 — Make the touch model legible, and prove the tables
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
click arm and then mobile keyboard pops up. Mobile trackpad for touch trackpad." (the half
that keeps it working after this chat is gone)
**Story:** `n/a`
## Objective
The touch equivalences are written down where they will be read before someone "improves"
them, the one new table this phase introduces is pinned against what it maps to, and the phone
observations are recorded as observations.
## Work
1. `tests/test_page.js` — the last touch assertions, added in the suite's existing voice:
every frame the page emits in touch mode parses with the firmware's own parser; the pad's
frames never exceed one signed byte in either axis; `touchcancel` is treated exactly like
`touchend` (a notification shade stealing the gesture must not leave a button down); the
pad capture ending does **not** disarm the keyboard (release and disarm are different acts,
already true for `R`); the mouse capture and the keyboard stay independent — taking the pad
does not arm, and disarming drops the pad.
2. `tests/test_relayctl.py` — the named-key table (`KEYNAME_TO_USAGE`, the ≤20-entry page map
for Enter/Tab/arrows/…) pinned: every entry's usage id checked against the value the
firmware's parser and `CODE_TO_USAGE` agree on, and the set of names checked against a list
in the test — the table decodes something the HID spec defined, so the *table* is tested,
not anybody's confidence in it (`AGENTS.md`, the `REL_*` lesson: codes guessed from field
order were wrong while the feature appeared to work).
3. `README.md`:
- **Controller page**: a short "On a phone" block — arm focuses the forwarding field, the
latch row, the pad's press-and-hold, the button row, `E9` on emoji.
- **Safety model**: the touch equivalences written as rules, not as UI description —
finger-down is the capture and finger-up ends it; the latch is visible and cleared by every
fail-safe; `Esc` cannot be forwarded from a soft keyboard because the phone's panic path is
the Release button, which is 44 px and on screen. Add the sentence that says *why* the
touch capture is stronger than Pointer Lock rather than weaker.
- **Security model**: the trust-boundary row for "Whoever holds the phone" — pointer only
while armed and while the finger is down.
- **Wire protocol**: `Q=` already landed in task 03; here the surrounding prose (the
`<mods>` explanation, the refusal list) is brought in line with what the code actually does.
- **Tests** table: the new counts, and the touch/table pins named the way the mouse pins are
named. Intro total updated from real output.
- **Measured behaviour**: the phone numbers — keyboard latency per character through `P=`,
pad deltas per cm of travel, anything `watch_target.py` showed. Numbers, with the command.
- **Backlog**: anything this phase discovered and deliberately did not do (e.g. gesture-free
right-click on a tablet with a hover-capable pointer, a `beforeinput` path for
autocompletion, a non-US soft keyboard), so the discovery is not lost.
4. `AGENTS.md` — the "Three tables that must agree" table gains the one new row this phase
created (`KEYNAME_TO_USAGE` in `src/web_page.h`, pinned by `tests/test_relayctl.py`), in the
existing format. Nothing else: the file is the operating manual, not a changelog.
5. `.agents/HW_VERIFICATION.md` — the phone rows: keyboard pops on ARM, characters in order,
latch combo on the target, pad move/stop/no stuck button, `E9` on emoji — each `ok` with the
command and what was seen, or `skipped (no device)` with the command to run.
6. `.agents/PLAN.md` — no edits (it is locked); if this phase revealed that an anchor was
wrong, that goes in the phase report and the final pass raises it, not into the file.
- ASSUMPTION: the named-key map stays page-only (the terminal tool does not need a soft
keyboard). The pin exists so the page's map cannot drift from the HID ids it names, not to
synchronise two copies.
## Testing & Quality
- Full gate green: suites, sanitiser run, `bridge` + `console` builds.
- Each new pin demonstrated to fail on an injected break (a wrong usage id in
`KEYNAME_TO_USAGE`, a `touchcancel` handler removed), then reverted.
- README internally consistent: intro counts, Tests table, Controller page, Safety model and
Backlog all describe the same page the board serves (`curl -sk … | wc -c` agrees with the
gate's number).
## Completion Criteria
- [ ] `KEYNAME_TO_USAGE` pinned by name **and** by usage id, with the failure demonstration.
- [ ] Touch safety rules in the README as rules, including why finger-up is stronger than Esc.
- [ ] `AGENTS.md` table row added, nothing else in that file changed.
- [ ] Phone observations recorded honestly (`ok` or `skipped (no device)` + command) — no claim
in that file that nobody made.
- [ ] `bash .agents/validate.sh` green; full test suite green; no regression in phases 01–04
(TLS still terminates, takeover still disarms, layout still single-column).
+2
View File
@@ -19,3 +19,5 @@ __pycache__/
token
*.capture
*.log
.agents/phase-sessions/
.agents/pipeline.log
+9
View File
@@ -12,6 +12,15 @@ the traps are. **Do not fork the protocol or the safety model into `docs/`** —
drifts, and two paths that can disagree about arming is the exact failure this project keeps
having to fix (see README, "Ten 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