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
+89 -4
View File
@@ -31,6 +31,7 @@ without one -- this tool will not be the component that quietly downgrades:
relayctl.py --fingerprint $FP --host keyrelay.local listen # status heartbeats
relayctl.py --fingerprint $FP --host keyrelay.local disarm
wl-paste | relayctl.py --fingerprint $FP paste --yes # type what is on the clipboard
relayctl.py --fingerprint $FP --take-over status # take a bridge left held, then use it
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
@@ -54,6 +55,15 @@ reversible by definition. There is no gesture recording, no auto-click and no "m
OK button", because the moment this tool could actuate a target on its own it would be a
different and worse device than a keyboard.
A busy board is a refusal, never a fallback. If another controller holds the bridge,
this tool authenticated into the board's one pending-claim slot and says so -- and
names --take-over, which is the same one deliberate act the page's TAKE OVER button
sends: `X` after the passphrase. The handover runs through the board's release funnel
-- the incumbent is told (E12), every key and every mouse button is released -- and the
session continues DISARMED. `--take-over` against a free board is a no-op, not an error.
Neither can arm anything: the takeover cannot produce an armed board, and the arm still
needs its own deliberate command, exactly as it did before anyone was displaced.
What the TLS in this tool does NOT do is make anything safe by itself. An encrypted session is
still only a controller that has to know the passphrase, and a session that drops is a lost link
the board answers by releasing every key and every mouse button. The certificate says *which*
@@ -98,6 +108,14 @@ MOUSE_DELTA_MAX = 127 # one signed byte, per axis, per frame
MOUSE_BUTTON_MASK = 0x1f # five buttons: all the report can carry
MOUSE_FRAME_MS = 8 # the pace the controller page sends at, matched here
# How long to wait after `OK` for the board's adoption status. A controller gets
# `OK` and a status frame back to back (the firmware's auth path sends both); a
# socket the board parked as a PENDING CLAIM gets `OK` and silence, because the
# status frame belongs to the controller alone. Half a second is orders of
# magnitude more than one LAN round-trip, and it is the board's own behaviour --
# this tool never sends a frame to guess at an answer it was already owed.
ADOPT_PROBE_S = 0.5
# Button name -> the report's bit, in the order the HID report descriptor lays them out.
# NOTE the DOM's MouseEvent.button numbers are NOT this order (there 1 is middle and 2 is
# right), which is why src/web_page.h carries its own mapping and both are pinned by
@@ -466,10 +484,17 @@ class Receiver:
class Relay:
def __init__(self, host: str, port=None, token: str = "", timeout: float = 5.0,
fingerprint: str = "", ca: str = "", insecure: bool = False,
plain: bool = False) -> None:
plain: bool = False, take_over: bool = False) -> None:
self.host = host
self.timeout = timeout
self.token = token
# A status frame that arrives while this side is waiting for some OTHER
# reply is the freshest truth about the board there is -- and the adoption
# probe below depends on not losing it: the firmware sends a controller's
# `OK` and its status back to back, often in one batch, and abandoning the
# batch at the OK would make a free board look busy for exactly the wrong
# reason. Held here, read once by the probe.
self._pending_status = None
# The transport decision happens before a socket exists: "no trust
# argument" must refuse in the terminal, not connect and find out.
@@ -565,14 +590,55 @@ class Relay:
if reply != "OK":
raise RelayError(f"authentication refused: {reply or 'no reply'}")
# Did the board ADOPT us? `OK` means the passphrase was right; the frame
# that follows is the answer to "is the bridge yours". A controller gets its
# status immediately (auth sends OK then status in one go); a socket the
# board parked in its spare slot as a pending claim -- another controller
# holds the bridge -- gets `OK` and nothing else, because the status frame
# belongs to the controller alone. Silence here is therefore the board
# reporting `busy` in exactly the terms it chose: a link state, never an
# identity. Reading is all this costs -- no frame is sent to guess at an
# answer a controller was already owed.
if self._pending_status is not None:
# The status rode in with the OK (the usual case on a LAN): adopted,
# no waiting, and no timeout being mistaken for a fact.
self._pending_status = None
self.busy = False
else:
self.busy = self._poll(want_status=True, timeout=ADOPT_PROBE_S) is None
self.took_over = False
if self.busy:
if not take_over:
# The refusal names the flag rather than letting some later command
# earn an E6 nobody can interpret. Free the claim slot for the one
# person who decides, promptly.
self.sock.close()
raise RelayError(
"another controller holds this bridge: the board authenticated "
"us as a pending claim and kept the keyboard. Re-run with "
"--take-over to take it: the incumbent is told (E12), every key "
"and every mouse button is released through the release funnel, "
"and the board comes back DISARMED -- nothing types until the "
"new session arms on purpose.")
# The one deliberate act, and the only frame beyond T and K a claim may
# send. The board tells the incumbent, runs the funnel, lands DISARMED,
# and adopts us: OK, then status, like any other authenticated start.
self._send("X")
self.took_over = True
# -- plumbing -----------------------------------------------------------
def _send(self, text: str) -> None:
self.sock.sendall(encode_frame(OP_TEXT, text.encode()))
def _poll(self, want_status: bool, timeout: float):
# The whole batch is drained before any early return: a status frame that
# rides in behind the reply this call is waiting for is kept in
# _pending_status, not abandoned with the generator. Losing it here is how
# the adoption probe would read a free board as busy.
deadline = time.time() + timeout
while time.time() < deadline:
hit = None
for opcode, payload in self._read_any(max(0.0, deadline - time.time())):
if opcode == OP_PING:
self.sock.sendall(encode_frame(OP_PONG, payload))
@@ -582,14 +648,18 @@ class Relay:
text = payload.decode(errors="replace")
if text.startswith("S{"):
if want_status:
return json.loads(text[1:])
hit = json.loads(text[1:])
else:
self._pending_status = json.loads(text[1:])
elif text == "OK":
if not want_status:
return text
hit = text
elif text.startswith("E"):
raise RelayError(f"board said {text!r}")
else:
print(f"board: {text}", file=sys.stderr)
if hit is not None:
return hit
if not want_status:
return None
return None
@@ -861,6 +931,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
ap.add_argument("--plain", action="store_true",
help="plaintext ws:// on port 80: only for boards built with "
"-D KR_TLS=0 (the rollback); a TLS board refuses this")
ap.add_argument("--take-over", action="store_true",
help="if another controller holds the bridge, take it (X): the "
"incumbent is told (E12), every key and mouse button is "
"released through the release funnel, and the board comes "
"back DISARMED. Without it a busy board is a refusal; "
"against a free board it is a no-op. Neither path arms: "
"arm is still its own deliberate command")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="print what the board reports")
@@ -994,10 +1071,18 @@ def main() -> int:
try:
relay = Relay(args.host, args.port, args.token, args.timeout,
fingerprint=args.fingerprint, ca=args.ca,
insecure=args.insecure, plain=args.plain)
insecure=args.insecure, plain=args.plain,
take_over=args.take_over)
except (OSError, RelayError) as exc:
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
return 1
if args.take_over:
# Say out loud which of the two no-surprises paths this took -- the
# operator clicked a takeover, and "what happened to the other session"
# is the question this answers before any keystroke goes out.
print("took the bridge -- the incumbent was told (E12), every key and mouse "
"button was released, and the board is DISARMED" if relay.took_over
else "the board was free -- --take-over had nothing to take")
try:
if args.cmd == "status":