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