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
+113
View File
@@ -141,6 +141,9 @@ const expose = `
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
get typing() { return typing; },
get autoReconnect() { return autoReconnect; },
get busyKnown() { return busyKnown; },
get takenOver() { return takenOver; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
@@ -626,6 +629,116 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
"a new status re-renders the new fingerprint");
console.log("the busy board and the take-over");
// Phase 03, page side. The board reports "held" to a socket it authenticated but
// did not adopt (the status frame belongs to the controller alone), and to a
// session it displaces (E12). The page's whole job is to make that visible, offer
// one deliberate click, and NOT fight: no retry loop, no auto-reclaim, and --
// until the click -- not one frame about the bridge.
// `busy` in a status frame, seen by a page that is not the controller, is the
// link pill's third honest state. A controller reading busy:1 is reading itself,
// and keeps "linked" -- the OK handler says so; applyStatus must not contradict it.
byId("takeover").hidden = true;
x.authed = false; x.armed = false;
x.onFrame('S{"armed":0,"usb":1,"typing":0,"busy":1}');
checkEq(byId("pLink").textContent, "held by another controller",
"a busy status frame drives the link pill's third state");
check(byId("pLink").className.includes("bad"),
"and it is not the calm 'linked' state");
checkEq(x.busyKnown, true, "and the page now knows the board is held");
x.onFrame("OK"); // adopted: the ordinary path
checkEq(byId("pLink").textContent, "linked",
"the adopted controller reads its own busy as linked, not as a rival");
// A refused upgrade: the board parks this socket as a pending claim -- `OK`, then
// the E6-and-drop that answers the ordinary `H` on a claim slot -- and the page
// must say "held", offer the button, and stop, instead of retrying every 2 s.
sandbox.location.protocol = "https:";
byId("token").value = "hunter2";
FakeWebSocket.instances.length = 0;
sentFrames.length = 0;
x.authed = false; x.armed = false;
x.connect();
await wait(5);
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
inst.onmessage({ data: "OK" }); // authenticated -- as a claim
const afterConnect = sentFrames.length; // T + H from the ordinary flow
inst.onmessage({ data: "E6:a pending claim may not send that" });
inst.onclose(); // and the claim is dropped
checkEq(byId("pLink").textContent, "held by another controller",
"the un-adopted socket reads the board's silence as 'held'");
checkEq(byId("takeover").hidden, false,
"and the take-over button appears -- only now that the board said held");
checkEq(sentFrames.length, afterConnect,
"after the refusal the page sends nothing on its own -- no retry, no X");
checkEq(x.autoReconnect, false,
"the reconnect loop is off: re-claiming automatically is a fight");
checkEq(FakeWebSocket.instances.length, 1, "and no second socket was opened");
// The one deliberate click: open, authenticate, ask for the bridge. T then X,
// in that order, and nothing else -- no arm, no state, not even an H.
byId("takeover").fire("click");
await wait(5);
checkEq(FakeWebSocket.instances.length, 2, "one click opened one takeover socket");
const t = FakeWebSocket.instances[1];
checkEq(sentFrames.slice(afterConnect).join(","), "Thunter2,X",
"the click sends T then X, in that order, and nothing else");
checkEq(byId("takeover").hidden, true, "and the button retires once the act is sent");
t.onmessage({ data: "OK" }); // adopted: OK, then the status
t.onmessage({ data: 'S{"armed":0,"usb":1,"typing":0,"busy":1}' });
checkEq(x.armed, false,
"the newcomer behaves like any controller, starting DISARMED");
checkEq(byId("pLink").textContent, "linked", "and it holds the bridge now");
const tlog = logEl.children.map(c => String(c.textContent)).join("\n");
check(tlog.includes("bridge taken over") && tlog.includes("DISARMED"),
"the log names what happened, including the state it landed in");
// E3 keeps its meaning next to E12's: "you were refused -- no room for a claim",
// not "you were dropped". It still disarms a page that asks; it does not claim
// this session was displaced. (Checked BEFORE the E12 walk, so "not a takeover"
// means E3 did not set it, not that an earlier takeover was already on record.)
x.setArmed(true, "harness: armed");
x.onFrame("E3:no room");
checkEq(x.armed, false, "E3 still refuses the ask and disarms");
checkEq(x.takenOver, false, "but E3 is not a takeover -- nothing displaced anyone");
const e3log = logEl.children.map(c => String(c.textContent)).join("\n");
check(e3log.includes("no room for a claim"),
"and its log line says 'no room', the sentence that distinguishes it from E12");
// The incumbent's side, which this page now is: E12 disarms, releases the mouse,
// ends a typing run mid-flight, marks the pill, and keeps the page down -- the
// same gesture the board itself ran through the funnel.
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "set-up: this session holds the mouse");
byId("paste").value = "run".repeat(100); // three frames' worth
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.onFrame("E12:session taken over");
checkEq(x.armed, false, "E12 disarms the incumbent immediately");
checkEq(x.mCaptured, false, "and drops the mouse");
x.onFrame('S{"armed":0,"usb":1,"typing":0}'); // release the run's waiter
await p;
checkEq(x.typing, false, "and ends the typing run");
checkEq(frames().length, 1, "the run stops -- no further P= frames after a takeover");
checkEq(byId("pLink").textContent, "taken over", "and the pill says what happened");
const ilog = logEl.children.map(c => String(c.textContent)).join("\n");
check(ilog.includes("another controller took over"),
"and the log carries the reason, in the incumbent's own words");
checkEq(x.autoReconnect, false, "and this page stays down until someone clicks");
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
const nSockets = FakeWebSocket.instances.length;
inst.onclose();
checkEq(FakeWebSocket.instances.length, nSockets,
"the close after E12 opens nothing -- no auto-reclaim");
checkEq(byId("pLink").textContent, "taken over", "the pill keeps the fact");
checkEq(byId("takeover").hidden, false,
"coming back is one deliberate click away, never zero");
x.setArmed(false, "harness: done");
console.log("element ids");
const markup = served;
const present = new Set();