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
+53 -5
View File
@@ -45,9 +45,24 @@ int main() {
CHECK(parse("C") == Cmd::SecureAttention);
CHECK(parse("A1") == Cmd::Arm);
CHECK(parse("A0") == Cmd::Disarm);
CHECK(parse("X") == Cmd::Takeover); // the pending claim's one deliberate act
// hand-typed clients end lines with \n: tolerated
CHECK(parse("A1\n") == Cmd::Arm);
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
CHECK(parse("X\r\n") == Cmd::Takeover); // tolerated here exactly like the others
}
CASE("the takeover frame: what a claimant puts on the wire is what the board parses");
{
// There is no formatX(): the client side of `X` is the single byte, and this is
// the round trip -- the exact bytes the page or relayctl will send, run through
// the firmware's own parser, plus the name the diagnostics print.
const char frame[] = "X";
Message m;
CHECK(kr::proto::parse(frame, sizeof(frame) - 1, m) == Cmd::Takeover);
CHECK(std::string(kr::proto::name(Cmd::Takeover)) == "takeover");
// Anything else starting with X is not a takeover and not a conversation.
CHECK(kr::proto::parse("XK", 2, m) == Cmd::Bad);
}
CASE("garbage is rejected, never half-accepted");
@@ -56,6 +71,9 @@ int main() {
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
CHECK(parse("A2") == Cmd::Bad);
CHECK(parse("A11") == Cmd::Bad);
CHECK(parse("XX") == Cmd::Bad); // `X` is one command; trailing bytes are Bad
CHECK(parse("X1") == Cmd::Bad); // ... even bytes that look meaningful
CHECK(parse("X ") == Cmd::Bad); // ... or not: one frame, one command
CHECK(parse("HH") == Cmd::Bad);
CHECK(parse("Z") == Cmd::Bad);
CHECK(parse("hello") == Cmd::Bad);
@@ -464,6 +482,11 @@ int main() {
CHECK_STR(buf, "E10:off");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
CHECK_STR(buf, "E11:boot");
// E12 is the incumbent's sentence ("you were dropped"), and it renders at full
// width like 10/11 -- a client that was told "E1" by truncation would tell its
// operator the passphrase failed instead of the bridge was taken.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 12, "session taken over"), 22u);
CHECK_STR(buf, "E12:session taken over");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
CHECK_STR(buf, "E0:z");
@@ -549,15 +572,35 @@ int main() {
char json[kr::kStatusJsonCap];
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
// A default snapshot: no TLS, and no label for an identity that is not there.
// A default snapshot: no TLS, no label for an identity that is not there, and a
// free bridge with no handover behind it.
size_t n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(n < kr::kStatusJsonCap);
CHECK(strstr(json, "\"tls\":0") != nullptr);
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
CHECK(strstr(json, "\"busy\":0") != nullptr);
CHECK(strstr(json, "\"tkov\":0") != nullptr);
CHECK_EQ(kr::kStatusFingerprintBytes, 8);
CHECK_EQ(kr::kStatusFingerprintHex, 16);
// The bridge, in the only terms the board may know: a link boolean and a
// counter. busy:1 says "a controller holds the keyboard" -- to a /status poller,
// someone else; to the adopted controller, itself -- and tkov counts completed
// handovers. There is no field that could say WHO, and walking these two is what
// keeps the next "just add the client IP" from passing review unnoticed: the
// README's never-stored list is about exactly this temptation.
StatusSnapshot held;
held.busy = true;
held.takeovers = 3;
n = statusJson(held, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"busy\":1") != nullptr);
CHECK(strstr(json, "\"tkov\":3") != nullptr);
CHECK(strstr(json, "addr") == nullptr); // the only ip here is the board's own
CHECK(strstr(json, "agent") == nullptr);
CHECK(strstr(json, "peer") == nullptr);
// The identity renders as `"tls":1` plus a `fp` of exactly 16 hex characters -- the
// first kr::kStatusFingerprintBytes of the digest, not the whole 64: this field is a label to
// eyeball against `cert`, and the full value is the console's job.
@@ -612,10 +655,11 @@ int main() {
// The longest frame the board can legally put on the wire: a 32-character SSID (the
// 802.11 limit, and staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button, and
// the whole 16-hex `fp`. This is the arithmetic config.h quotes -- measured here rather
// than believed, with the headroom asserted as at least one more field's worth so the
// next field is a decision and not an overflow nobody looked for.
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button,
// `busy` set, `tkov` at UINT32_MAX, and the whole 16-hex `fp`. This is the arithmetic
// config.h quotes -- measured here rather than believed, with the headroom asserted as
// at least one more field's worth so the next field is a decision and not an overflow
// nobody looked for.
StatusSnapshot worst;
worst.version = kr::kFirmwareVersion;
worst.uptimeS = 0xFFFFFFFFu;
@@ -637,6 +681,8 @@ int main() {
worst.ledState = 7; // caps/num/scroll: the three bits a report carries
worst.defaultToken = true;
worst.apForced = true;
worst.busy = true;
worst.takeovers = 0xFFFFFFFFu;
worst.tls = true;
worst.fp = fp;
char big[kr::kStatusJsonCap + 16];
@@ -650,6 +696,8 @@ int main() {
CHECK(strchr(big, '\n') == nullptr); // one frame, one line
CHECK(strstr(big, "\"tls\":1") != nullptr);
CHECK(strstr(big, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
CHECK(strstr(big, "\"busy\":1") != nullptr);
CHECK(strstr(big, "\"tkov\":4294967295") != nullptr);
// And the cap means it: one byte short renders nothing at all, which is the difference
// between a status that is missing and a status that is wrong.