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>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
# 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<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.
|
||||
Reference in New Issue
Block a user