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