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:
+89
-4
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user