Files
ducoterra d629a0ff51 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/`.
2026-11-09 08:57:35 -05:00

187 lines
8.9 KiB
C++

// tests for the pending takeover claim: tests/test_claim.cpp
//
// Phase 03 task 01 opens one door a crack: the spare socket may now complete the
// WebSocket handshake and authenticate as a *pending claim* instead of getting a 503.
// Nothing else is on the wire yet, so everything that decides what that socket is
// allowed to be -- which role, which commands, which clock -- lives in src/claim.h,
// and this suite walks it: the whitelist as a matrix (every Cmd, both auth states,
// both roles), the deadline arithmetic on both sides of the millis() rollover, and
// the claim's buffer against the widest frame it may receive -- refused, not
// buffered, measured through the real codec rather than asserted from the constant.
#include <string.h>
#include "claim.h"
#include "config.h"
#include "proto.h"
#include "test.h"
#include "ws_codec.h"
using kr::claim::acceptsUpgrade;
using kr::claim::expired;
using kr::claim::honours;
using kr::claim::ViewerRole;
using kr::proto::Cmd;
using kr::proto::Message;
using kr::proto::parse;
namespace {
// Every value proto::Cmd has. The matrix below walks it, so a new command added to
// the protocol is a new row that must be judged allowed-or-refused for a claim --
// the whitelist decides by name, and this list is what notices a name appears.
constexpr Cmd kAll[] = {Cmd::None, Cmd::Hello, Cmd::Auth,
Cmd::Arm, Cmd::Disarm, Cmd::State,
Cmd::SecureAttention, Cmd::Chord, Cmd::Text,
Cmd::Mouse, Cmd::ReleaseAll, Cmd::KeepAlive,
Cmd::Takeover, Cmd::Bad};
constexpr size_t kNumCmds = sizeof(kAll) / sizeof(kAll[0]);
// One masked client text frame, the only shape the codec accepts, sized for a
// payload below 126 bytes (the claim's whole budget is 64).
size_t maskedTextFrame(uint8_t *out, const uint8_t *text, size_t n) {
const uint8_t key[4] = {0x37, 0xfa, 0x11, 0xde};
size_t o = 0;
out[o++] = 0x81; // FIN + text
out[o++] = static_cast<uint8_t>(0x80 | n); // masked, 7-bit length
for (size_t i = 0; i < 4; i++) out[o++] = key[i];
for (size_t i = 0; i < n; i++) out[o++] = static_cast<uint8_t>(text[i] ^ key[i & 3]);
return o;
}
} // namespace
int main() {
CASE("the whitelist, unauthenticated: T is the only door");
for (size_t i = 0; i < kNumCmds; i++) {
CHECK_EQ(honours(ViewerRole::Claim, false, kAll[i]), kAll[i] == Cmd::Auth);
}
CASE("the whitelist, authenticated: T, K and the deliberate X -- nothing else");
for (size_t i = 0; i < kNumCmds; i++) {
const bool allowed = kAll[i] == Cmd::Auth || kAll[i] == Cmd::KeepAlive ||
kAll[i] == Cmd::Takeover;
CHECK_EQ(honours(ViewerRole::Claim, true, kAll[i]), allowed);
}
// X is allowed ONLY here, in the authenticated branch: it is the one deliberate act
// the door exists for, and control_server.cpp::handOverClaim -- the only path it
// triggers -- runs the release funnel and lands the board DISARMED. The claim socket
// that reaches it has already proven the passphrase.
CHECK(honours(ViewerRole::Claim, true, Cmd::Takeover));
// Refused by name, because this list is the safety argument: reaching the target,
// disturbing the incumbent, or moving the arming line are all refused here.
CHECK(!honours(ViewerRole::Claim, true, Cmd::Arm)); // no arm
CHECK(!honours(ViewerRole::Claim, true, Cmd::Disarm));
CHECK(!honours(ViewerRole::Claim, true, Cmd::State)); // no keystrokes
CHECK(!honours(ViewerRole::Claim, true, Cmd::SecureAttention)); // no Ctrl+Alt+Del
CHECK(!honours(ViewerRole::Claim, true, Cmd::Chord));
CHECK(!honours(ViewerRole::Claim, true, Cmd::Text)); // no paste
CHECK(!honours(ViewerRole::Claim, true, Cmd::Mouse)); // no pointer
CHECK(!honours(ViewerRole::Claim, true, Cmd::ReleaseAll)); // no R either
CHECK(!honours(ViewerRole::Claim, true, Cmd::Hello)); // no status
CHECK(!honours(ViewerRole::Claim, true, Cmd::Bad)); // not a conversation
CASE("a Readonly slot honours no frame at all, authenticated or not");
for (size_t i = 0; i < kNumCmds; i++) {
CHECK(!honours(ViewerRole::Readonly, false, kAll[i]));
CHECK(!honours(ViewerRole::Readonly, true, kAll[i]));
}
// Including the takeover ask: a slot that never did the upgrade cannot claim a bridge.
CHECK(!honours(ViewerRole::Readonly, true, Cmd::Takeover));
CASE("one claim at a time; a read-only slot may be turned into one");
CHECK(acceptsUpgrade(ViewerRole::Readonly));
CHECK(!acceptsUpgrade(ViewerRole::Claim));
CASE("deadlines: authenticate on the controller's clock, then wait our own");
{
const uint32_t anchor = 1234567;
// Unauthenticated: exactly the controller's kAuthTimeoutMs, strictly greater.
CHECK(!expired(false, anchor, anchor));
CHECK(!expired(false, anchor + kr::kAuthTimeoutMs, anchor));
CHECK(expired(false, anchor + kr::kAuthTimeoutMs + 1, anchor));
// Authenticated: the auth clock no longer applies -- a claim that got its `OK`
// at the worst legal moment is still a claim -- and kClaimTimeoutMs governs.
CHECK(!expired(true, anchor + kr::kAuthTimeoutMs + 1, anchor));
CHECK(!expired(true, anchor + kr::kClaimTimeoutMs, anchor));
CHECK(expired(true, anchor + kr::kClaimTimeoutMs + 1, anchor));
CHECK(kr::kClaimTimeoutMs > kr::kAuthTimeoutMs); // 15 s for a click, 5 s for T
// Across the 49.7-day millis() rollover the arithmetic is elapsed time, not
// magnitude: a live claim is not evicted and a dead one is not immortal.
const uint32_t nearWrap = 0xFFFFFF00u;
CHECK(!expired(false, nearWrap + 4000, nearWrap));
CHECK(expired(false, nearWrap + kr::kAuthTimeoutMs + 1, nearWrap)); // wraps
CHECK(!expired(true, nearWrap + 14000, nearWrap));
CHECK(expired(true, nearWrap + kr::kClaimTimeoutMs + 1, nearWrap)); // wraps
}
CASE("the claim's budget: every legal frame fits, anything larger is refused");
{
// The sizing claim, pinned as numbers: the widest frame a claim may receive is
// `T<token>` at kTokenMax + 1, the cap is that with headroom, and it is exactly
// one quarter of the controller's kWsMessageCap (config.h carries the reasoning;
// a draft said sixteenth, the arithmetic says four, and the arithmetic wins).
CHECK(kr::kTokenMax + 1 <= kr::kClaimMsgCap);
CHECK_EQ(kr::kWsMessageCap / kr::kClaimMsgCap, 4u);
CHECK(kr::kClaimMsgCap < kr::kWsMessageCap);
// The widest legal T frame parses, and fits the buffer with room to spare.
char widest[ kr::kTokenMax + 2];
widest[0] = 'T';
memset(widest + 1, 'a', kr::kTokenMax);
widest[kr::kTokenMax + 1] = '\0';
Message m;
CHECK(parse(widest, kr::kTokenMax + 1, m) == Cmd::Auth);
CHECK(strlen(widest) < kr::kClaimMsgCap);
}
CASE("an oversized claim frame is refused by the codec, not buffered");
{
uint8_t msg[kr::kClaimMsgCap + 16];
memset(msg, 'z', sizeof(msg));
// Exactly at the cap: the reader completes (the frame is refused or accepted by
// the whitelist later -- the codec's answer is "it fits").
uint8_t buf[kr::kClaimMsgCap];
kr::ws::Reader r{buf, sizeof(buf)};
uint8_t frame[256];
size_t n = maskedTextFrame(frame, msg, kr::kClaimMsgCap);
kr::ws::In in = kr::ws::In::NeedMore;
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r.feed(frame[i]);
CHECK(in == kr::ws::In::Text);
// One byte over: an error, not a truncation, not a growing buffer. The board's
// RAM is not somebody else's allocation request.
kr::ws::Reader r2{buf, sizeof(buf)};
n = maskedTextFrame(frame, msg, kr::kClaimMsgCap + 1);
in = kr::ws::In::NeedMore;
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r2.feed(frame[i]);
CHECK(in == kr::ws::In::Error);
CHECK(r2.error() == kr::ws::Err::TooLong);
}
CASE("X is the takeover command, and the frames the whitelist names");
{
// Task 02 put `X` on the wire: it parses to Takeover (trailing bytes are Bad --
// test_proto.cpp walks that in full), and it is the only frame that changed side
// of the whitelist since task 01. The rest still parse as the whitelist comment
// says they do, because that comment and this list are the same argument.
Message m;
CHECK(parse("X", 1, m) == Cmd::Takeover);
CHECK(parse("XX", 2, m) == Cmd::Bad);
// And the frames named by the whitelist comment really are the commands named:
CHECK(parse("K", 1, m) == Cmd::KeepAlive);
CHECK(parse("Tchangeme", 9, m) == Cmd::Auth);
CHECK(parse("A1", 2, m) == Cmd::Arm);
CHECK(parse("A0", 2, m) == Cmd::Disarm);
CHECK(parse("R", 1, m) == Cmd::ReleaseAll);
CHECK(parse("C", 1, m) == Cmd::SecureAttention);
CHECK(parse("H", 1, m) == Cmd::Hello);
CHECK(parse("S=00:", 5, m) == Cmd::State);
CHECK(parse("Y=02:04", 7, m) == Cmd::Chord);
CHECK(parse("P=0:6162", 8, m) == Cmd::Text);
CHECK(parse("M=00:0:0:0:0", 12, m) == Cmd::Mouse);
}
return report("test_claim");
}