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
+199 -10
View File
@@ -382,6 +382,84 @@ check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
"closeLink() treats anything that reached the WebSocket phase as able to leave "
"keys down, not only what is still marked authenticated")
print("the bridge handover (phase 03)")
# `X` moves the keyboard from one socket to the other, and the safety sentences about it
# are ordering facts in control_server.cpp that no host-runnable test can observe -- they
# need two real sockets and a board. Same trade as bug 10's pins above: these are source
# pins, deliberately, for the three sentences a later edit can undo silently.
server_hdr = (root / "src" / "control_server.h").read_text()
claim_src = (root / "src" / "claim.h").read_text()
hand_at = server_src.index("void ControlServer::handOverClaim")
hand = server_src[hand_at:server_src.index("\n}", hand_at)]
# Code lines only -- the function's comment is full of the words it forbids itself, and
# a check that flagged the comment would be worse than no check at all.
hand_code = "\n".join(l for l in hand.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
# (a) The funnel runs BEFORE the newcomer is adopted. closeLink() is the release funnel
# (it disarms and releases for anything that reached the WebSocket phase -- bug 10's
# fix -- and runs _onLinkChange(false), which in main.cpp is disarm("controller
# disconnected") -> releaseEverything()); adopting the claimant is `_authed = true`.
# If those two ever swap order, the newcomer inherits a held key and an armed
# bridge, and nothing on a host would ever notice.
check("closeLink(\"taken over\")" in hand_code,
"the handover runs the existing release funnel, not a copy of it")
check("_authed = true" in hand_code,
"and it is the code that adopts the claimant as the controller")
check(hand_code.index("closeLink(\"taken over\")") < hand_code.index("_authed = true"),
"the funnel runs BEFORE the claimant is adopted -- a takeover can never inherit "
"a held key or an armed bridge")
check("if (!linked) disarm(\"controller disconnected\");" in main_src,
"and the funnel's link change is the one main.cpp disarms (releases every key "
"and every mouse button) on")
# (b) No arm is reachable from the `X` handler. The board lands DISARMED by design; the
# newcomer arms with an ordinary authenticated A1 or never. `arm(`/`setArmed`/"armed =
# true" must not appear in the handover's code lines, nor in the controller-side `X`
# case in main.cpp (which is a no-op answered OK).
for tok in ("setArmed", "armed = true", "arm("):
check(tok not in hand_code,
f"the handover path contains no {tok!r} -- a takeover cannot produce an "
f"armed board")
mt_at = main_src.index("case proto::Cmd::Takeover:")
mt_case_raw = main_src[mt_at:main_src.index("return;", mt_at)]
# Comment lines stripped for the same reason as hand_code: the case's own comment
# explains what it must not do, in words that contain the words being searched for.
mt_case = "\n".join(l for l in mt_case_raw.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
check("control.sendOk();" in mt_case,
"`X` from the incumbent controller is a no-op answered OK, not E6")
check("setArmed" not in mt_case and "arm(" not in mt_case,
"and it cannot arm either -- you already hold the bridge, that is all this says")
# (c) A claim cannot be promoted without `_claimAuthed`. Two halves: the whitelist is
# consulted before the Takeover branch in handleClaimFrame, and claim.h admits `X`
# only in the authenticated branch, so the ordering has something correct to check.
hcf_at = server_src.index("bool ControlServer::handleClaimFrame")
hcf = server_src[hcf_at:server_src.index("\n}", hcf_at)]
check("claim::honours(_viewerRole, _claimAuthed, cmd)" in hcf,
"handleClaimFrame asks the one whitelist, with _claimAuthed in the question")
check(hcf.index("claim::honours(") < hcf.index("proto::Cmd::Takeover"),
"and it asks BEFORE the Takeover branch -- `X` cannot reach the promotion "
"unauthenticated")
check_eq(server_src.count("handOverClaim(nowMs);"), 1,
"handOverClaim is called from exactly that one place, behind the whitelist")
hon_at = claim_src.index("inline bool honours(")
hon = claim_src[hon_at:claim_src.index("\n}", hon_at)]
check("if (!authed) return cmd == proto::Cmd::Auth;" in hon,
"claim.h admits only T before authentication -- an unauthenticated `X` is refused")
check(hon.index("if (!authed)") < hon.index("case proto::Cmd::Takeover"),
"and `X` lives in the authenticated branch only, after that refusal")
# E12 is its own sentence: "you were dropped", not E3's "you were refused". The named
# code and the incumbent-side send are both pinned so the two can never merge back.
check("kErrTakenOver = 12" in server_hdr,
"the error code is named at 12, the number the incumbent's page will read")
check('sendError(kErrTakenOver, "session taken over")' in hand_code,
"and it is that named code the incumbent is told with, before the funnel closes "
"its link")
print("one routing path, two transports")
# Phase 02 task 04 moved the TLS session underneath the routing instead of beside it. The
# claims are structural, so they are pinned as source sentences: what they defend is a
@@ -401,11 +479,16 @@ check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in
"readSome() stamps the silence clock only for an authenticated controller on the "
"WebSocket, whatever transport the bytes came over")
check("_lastInboundMs" in read_fn, "readSome() is still the code that notices inbound bytes")
# ... and it is the ONLY inbound path that may. Three writers of that clock would mean a
# route that can keep the board armed: readSome, and closeLink stamping on the way out.
# ... and it is the ONLY inbound path that may. Every writer of that clock is a route
# that can keep the board armed, so the count is pinned: readSome, the authentication,
# closeLink stamping on the way out, and the bridge handover ADOPTING an already-
# authenticated claim (phase 03 -- the claim was checked against the same passphrase,
# so from that instant a controller exists and is speaking; the adoption stamps exactly
# the stamp an auth would). A fifth writer needs its sentence written here first.
stamps = [l.strip() for l in server_src.splitlines() if "_lastInboundMs = millis()" in l]
check_eq(len(stamps), 3,
"exactly three writers of the controller-silence clock: auth, readSome, closeLink")
check_eq(len(stamps), 4,
"exactly four writers of the controller-silence clock: auth, readSome, "
"closeLink, handover adoption")
check(any("the controller exists from here" in l for l in stamps),
"one of them is the authentication itself")
# The spare slot must not be able to reach that clock even by accident: it reads the Conn
@@ -590,17 +673,33 @@ for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
# The status an adopted controller gets back to back with its `OK` -- the frame
# whose ABSENCE after `OK` is how a pending claim learns the board is busy. The
# values are the honest post-handover ones: disarmed, busy (with itself), one
# completed takeover.
FAKE_STATUS = b'S{"v":"1.0.0","armed":0,"usb":1,"typing":0,"busy":1,"tkov":1}'
class FakeBoardSock:
"""Records every byte this side sends; answers the upgrade and `T<token>`.
The recorded frame list is the point: "the pin aborted before T<token>" can
only be shown by what did NOT go out.
`claim_slot=True` makes the board a BUSY one: `T` authenticates into the spare
slot as a pending claim (`OK`, and deliberately no adoption status -- the status
frame belongs to the controller alone), and the board adopts the client only
when it sends the one deliberate `X`. That asymmetry is the whole of phase 03
from this side of the socket, so the fake spells it out instead of collapsing
both answers into "OK".
"""
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False):
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False,
claim_slot=False):
self.host, self.port = host, port
self.bad_accept = bad_accept # answer the upgrade with a wrong accept hash
self.flood = flood # answer with headers that never terminate
self.claim_slot = claim_slot # busy board: park the newcomer as a claim
self.sent = [] # every sendall payload, in order
self.inbox = bytearray()
self.upgraded = False
@@ -639,6 +738,20 @@ class FakeBoardSock:
del self.post[:6 + length]
if payload.startswith(b"T"):
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
if not self.claim_slot:
# The adoption status a real controller gets back to back with
# the OK -- the auth path sends both on one write.
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
elif payload == b"X":
# Only a claim has anything to take; this fake only ever sees X
# from one. The handover lands disarmed, and the adopted session
# gets exactly the greeting a fresh authentication gets.
if self.claim_slot:
self.claim_slot = False
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
elif payload == b"H":
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
def recv(self, n):
if self.inbox:
@@ -706,14 +819,15 @@ class FakeSsl:
def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
der=FAKE_DER, token="sekrit", wrap_error=None, close_early=False,
empty_der=False, bad_accept=False, flood=False,
empty_der=False, bad_accept=False, flood=False, claim_slot=False,
take_over=False,
real_create=socketmod.create_connection, real_ssl=relayctl.ssl):
fake = FakeSsl(der, wrap_error=wrap_error, empty_der=empty_der)
socks = []
def fake_create(addr, timeout):
s = FakeBoardSock(addr[0], addr[1], close_early=close_early,
bad_accept=bad_accept, flood=flood)
bad_accept=bad_accept, flood=flood, claim_slot=claim_slot)
socks.append(s)
return s
@@ -725,7 +839,8 @@ def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
with contextlib.redirect_stderr(stderr):
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
fingerprint=fingerprint, ca=ca,
insecure=insecure, plain=plain)
insecure=insecure, plain=plain,
take_over=take_over)
except relayctl.RelayError as exc:
err = exc
finally:
@@ -835,10 +950,47 @@ frames = client_frame_payloads(socks[0])
check(frames and frames[0] == b"Tsekrit",
"the rollback session authenticates the same way")
print("the busy board and --take-over")
# Phase 03, from the terminal side. A busy board authenticates this tool into its
# one spare slot as a pending claim -- `OK` and then the silence that means `busy`,
# because the status frame belongs to the controller alone. The default is a
# refusal that names the flag; the flag is one deliberate `X`; a free board is a
# no-op. The frames that DO go out are asserted, in order, because "takeover can
# never arm" is a sentence about what a session looks like at the moment it takes
# over: T, X, and nothing that types.
# Default against a busy board: refuse, name --take-over, send no X, free the slot.
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True)
check(isinstance(err, relayctl.RelayError), "a busy board refuses by default")
check(err is not None and "--take-over" in str(err), "and the refusal names the flag")
check(err is not None and "DISARMED" in str(err),
"and says what the handover lands on, before anyone takes it")
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
"the refusal sends no X -- the act needs the flag, not just the passphrase")
check(socks and socks[0].closed, "and the claim slot is freed rather than parked on")
# With the flag: the claim's one deliberate frame, in the right order, alone.
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True,
take_over=True)
check(err is None and relay is not None, f"--take-over takes a busy board ({err})")
check(relay is not None and relay.took_over, "and the session knows it took the bridge")
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit", b"X"],
"T then X, in that order, and nothing else -- one intent, no arm, no state")
# --take-over against a free board is a no-op, not an error, and never wastes an X
# on a board that has nothing to take.
relay, err, socks, fake, warns = connect_with(plain=True, take_over=True)
check(err is None and relay is not None, f"--take-over on a free board succeeds ({err})")
check(relay is not None and relay.busy is False and not relay.took_over,
"and it reports the board was free")
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
"a free board is never sent an X it did not need")
# --- main(): the refusals that need no board -----------------------------------
def run_main(argv, fp_env=None, token_env="hunter2secret"):
def run_main(argv, fp_env=None, token_env="hunter2secret", claim_slot=False,
socks_out=None):
"""Run main() with the transport faked: create_connection records the address
instead of dialing, and ssl is the fake from above. Any credential that
reaches the returned text is a test failure, so the caller never has to
@@ -847,7 +999,10 @@ def run_main(argv, fp_env=None, token_env="hunter2secret"):
def record_create(addr, timeout):
calls.append(addr)
return FakeBoardSock(addr[0], addr[1])
s = FakeBoardSock(addr[0], addr[1], claim_slot=claim_slot)
if socks_out is not None:
socks_out.append(s)
return s
old_argv = sys.argv
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
@@ -919,6 +1074,40 @@ code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32)
check_eq(code, 4, "paste still needs --yes before any connection")
check(calls == [], "and that check still does not require a reachable board")
# The whole takeover gesture through main(): take the held bridge, then read the
# board. T, X, H -- claim, handover, status -- with the terminal told which path
# ran, and no credential in anything it prints.
board = []
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
token_env="sekrit", claim_slot=True,
socks_out=board)
check_eq(code, 0, f"--take-over status against a busy board runs ({err_text})")
session = client_frame_payloads(board[0]) if board else []
check_eq([f for f in session if f in (b"Tsekrit", b"X", b"H")],
[b"Tsekrit", b"X", b"H"],
"the busy session's frames are T, then X, then H -- claim, handover, status")
check("took the bridge" in out and "DISARMED" in out,
"and the terminal is told what the handover did before anything else runs")
check("sekrit" not in out and "sekrit" not in err_text,
"and the passphrase stays out of the terminal")
board = []
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
token_env="sekrit", socks_out=board)
check_eq(code, 0, f"--take-over against a free board still runs ({err_text})")
check("nothing to take" in out, "and says it was a no-op, in those words")
check_eq([f for f in (client_frame_payloads(board[0]) if board else []) if f == b"X"],
[], "with no X on the wire at all")
# The default against a busy board, through main(): exit 1, the refusal names
# --take-over, and the wire never carried an X.
code, out, err_text, calls = run_main(["--plain", "status"], token_env="sekrit",
claim_slot=True, socks_out=board)
check_eq(code, 1, "a busy board is a refusal through main() too")
check("--take-over" in err_text, "and the refusal names the flag in the terminal")
check_eq([f for f in (client_frame_payloads(board[-1]) if board else [])
if f == b"X"], [], "and refused means refused: no X went out anyway")
print("the target-side verifier")
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets