# 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 ```rust pub const GONE: &str = "the requester went away"; pub type Log = Box; pub struct Broker { /* cfg: Config, ledger: Ledger, table: Table, runtime: Box, log: Log, printed: Mutex>> */ } impl Broker { pub fn new(cfg: Config, ledger: Ledger, runtime: Box, 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; 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` with variants `Timeout` and `Disconnected`; `UnixStream::set_read_timeout(Option)`. ## 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.