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,186 @@
|
||||
// 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");
|
||||
}
|
||||
Reference in New Issue
Block a user