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
4.5 KiB
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=0the claim travels over plaintextws://, exactly like the controller does in that build — the claim adds no new security dependency.
Tasks
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.02_takeover_funnel.md—Xclaims the bridge:E12to the incumbent, the funnel, the disarm, then adoption; silence-clock andcontrollerSeen()semantics kept honest.03_status_page_takeover_ui.md—busyin the status frame, the page's TAKE OVER affordance,E12handled, and the incumbent visibly disarmed with the reason in its log.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:Xparses (and refuses trailing garbage),E12renders, the round-trip and bounds checks extended, the existing chord-label assertions untouched.tests/test_relayctl.py: the--take-overframe sequence (TthenXthenH) 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 theXhandler callsarm()/setsarmed, (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),E12disarms 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.pymust show the key up before the new session's first key, and the board must reportarmed:0across 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.shgreen; README safety/security text says takeover cannot arm.