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:
@@ -0,0 +1,75 @@
|
||||
# Phase 03 — Take the bridge: session takeover that cannot arm itself
|
||||
|
||||
**Source:** `TODO.md` L6 — "Need to be able to take control if another session is connected.
|
||||
Sometimes I accidentally leave my laptop connected."
|
||||
**Story:** `n/a`
|
||||
**Context:** `PLAN.md` "Safety position"; `src/control_server.{h,cpp}` (`acceptNew()`,
|
||||
`serveViewer()`, `closeLink()`), `src/proto.h`, `src/main.cpp` (`releaseEverything()`,
|
||||
`onMessage()`, `onLinkChange()`), `src/web_page.h`, `tools/relayctl.py`
|
||||
|
||||
## Objective
|
||||
|
||||
When a controller left connected on the laptop is holding the bridge, the operator on the
|
||||
phone can take it: the newcomer authenticates on a *pending* claim, deliberately asks for the
|
||||
bridge, the incumbent is told and dropped, **the release funnel runs**, and the board comes
|
||||
back **disarmed** so the new session has to arm again on purpose.
|
||||
|
||||
## Why this shape
|
||||
|
||||
`E3` exists because two controllers typing into one machine is the ambiguity a keyboard must
|
||||
not have — that stays. What changes is that "no room" was the *only* answer, which turns the
|
||||
most ordinary accident (a laptop tab that kept the socket open) into a dead board until you
|
||||
find the other tab or reboot the board. A claim is therefore not a new privilege: it is the
|
||||
**same** privilege (the passphrase) exercised from a different socket, plus one deliberate
|
||||
act, plus a guarantee that the handover cannot inherit anything.
|
||||
|
||||
The load-bearing rule for every task here: **a takeover can never produce an armed board.**
|
||||
The incumbent's keys go up before the newcomer is adopted, `A1` from the newcomer is an
|
||||
ordinary authenticated arm, and nothing in the handover path may call `arm()`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `02_tls_control_channel` (todo) — the claim travels over the encrypted channel, and the
|
||||
pending slot reuses the *one* extra socket the server already budgets (controller + one
|
||||
more), so the TLS-session ceiling set in phase 02 (`kTlsMaxSessions = 2`) must exist. When
|
||||
phase 02 is rolled back with `-D KR_TLS=0` the claim travels over plaintext `ws://`, exactly
|
||||
like the controller does in that build — the claim adds no new security dependency.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_pending_claim_socket.md` — the second socket may handshakes and authenticate as a
|
||||
*pending claim* instead of being refused; its own header buffer, bounded reader and
|
||||
command whitelist; nothing else changes.
|
||||
2. `02_takeover_funnel.md` — `X` claims the bridge: `E12` to the incumbent, the funnel, the
|
||||
disarm, then adoption; silence-clock and `controllerSeen()` semantics kept honest.
|
||||
3. `03_status_page_takeover_ui.md` — `busy` in the status frame, the page's TAKE OVER affordance,
|
||||
`E12` handled, and the incumbent visibly disarmed with the reason in its log.
|
||||
4. `04_takeover_tests_docs.md` — `relayctl.py --take-over`, the frame/pin tests that keep the
|
||||
safety sentences true, README Safety/Security/Wire/Controller pages.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_proto.cpp`: `X` parses (and refuses trailing garbage), `E12` renders, the
|
||||
round-trip and bounds checks extended, the *existing* chord-label assertions untouched.
|
||||
- `tests/test_relayctl.py`: the `--take-over` frame sequence (`T` then `X` then `H`) and the
|
||||
default refusal; plus three **source pins** in the style of the existing three —
|
||||
(a) the takeover path calls the release funnel before it adopts the newcomer,
|
||||
(b) no code reachable from the `X` handler calls `arm()`/sets `armed`,
|
||||
(c) a pending claim cannot exist without `_authed`.
|
||||
These are the sentences a later edit can undo silently; they are the point of this phase.
|
||||
- `tests/test_page.js`: refused-then-offered flow (a refused upgrade shows the affordance, it
|
||||
does not retry-loop), `E12` disarms locally, the takeover button sends nothing until clicked.
|
||||
- Coverage **>90%** on new/modified logic.
|
||||
- Device (best effort, one attempt, per `.agents/HW_VERIFICATION.md`): two controllers, one
|
||||
arm, one held key, one takeover — `watch_target.py` must show the key **up** before the new
|
||||
session's first key, and the board must report `armed:0` across the handover.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] A second controller can authenticate and take the bridge; a third gets `E3`.
|
||||
- [ ] The handover releases every key and every mouse button and leaves the board disarmed;
|
||||
demonstrated on hardware or recorded as `skipped (no device)` with the commands.
|
||||
- [ ] The incumbent's page says it was taken over, disarmingly and in its log.
|
||||
- [ ] The three source pins exist and fail when the corresponding code is inverted (shown in
|
||||
the report, then reverted).
|
||||
- [ ] `bash .agents/validate.sh` green; README safety/security text says takeover cannot arm.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Task 01 — A pending claim: the second socket may authenticate
|
||||
|
||||
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
|
||||
if another session is connected. Sometimes I accidentally leave my laptop connected."
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
A second socket may complete the WebSocket handshake and authenticate as a **pending claim**
|
||||
while the incumbent keeps the keyboard — instead of getting a `503` and being locked out of
|
||||
the board entirely. Nothing can be *done* from a pending claim yet; this task only opens the
|
||||
door far enough to stand in it.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/control_server.h` — the pending slot reuses the socket that today is `_viewer`, on
|
||||
purpose: one incumbent + one more, never one incumbent + two claimants, because that is
|
||||
already the server's shape and it is the TLS-session ceiling from phase 02
|
||||
(`kTlsMaxSessions = 2`). Add, beside `_viewer`/`_viewerHttp`/`_viewerLen`:
|
||||
- `enum class ViewerRole : uint8_t { Readonly, Claim };`
|
||||
- `Phase _viewerPhase` (`None`/`Http`/`WebSocket`) and its own `ws::Reader` over a
|
||||
**small** buffer — `kClaimMsgCap = 64` in `config.h`: `T<token>` is at most
|
||||
`kTokenMax + 1`, `X` is one byte, `K` is one byte. Anything larger is refused, not
|
||||
buffered. Add a comment naming the RAM each field costs and why the claim buffer is a
|
||||
sixteenth of the controller's.
|
||||
- `bool _claimAuthed`, `uint32_t _claimAtMs`, and (task 02) the claim's conn.
|
||||
2. `src/control_server.cpp`, `serveViewer()`:
|
||||
- an `/ws` upgrade from the viewer slot is now accepted into `ViewerRole::Claim` and
|
||||
`Phase::WebSocket` (the same `ws::acceptFor()` handshake as the controller path); the
|
||||
`503` path stays for what comes *after* the claim slot is occupied (task 02 wires the
|
||||
counting; here, keep refusing a *third* socket and make `_rejected++` mean "no room").
|
||||
- A pending claim authenticates exactly like a controller: `T<token>` within
|
||||
`kAuthTimeoutMs`, the same constant-time compare, the same `_authFails`/`_lockUntilMs`
|
||||
counters (one lockout for the board, not one per socket), and the same close-with-nothing
|
||||
on failure.
|
||||
- While pending and unauthenticated, **only** `T` is honoured. While pending and
|
||||
authenticated, only `T`, `K` and (from task 02) `X`; every other frame gets
|
||||
`E6`-shaped refusal and the claim is dropped. No arm, no state, no text, no mouse —
|
||||
spell that list out in the code comment, because it is the whole safety argument.
|
||||
- The claim never touches `_lastInboundMs`: a claim attempt is not the operator being
|
||||
alive (the comment in `readSome()` explains why that distinction exists). It refreshes
|
||||
`_claimAtMs` instead, and a claim that goes quiet is dropped.
|
||||
- The read-only routes still work from the same slot when it is *not* in Claim role —
|
||||
`serveReadonly()` remains the single implementation (invariant 8).
|
||||
3. `src/config.h` — `kClaimMsgCap`, `kClaimTimeoutMs` (a pending claim that has not asked for
|
||||
the bridge this long is dropped; 15 s is plenty for a human click, and an unanswered claim
|
||||
holding a TLS session open is a nuisance attack), with the reasoning in the comment.
|
||||
4. `tests/test_proto.cpp` / `tests/test_relayctl.py` — nothing on the wire yet, so both
|
||||
suites must stay green **untouched**; if either needs a change, this task grew the
|
||||
protocol and must stop and say so.
|
||||
|
||||
- ASSUMPTION: the claim is accepted into a pending state rather than the board keeping
|
||||
`503`-only and adding an HTTP "request takeover" endpoint. A second control channel would
|
||||
be a second auth path — exactly the drift `AGENTS.md` warns about — while the pending
|
||||
WebSocket reuses the authentication code that already exists and is already tested.
|
||||
- ASSUMPTION: one claim slot at a time; a third socket gets the `503`/`E3` it gets today.
|
||||
- ASSUMPTION: a claim gets a smaller message cap than the controller. A frame that does not
|
||||
fit is a refusal, consistent with invariant 7 (one frame is one command) and it keeps a
|
||||
hostile claimant from allocating board RAM.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Host: existing suites untouched and green (`test_proto` at 2 327 checks, `test_output`
|
||||
unchanged). New pure logic is limited to the claim state machine's predicates (which role,
|
||||
which commands allowed, deadline arithmetic) — those go in a pure helper the tests can call,
|
||||
in the style of `output.h`.
|
||||
- Firmware: `bridge` + `console` build; report the RAM delta from the added buffers
|
||||
(`kClaimMsgCap` + one `ws::Reader` + one header buffer), against `bridge 26.7% / 87 592 B`.
|
||||
- Device (best effort, one attempt): with the controller connected, a second tab's `/ws`
|
||||
upgrade is accepted and authenticates; a third socket is refused; the controller keeps
|
||||
typing throughout.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] A second `/ws` upgrade while a controller is linked reaches `authenticated` as a claim
|
||||
(log/observation or `skipped (no device)`), and a third is refused.
|
||||
- [ ] A pending claim can arm nothing: the whitelist is one visible list in the code and the
|
||||
report names every entry.
|
||||
- [ ] Claim timeouts and silence drop the claim and free its session; no leak over 20 attempts.
|
||||
- [ ] `bash .agents/validate.sh` green; `test_proto.cpp`/`test_relayctl.py` unchanged in this task.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Task 02 — `X`: hand the bridge over through the funnel
|
||||
|
||||
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
|
||||
if another session is connected…"
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
One authenticated frame from the pending claim — `X` — takes the bridge: the incumbent is
|
||||
told (`E12`), the release funnel runs, the board ends up **disarmed**, and only then is the
|
||||
claimant adopted as the controller. The handover is a link change that the rest of the
|
||||
firmware recognises, not a silent substitution of one socket for another.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/proto.h` — `Cmd::Takeover` for a lone `X`, documented in the header comment's
|
||||
direction table: `c -> d X claim this bridge for myself; requires authentication, and
|
||||
nothing else is honoured on a claim socket`. `proto::formatError` unchanged; add
|
||||
`kErrTakenOver = 12` to `control_server.h` ("your session was taken over by another
|
||||
controller") and name it in the error list comment.
|
||||
2. `src/control_server.cpp` — the handover, in this order and no other:
|
||||
1. `sendError(12, "session taken over")` on the **incumbent** socket, so its page can say
|
||||
why it stopped. It is best-effort: an unreadable incumbent must not block the handover.
|
||||
2. `closeLink("taken over")` on the incumbent — the existing funnel. `closeLink()` already
|
||||
disarms and releases for anything that reached the WebSocket phase (that is bug 10's
|
||||
fix; do not re-narrow it here) and calls `_onLinkChange(false)`, which in `main.cpp`
|
||||
runs `releaseEverything()`. **This must happen before adoption**, so the newcomer can
|
||||
never inherit a held key or an armed bridge.
|
||||
3. Promote the claim: move its conn into the controller slot, `_phase = WebSocket`,
|
||||
`_authed = true`, `_controllerSeen = true`, `_lastInboundMs = millis()`,
|
||||
`_connectedAtMs = nowMs`, reset the controller's reader, then `OK` + `sendStatus()` and
|
||||
`_onLinkChange(true)`.
|
||||
4. Clear the claim slot completely (`_claimAuthed = false`, reader reset, buffers zeroed)
|
||||
so a stale claim cannot be promoted twice.
|
||||
- `arm()` is not called, and nothing on this path can reach it. State that in a comment at
|
||||
the top of the handover function and make the function's name say it too
|
||||
(`handOverClaim()` or similar — not `takeover()` which reads like an arm).
|
||||
- If the incumbent is already gone by the time `X` arrives, the promotion still runs
|
||||
through the same steps (the funnel is a no-op when disarmed), so there is only one path.
|
||||
- `_rejected` keeps meaning "a socket was turned away", and a successful takeover gets its
|
||||
own counter (`_takeovers`), reported in the status frame (task 03) — counters only, never
|
||||
addresses or identities (invariant 4).
|
||||
3. `src/main.cpp` — `onMessage()` handles `Cmd::Takeover` for the case where an *existing*
|
||||
controller sends `X` (a no-op with `OK`, documented: you already hold it) — do not let it
|
||||
fall through to `E6` — and `fillStatus()` publishes the counter.
|
||||
4. `src/config.h` — nothing new; `kClaimTimeoutMs` from task 01 bounds the claim's life.
|
||||
|
||||
- ASSUMPTION: `X` is honoured **only** on a claim socket (or is a no-op from the controller).
|
||||
It is not a general command, so a controller cannot use it to reset its own session.
|
||||
- ASSUMPTION: the incumbent gets no warning window and no veto. Its operator is by
|
||||
definition absent — that is the situation the TODO describes — and a veto held by an
|
||||
absent tab is the lockout we are removing. The protection is that the claimant needed the
|
||||
passphrase, and that the board lands disarmed.
|
||||
- ASSUMPTION: `E12` is a new code rather than reusing `E3`. `E3` means "you were refused";
|
||||
`E12` means "you were dropped". One of those should be alarming on the incumbent's screen
|
||||
and the other should not.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_proto.cpp`: `X` parses to `Cmd::Takeover`; `X` with trailing bytes is `Cmd::Bad`;
|
||||
`E12` formats; the render/parse round trip covers it; the existing 2 327 checks around chord
|
||||
labels and state specs stay exactly as they are.
|
||||
- `tests/test_relayctl.py` — the three source pins named in the phase overview: funnel before
|
||||
adoption, no `arm()` reachable from the `X` path, no promotion without `_authed`. Written
|
||||
the way the existing pins are written (a named assertion per sentence, with the sentence in
|
||||
the output) so the suite says out loud what it checked.
|
||||
- Device (best effort, one attempt): arm on tab A, hold a key, take over from tab B —
|
||||
`watch_target.py` shows the key-up and the board reports `armed:0`; tab A shows `E12`.
|
||||
Record the transcript in `.agents/HW_VERIFICATION.md` or `skipped (no device)`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `X` from an authenticated claim drops the incumbent with `E12` and adopts the claim.
|
||||
- [ ] The board is disarmed immediately after every takeover, observed or recorded as
|
||||
unobserved-with-commands — never asserted without a mechanism.
|
||||
- [ ] The three source pins exist and each one fails when its sentence is broken (shown, then
|
||||
reverted).
|
||||
- [ ] `bash .agents/validate.sh` green; `_takeovers` counter visible; full test suite green.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Task 03 — Make it visible: `busy` in the status frame, TAKE OVER on the page
|
||||
|
||||
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
|
||||
if another session is connected…" (the operator-facing half: the accident has to be fixable
|
||||
from the screen you are looking at)
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
A controller that finds the bridge already held can see that it is held, can see who is
|
||||
holding it in the only terms the board is allowed to know (a counter and a link state, never
|
||||
an address), and can take it with one deliberate click. The incumbent's screen stops being
|
||||
mysterious: it says it was taken over, disarms, and shows that in its log.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/control_server.h` / `.cpp` — `StatusSnapshot` gains `bool busy` (a controller holds
|
||||
the keyboard: `linked()`) and `uint32_t takeovers`; `statusJson()` emits `"busy":u` and
|
||||
`"tkov":u`; grow `kStatusJsonCap` with the arithmetic in the comment. **No** client
|
||||
address, no user agent, no timing that would identify the other session — the README's
|
||||
"never stored" list is about exactly this temptation.
|
||||
2. `src/web_page.h`:
|
||||
- The link pill gets a third honest state: `held by another controller` (from `s.busy`)
|
||||
alongside `linked` and `link down`.
|
||||
- A refused upgrade no longer retries in a loop. On close-without-auth, if the board
|
||||
reports `busy`, show a **take over** button next to the passphrase field. Clicking it:
|
||||
open the socket, authenticate with the passphrase already in the field, then send `X` —
|
||||
one click, one intent. Before the click nothing is sent; after it the page behaves like
|
||||
any other controller, starting **disarmed**, with a log line naming what happened.
|
||||
- `onFrame()` handles `E12`: `setArmed(false, "another controller took over")`, drop the
|
||||
mouse (`dropMouse`), abort a typing run, mark the link pill, and stop reconnecting
|
||||
automatically — a page that immediately re-claims would turn two tabs into a fight.
|
||||
The existing `code === "3"` branch keeps its meaning for "no room for a claim".
|
||||
- Keep the arming banner, the safety hints and every id the script reaches for intact —
|
||||
`tests/test_page.js` checks id coverage, and phase 04 is where the prose gets trimmed.
|
||||
3. `tools/relayctl.py` — `--take-over`: after authentication, if the board reports a
|
||||
controller already present, send `X` and continue; without the flag, a `busy` board is a
|
||||
clear refusal naming the flag. `--take-over` on a free board is a no-op, not an error.
|
||||
(Task 04 finishes the tool's tests; the flag lands here so the page and the tool agree
|
||||
from the start.)
|
||||
4. `README.md` — the **Wire protocol** table gains `X` and `E12` (this is a four-file
|
||||
protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README — all four move), the
|
||||
**Controller page** element table gains the take-over affordance, and **Security model**
|
||||
gets the sentence that matters: someone with the passphrase can take the bridge over, and
|
||||
cannot arm it by doing so.
|
||||
|
||||
- ASSUMPTION: the page reconnect policy after a takeover of *your* session is "stay down until
|
||||
you click". Auto-reclaim is the fastest way to make two devices fight over a keyboard.
|
||||
- ASSUMPTION: `busy` is a boolean plus a counter, not "who". The README's trust table already
|
||||
promises the target's user cannot detect a second controller and the board stores nothing
|
||||
identifying; `busy` keeps that promise while making the state visible to the person who is
|
||||
locked out.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_page.js`: `busy` renders the third pill state; a refused upgrade shows the
|
||||
button and sends nothing until clicked; the click sends `T` then `X` in that order and
|
||||
nothing else; `E12` disarms, releases the mouse, aborts typing and does not reconnect;
|
||||
every id the script reaches for exists in the markup (the existing coverage check).
|
||||
- `tests/test_proto.cpp`: the new status fields render and the frame still fits
|
||||
`kStatusJsonCap` with the longest legal SSID.
|
||||
- `tests/test_relayctl.py`: `--take-over` frame ordering; refusal without it; no-op on a free
|
||||
board; every generated frame parseable by the firmware's own parser (already a suite rule).
|
||||
- Device (best effort, one attempt): the whole gesture from a phone while a laptop tab holds
|
||||
the bridge, observed with `watch_target.py` on the target.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `s.busy` drives a distinct pill state, and the take-over button appears only when the
|
||||
board says it is held.
|
||||
- [ ] One click takes the bridge; the page that lost it says so, disarmed, and stays down.
|
||||
- [ ] `relayctl.py --take-over` works and the default refuses; both go through the same
|
||||
frames the page sends.
|
||||
- [ ] README wire table lists `X`/`E12` and all four protocol locations agree (`test_proto`
|
||||
and `test_relayctl` green).
|
||||
- [ ] Full test suite green; no client identity anywhere in the status frame.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Task 04 — Pin the safety sentences, the procedure, and what `AGENTS.md` now gets wrong
|
||||
|
||||
**Phase:** `03_session_takeover` · **Source:** `TODO.md:6` — "Need to be able to take control
|
||||
if another session is connected…" (the half that keeps it true next month)
|
||||
**Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
|
||||
Make the takeover's safety claims the kind that fail loudly when someone edits them away:
|
||||
executable checks in the host suites, a written end-to-end procedure that can be run against
|
||||
a real target, and the two documents that currently state the old rule corrected so they no
|
||||
longer describe a refusal that no longer happens.
|
||||
|
||||
## Work
|
||||
|
||||
1. `tests/test_relayctl.py` — the phase's three source pins, final form, in the style of the
|
||||
existing three ("the suite says so out loud"):
|
||||
- the takeover path calls the release funnel (`releaseEverything()` / the `onLinkChange`
|
||||
that runs it) **before** the newcomer is adopted — assert the ordering in
|
||||
`control_server.cpp`, not the intention;
|
||||
- no `arm` / `armed = true` is reachable from the `X` handler;
|
||||
- a claim socket cannot reach `Phase::WebSocket` promotion without `_authed`.
|
||||
Also: the existing pins (silence disarm off `controllerSeen()`, `acceptNew()` never stamps
|
||||
the controller clock, `closeLink()` stamps it only for a controller link) must still pass —
|
||||
the takeover touched all three of those code paths, so re-read each one and confirm the
|
||||
sentence is still true rather than trusting green.
|
||||
2. `tests/test_page.js` — one check per safety sentence: takeover disarms the page that lost
|
||||
the bridge; a lost page does not auto-reclaim; the claim sends nothing before the click;
|
||||
the mouse is released on `E12`. Reuse the shim's `fire()`; extend the shim only as far as
|
||||
`WebSocket` refusal needs (it already shims `WebSocket`, `sessionStorage` and `location`).
|
||||
3. `README.md`:
|
||||
- **End-to-end verification**: the takeover procedure in the commands-already-in-this-file
|
||||
style — terminal A `watch_target.py` on the target, terminal B `relayctl.py … key a`
|
||||
held, laptop tab holds the bridge, phone takes over, and what must appear in what order
|
||||
(key-up before the newcomer's first key).
|
||||
- **Safety model**: a bullet in the "disarmed is the only default" list — a takeover
|
||||
disarms, releases every key and every mouse button, and cannot arm.
|
||||
- **Tests**: the new check counts, and the suite descriptions updated to name the takeover
|
||||
pins the way the table already names bug 10's pins.
|
||||
- If this phase found a real bug — a stale `wasLinked`, a double promotion, a claim that
|
||||
survived its own timeout — it goes into **"Ten bugs this verification caught"** with its
|
||||
root cause and how it was proven, and the section's count changes with it. A bug fixed
|
||||
silently is a bug that comes back.
|
||||
4. `AGENTS.md` — **stale statements only**. Invariant 8 currently reads "A second `/ws`
|
||||
upgrade is refused (`E3`)"; that is now wrong. Rewrite that one sentence to say what is
|
||||
true: the keyboard stays exclusive, a second `/ws` is a pending claim that must
|
||||
authenticate, and only `X` moves the keyboard — with `E3` now meaning "no room for a
|
||||
claim". Nothing else in the file: no phase narrative, no new sections.
|
||||
5. `.agents/HW_VERIFICATION.md` — the takeover row filled with what was observed (or
|
||||
`skipped (no device)` + the commands). If it was skipped, the phase overview's completion
|
||||
criteria must still be honest about it — the phase's final pass restates it, it does not
|
||||
delete it.
|
||||
|
||||
- ASSUMPTION: the pins are source-text assertions, not behavioural ones, because the handover
|
||||
needs two real sockets and there is no socket harness here. That is the same trade the
|
||||
project already made for bug 10, and the same caveat is written into the test's comment.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Each new pin is demonstrated to fail when its sentence is inverted, then reverted (report
|
||||
shows both runs) — a pin that cannot fail is decoration.
|
||||
- `bash .agents/validate.sh` green: suites, sanitiser run, both firmware environments.
|
||||
- Coverage **>90%** on the modified logic; `test_proto.cpp` unchanged apart from the status
|
||||
fields and `X`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] Three takeover source pins present, named in the suite output, each proven to fail.
|
||||
- [ ] Page safety sentences pinned in `test_page.js` (disarm on `E12`, no auto-reclaim,
|
||||
nothing sent before the click, mouse released).
|
||||
- [ ] README: safety bullet, end-to-end takeover procedure, Tests table and totals consistent.
|
||||
- [ ] `AGENTS.md` invariant 8 describes the claim, not the old refusal, and gained no new prose.
|
||||
- [ ] Full test suite green; no behaviour change in phases 01–02 (spot-check: TLS handshake
|
||||
and `busy`-free takeover both still observed or recorded as unobserved-with-commands).
|
||||
Reference in New Issue
Block a user