Files
boxmaker/docs/plans/M3a/13-brokerd-broker.md
T
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
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>
2026-09-18 23:45:43 -07:00

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, and crates/brokerd/tests/support/client.rs
  • Create: crates/brokerd/src/broker.rs
  • Modify: crates/brokerd/src/lib.rs (add pub 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 → set printed to None. Err(p) and printed is not Some(p) → log(grants::render(&p).trim_end()), then printed = Some(p.clone()). So each distinct set of problems is printed once. Recover a poisoned printed lock with into_inner. Return what load returned.
  • read_request: read_frame. Ok → Some. Closed → None. Any other error → send an error frame (id 0, final: true) with code BadVersion for FrameError::BadVersion(_), BadMessage for FrameError::Json(_), BadFrame otherwise, detail the error's text; None.
  • forbid: log(&format!("brokerd: refused the message kind {kind} on {socket}\nsee docs/runbook.md#socket-forbidden")), then send Error { code: Forbidden, detail: format!("{kind} is not accepted on {socket}") }, final: true, the request's id.
  • alive: set_read_timeout(Some(Duration::from_millis(10))) (Err → false; a zero duration is an error in std). Then read one byte through &UnixStream (Read is implemented for it): Ok(0) → false (gone); Ok(_) → false (bytes break the protocol); Err with kind WouldBlock or TimedOut → true; any other Err → false. loopd never half-closes and sends nothing more, so waiting means it is there.

handle: every exit

  1. read_request is None → return.
  2. The message is not Message::ToolRequest → forbid(.., "broker.sock"), return. Nothing is written to the audit log.
  3. now = Timestamp::now(), grants = broker.grants(), broker.ledger.decide(request, &grants, now):
    • Denied(reason) → answer ToolResponse::Denied { reason }.
    • Allowed { decision, seq } → answer run(decision, seq) (below).
    • Ask { .. } → the pending path (below). None from it → return, sending nothing more.
  4. Send the answer, final: true, the request's id. 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

  1. expires = Timestamp::from_unix_millis(now.unix_millis().saturating_add(ttl_ms)), or Timestamp::MAX if that is Err; then the earlier of that and ask.expires() if the grant has one.
  2. info = PendingApproval { approval: seq, session, call, tool: ask.request().tool, arguments: ask.args().canonical_json(), grant: ask.grant(), taint: state.taint, created: now, expires }, then let verdict = table.insert(info, ask).
  3. Send PendingApproval { approval: seq, expires } with final: false. If the send fails and table.take(seq) is Some → return None (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.
  4. Wait: loop on verdict.recv_timeout(Duration::from_secs(1)):
    • Ok(v) → go to 5 with v.
    • Err(Timeout): alive(stream) → loop. Gone and table.take(seq) is Some → return None. Gone and the entry is already taken → verdict.recv(): Ok(v) → 5; Err → Denied(AuditUnavailable).
    • Err(Disconnected) (the taker dropped it unanswered) → Denied(AuditUnavailable).
  5. Denied(reason) → answer Denied { reason }. Run(decision) → unbox it; one more look: if !alive(stream), ledger.finish(&Call::of(&decision, seq), Failed { GONE }, now) and return None without running. Otherwise answer run(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, then cp docs/plans/M3a/files/crates/brokerd/tests/{broker,broker_pending,broker_sequence}.rs crates/brokerd/tests/ and cp 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, add pub mod broker;. Run cargo 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 passed every time. broker_pending takes 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 gate prints gate: ok.

Stop and report if

  • A test expects a call to run without a Decision record allowing it or an Approval record whose re-decision allows it.
  • A test needs handle to write an audit record itself: every record goes through the ledger.