Files
keyrelay/.agents/phases/todo/03_session_takeover/00_phase.md
T
ducoterra 56aa97d60d 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
2026-11-08 23:28:25 -05:00

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=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.