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:
+121
@@ -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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user