Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.1 KiB
M3a task 13: one tool request on broker.sock
Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop)
Commit subject: Handle a tool request from decision to answer
Goal
brokerd::broker::handle serves one broker.sock connection: loopd sends one tool_request;
brokerd decides, records, runs if allowed, records the result, and answers with one final
tool_response. For an ask call it first sends one pending_approval frame (final: false)
and waits for whoever takes the table entry to send the verdict. Every frame carries the
request's id. The tests answer entries by hand, as task 14's admin will.
Files
- Copy:
crates/brokerd/tests/broker.rs,broker_pending.rs,broker_sequence.rs, andcrates/brokerd/tests/support/client.rs - Create:
crates/brokerd/src/broker.rs - Modify:
crates/brokerd/src/lib.rs(addpub mod broker;),docs/implementer-log.md
Interfaces
pub const GONE: &str = "the requester went away";
pub type Log = Box<dyn Fn(&str) + Send + Sync>;
pub struct Broker { /* cfg: Config, ledger: Ledger, table: Table, runtime: Box<dyn Runtime>,
log: Log, printed: Mutex<Option<Vec<GrantProblem>>> */ }
impl Broker {
pub fn new(cfg: Config, ledger: Ledger, runtime: Box<dyn Runtime>, log: Log) -> Broker; // Table::new()
pub fn cfg(&self) -> &Config;
pub fn ledger(&self) -> &Ledger;
pub fn table(&self) -> &Table;
pub fn log(&self, line: &str);
pub fn grants(&self) -> Grants;
}
pub fn kind(msg: &Message) -> &'static str; // snake_case wire name, all 14 kinds, no `_`
pub fn send(stream: &mut UnixStream, id: u64, last: bool, msg: Message) -> bool; // write_frame(..).is_ok()
pub fn read_request(stream: &mut UnixStream) -> Option<Envelope>;
pub fn forbid(broker: &Broker, stream: &mut UnixStream, id: u64, msg: &Message, socket: &str);
pub fn alive(stream: &UnixStream) -> bool;
pub fn handle(stream: UnixStream, broker: &Broker);
Task 14 uses send, read_request, forbid and grants too; they are pub for that.
The helpers
grants():grants::load(&self.cfg.paths.grants).Ok→ setprintedtoNone.Err(p)andprintedis notSome(p)→log(grants::render(&p).trim_end()), thenprinted = Some(p.clone()). So each distinct set of problems is printed once. Recover a poisonedprintedlock withinto_inner. Return whatloadreturned.read_request:read_frame.Ok→Some.Closed→None. Any other error → send an error frame (id0,final: true) with codeBadVersionforFrameError::BadVersion(_),BadMessageforFrameError::Json(_),BadFrameotherwise, detail the error's text;None.forbid:log(&format!("brokerd: refused the message kind {kind} on {socket}\nsee docs/runbook.md#socket-forbidden")), then sendError { code: Forbidden, detail: format!("{kind} is not accepted on {socket}") },final: true, the request'sid.alive:set_read_timeout(Some(Duration::from_millis(10)))(Err→false; a zero duration is an error in std). Thenreadone byte through&UnixStream(Readis implemented for it):Ok(0)→false(gone);Ok(_)→false(bytes break the protocol);Errwith kindWouldBlockorTimedOut→true; any otherErr→false.loopdnever half-closes and sends nothing more, so waiting means it is there.
handle: every exit
read_requestisNone→ return.- The message is not
Message::ToolRequest→forbid(.., "broker.sock"), return. Nothing is written to the audit log. now = Timestamp::now(),grants = broker.grants(),broker.ledger.decide(request, &grants, now):Denied(reason)→ answerToolResponse::Denied { reason }.Allowed { decision, seq }→ answerrun(decision, seq)(below).Ask { .. }→ the pending path (below).Nonefrom it → return, sending nothing more.
- Send the answer,
final: true, the request'sid. A failed send is ignored: the records are already written.
run(decision, seq): let call = Call::of(&decision, seq), then runner::run(decision, runtime), then return ledger.finish(&call, response, Timestamp::now()).
The pending path: every exit
expires=Timestamp::from_unix_millis(now.unix_millis().saturating_add(ttl_ms)), orTimestamp::MAXif that isErr; then the earlier of that andask.expires()if the grant has one.info = PendingApproval { approval: seq, session, call, tool: ask.request().tool, arguments: ask.args().canonical_json(), grant: ask.grant(), taint: state.taint, created: now, expires }, thenlet verdict = table.insert(info, ask).- Send
PendingApproval { approval: seq, expires }withfinal: false. If the send fails andtable.take(seq)isSome→ returnNone(nothing written: the log shows it abandoned). If the send fails and the entry is already gone, someone is answering it: go on to 4. - Wait: loop on
verdict.recv_timeout(Duration::from_secs(1)):Ok(v)→ go to 5 withv.Err(Timeout):alive(stream)→ loop. Gone andtable.take(seq)isSome→ returnNone. Gone and the entry is already taken →verdict.recv():Ok(v)→ 5;Err→Denied(AuditUnavailable).Err(Disconnected)(the taker dropped it unanswered) →Denied(AuditUnavailable).
Denied(reason)→ answerDenied { reason }.Run(decision)→ unbox it; one more look: if!alive(stream),ledger.finish(&Call::of(&decision, seq), Failed { GONE }, now)and returnNonewithout running. Otherwise answerrun(decision, seq).
Verified in the std docs: Receiver::recv_timeout(Duration) -> Result<T, RecvTimeoutError> with
variants Timeout and Disconnected; UnixStream::set_read_timeout(Option<Duration>).
Steps
- 1. Copy.
git switch m3a, thencp docs/plans/M3a/files/crates/brokerd/tests/{broker,broker_pending,broker_sequence}.rs crates/brokerd/tests/andcp docs/plans/M3a/files/crates/brokerd/tests/support/client.rs crates/brokerd/tests/support/ - 2. See the tests fail.
cargo test -p brokerd --test broker. Expected: no compile. - 3. Write
broker.rs, addpub mod broker;. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p brokerd --test broker --test broker_pending --test broker_sequence, five times. Expected:9 passed,5 passed,2 passedevery time.broker_pendingtakes about a second: it waits for the one-second look. - 5. Walk the exits. Point at the line of each numbered exit in both lists above.
- 6. Run the gate.
make gate. Expected last line:gate: ok. - 7. Log and commit.
git add crates/brokerd docs/implementer-log.md && git commit
Done when
- The three suites pass five runs in a row with the counts in step 4; step 5 is in the log's
Notes;
make gateprintsgate: ok.
Stop and report if
- A test expects a call to run without a
Decisionrecord allowing it or anApprovalrecord whose re-decision allows it. - A test needs
handleto write an audit record itself: every record goes through the ledger.