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:
+90
-10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user