phase: 03_session_takeover

**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
This commit is contained in:
2026-11-09 08:57:35 -05:00
parent e0780f58e0
commit d629a0ff51
45 changed files with 2884 additions and 161 deletions
+90 -10
View File
@@ -3,12 +3,21 @@
* over a plaintext socket or a TLS session -- into one controller slot.
*
* Shape of it:
* - one controller at a time. A second WebSocket upgrade is refused outright rather
* than queued or swapped silently, because two controllers typing into one machine
* is exactly the ambiguity a keyboard must not have. The page says so (E3).
* Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board.
* Refusing everything meant pressing reload on your own tab failed, and a live
* controller could not be inspected by anything else (serveViewer).
* - one controller at a time. Two controllers typing into one machine is exactly the
* ambiguity a keyboard must not have, so the second socket never *drives* one. What
* it may now do (phase 03) is stand in the spare slot as a **pending claim**: it
* completes the same handshake, authenticates with the same passphrase against the
* same lockout counters, and may then send nothing but `K` and the deliberate `X`
* -- every arm, key state, chord, text, mouse and release frame is refused by the
* one whitelist in claim.h and the claim is dropped. `X` hands the bridge over
* *through the funnel*: the incumbent is told (`E12`), closeLink() runs (which
* disarms and releases every key and button -- bug 10's fix, un-narrowed), and only
* then is the claimant adopted as the controller, DISARMED. No path reachable from
* `X` can call arm(); the newcomer arms with an ordinary `A1` or not at all
* (handOverClaim). Before phase 03 the answer was `503`/`E3` outright; read-only
* HTTP is *not* and was never refused: what is exclusive is the keyboard, not the
* board. Refusing everything meant pressing reload on your own tab failed, and a
* live controller could not be inspected by anything else (serveViewer).
* - two listeners, ONE state machine, ONE transport view. :80 and (KR_TLS) :443 both
* feed the same accept decision -- tls::admitConnection -- the same serveHttp() /
* serveWebSocket() routing and the same serveReadonly(); the only thing a socket's
@@ -37,6 +46,7 @@
#include <WiFi.h>
#include "claim.h"
#include "config.h"
#include "conn.h"
#include "proto.h"
@@ -63,6 +73,11 @@ enum ErrorCode : uint8_t {
kErrUntypeable = 9,
kErrMouseDisabled = 10,
kErrMouseBootProtocol = 11,
// "you were DROPPED", sent on the incumbent's socket just before the handover closes
// it -- distinct from kErrBusy ("you were refused"), because the controller that was
// never let in and the controller that was deliberately displaced deserve different
// sentences on their screens (proto.h carries the list).
kErrTakenOver = 12,
};
class ControlServer {
@@ -82,6 +97,9 @@ class ControlServer {
// arm/disarm, so the page's one source of truth is the board, not its own guess.
void sendStatus();
void sendError(uint8_t code, const char *text);
// The bare `OK` the protocol promises as "accepted": what `X` from the incumbent
// controller gets (it already holds the bridge; there is nothing to do but say so).
void sendOk();
bool linked() const { return _phase == Phase::WebSocket && _authed; }
/**
@@ -96,6 +114,11 @@ class ControlServer {
uint32_t rejected() const { return _rejected; }
uint32_t authFails() const { return _authFails; }
// Successful bridge handovers (phase 03): a count of events, never addresses, user
// agents or anything else that would identify the other session (invariant 4 -- the
// README's never-stored list is about exactly this temptation).
uint32_t takeovers() const { return _takeovers; }
// Frames the transport could not take (its queue was full) -- a periodic status frame
// loses its slot, the next tick's drain resumes, and nothing is ever half-sent. Either
// transport: the queue is the transport view's, so the count means one thing everywhere.
@@ -115,9 +138,11 @@ class ControlServer {
void setIdentity(const tls::PemPair *identity) { _identity = identity; }
// Live TLS sessions, both slots -- the heap-cost readout main.cpp logs whenever this
// changes. A number, about numbers; nothing about content (invariant 4).
// changes. A number, about numbers; nothing about content (invariant 4). Walked over
// the STORAGE, not the slot pointers: a bridge handover swaps which object serves
// which slot (handOverClaim), and the count means the same thing either way.
uint32_t tlsLiveSessions() const {
return (_tlsCtl.alive() ? 1u : 0u) + (_tlsView.alive() ? 1u : 0u);
return (_tlsStore[0].alive() ? 1u : 0u) + (_tlsStore[1].alive() ? 1u : 0u);
}
#endif
@@ -168,6 +193,18 @@ class ControlServer {
// 426) in a TLS build and is always true -- and inert -- in the KR_TLS=0 rollback.
void serveReadonly(const ws::Request &req, BuiltResponse &rsp, bool plaintext);
void serveViewer(uint32_t nowMs);
// The pending claim's half of the spare slot: the WebSocket that lives there once
// an upgrade turned Readonly into Claim (claim.h for the rules).
void serveClaim(uint32_t nowMs);
bool handleClaimFrame(uint32_t nowMs);
// The bridge handover (`X`): tell the incumbent, run the release funnel, adopt the
// claimant -- in that order and no other. Named handOver, never takeover, so a
// reader cannot mistake it for an arming path: it is the opposite, the board lands
// disarmed and nothing here may call arm().
void handOverClaim(uint32_t nowMs);
void dropClaim();
void sendClaimText(const char *text, size_t len);
void sendClaimError(uint8_t code, const char *text);
void stopViewer();
void serveWebSocket(uint32_t nowMs);
void closeLink(const char *why);
@@ -217,6 +254,40 @@ class ControlServer {
size_t _viewerLen = 0;
uint32_t _viewerAtMs = 0;
// ---- the pending claim (phase 03) ------------------------------------------
// What the spare slot is for, and how far its request has got. The role type and
// the frame whitelist live in claim.h (pure, host-tested) because the whitelist is
// the whole safety argument of this slot and an argument nothing executes is one a
// later edit can undo silently; `ViewerRole` is spelled here so the slot reads the
// way the task that added it named it.
//
// What each field costs, in the static RAM this board has no PSRAM of:
// _viewerRole, _viewerPhase 1 byte each (uint8_t enums; they pack together)
// _claimAuthed 1 byte -- the claim's OWN auth flag. The
// controller's `_authed` stays a separate
// fact on purpose: linked() is the
// controller link, and a claim must never
// make one look live
// _claimAtMs 4 bytes -- the claim's own deadline anchor; the
// controller-silence clock (_lastInboundMs)
// is not it and is never moved by a claim
// _claimMsg + _claimReader 64 bytes + ws::Reader's ~40 B of decode state (the
// payload IS _claimMsg; the reader owns no
// buffer of its own)
// Total ~110 B. The claim buffer is a QUARTER of the controller's kWsMessageCap --
// 64 against 256 -- because the widest frame a claim may ever send is `T<token>` at
// kTokenMax + 1 = 33 bytes (config.h re-derives this); anything larger is refused by
// the codec, not buffered, so this slot cannot be made to grow.
using ViewerRole = claim::ViewerRole;
ViewerRole _viewerRole = ViewerRole::Readonly;
Phase _viewerPhase = Phase::None;
bool _claimAuthed = false;
uint32_t _claimAtMs = 0;
uint8_t _claimMsg[kClaimMsgCap];
ws::Reader _claimReader{_claimMsg, sizeof(_claimMsg)};
// Task 02 adopts the claim's Conn on the bridge handover; until then the claim
// travels on `_view`, the slot it authenticated on, and nothing else.
uint8_t _wsBuf[kWsMessageCap];
ws::Reader _reader{_wsBuf, sizeof(_wsBuf)};
@@ -231,6 +302,7 @@ class ControlServer {
uint32_t _rejected = 0;
uint32_t _txDropped = 0;
uint32_t _slotRefused = 0;
uint32_t _takeovers = 0; // completed handovers: counters only, never identities
#if KR_TLS
// The encrypted listener: a raw LWIP socket rather than WiFiServer, because the
@@ -240,8 +312,16 @@ class ControlServer {
int _tlsListen = -1;
const tls::PemPair *_identity = nullptr;
tls::Session _tlsCtl; // the controller slot, when _ctl.isTls()
tls::Session _tlsView; // the spare slot, when _view.isTls()
// Two session objects (kTlsMaxSessions of RAM is what sized them), and two pointers
// that say which one the controller slot uses. Normally _tlsCtl is store[0] and the
// spare's is store[1]; a bridge handover SWAPS the pointers so the claimant's live
// session is named by the controller slot without one crypto context having to move
// inside another (mbedtls contexts own heap buffers and are not moveable state --
// a pointer swap is a fact about slot naming, which is all the handover needs).
tls::Session _tlsStore[kTlsMaxSessions];
static_assert(kTlsMaxSessions == 2, "the slot pointers below name store[0] and store[1]");
tls::Session *_tlsCtl = &_tlsStore[0]; // the controller slot, when _ctl.isTls()
tls::Session *_tlsView = &_tlsStore[1]; // the spare slot, when _view.isTls()
#endif
OnMessage _onMessage = nullptr;