diff --git a/crates/brokerd/src/container.rs b/crates/brokerd/src/container.rs new file mode 100644 index 0000000..240221d --- /dev/null +++ b/crates/brokerd/src/container.rs @@ -0,0 +1,276 @@ +//! The Podman runtime: one fresh container per call, with the limits of `[runner]`. Whatever the +//! tool prints is the result's content; every failure is a fixed sentence, and what Podman itself +//! said goes only to `brokerd`'s log. M3b spec, section 6. + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crate::config::Runner; +use crate::podman; +use crate::runner::{RunError, RunOutput, RunSpec, Runtime}; + +pub const RUNBOOK: &str = "see docs/runbook.md#runner-unavailable"; +pub const COULD_NOT_RUN: &str = "the tool could not run"; +pub const CANNOT_START: &str = "the tool runner could not start the container"; +pub const KILLED: &str = "the tool was stopped: it ran out of memory or was killed"; +pub const TIMED_OUT: &str = "the tool ran past its time limit"; +pub const UNEXPECTED: &str = "the tool failed with an unexpected status"; +/// How often a running container is checked. +pub const POLL: Duration = Duration::from_millis(50); +/// How much of Podman's standard error is kept for the log. +pub const STDERR_KEPT: usize = 4096; + +/// A log sink. Call it as `(self.log)("a line")`. +pub type Log = Arc; + +pub struct Podman { + runner: Runner, + egress_dir: PathBuf, + log: Log, + next: AtomicU64, +} + +impl Podman { + /// `egress_dir` is `Config::egress_dir()`; task 12 uses it. + pub fn new(runner: Runner, egress_dir: PathBuf, log: Log) -> Podman { + Podman { + runner, + egress_dir, + log, + next: AtomicU64::new(0), + } + } + + /// Where `http_fetch` calls get their directories. Public, so the field counts as read. + pub fn egress_dir(&self) -> &Path { + &self.egress_dir + } + + /// Run one short `podman` command (`kill`, `rm -f`) whose result only matters for the log. + pub(crate) fn podman(&self, args: &[&str]) { + let what = args.join(" "); + let podman = &self.runner.podman; + match Command::new(podman) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => (self.log)(&format!("brokerd: podman {what} exited {status}")), + Err(e) => (self.log)(&format!("brokerd: podman {what} failed: {e}")), + } + } + + /// Wait for `child` until `limit` has passed since `started`. `Some(status)` if it ended; + /// `None` if it ran too long, after `podman kill `, `podman rm -f `, + /// `child.kill()` and `child.wait()` (step 5). + fn wait( + &self, + child: &mut Child, + name: &str, + started: Instant, + limit: Duration, + ) -> Option { + loop { + if let Ok(Some(status)) = child.try_wait() { + return Some(status); + } + if started.elapsed() >= limit { + self.podman(&["kill", name]); + self.podman(&["rm", "-f", name]); + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(POLL); + } + } +} + +impl Podman { + /// Steps 3 to 7 for one container: spawn, feed and read it, wait, answer. Task 12 calls it too. + /// Already written: it only joins the helpers below. + fn run_container( + &self, + name: &str, + args: Vec, + input: String, + limit: Duration, + ) -> Result { + let Some(mut child) = self.spawn(args) else { + return Err(RunError::Unavailable(CANNOT_START.to_string())); + }; + let started = Instant::now(); + let cap = usize::try_from(self.runner.output_cap).unwrap_or(usize::MAX); + let io = Io::start(&mut child, input, cap); + let status = self.wait(&mut child, name, started, limit); + let (out, truncated, err) = io.finish(); + self.answer(name, status, out, truncated, &err) + } + + /// Step 3: spawn `podman` with `args` and all three standard streams piped. On failure, log + /// `brokerd: cannot start {podman path}: {e}` + "\n" + RUNBOOK and return `None`. + fn spawn(&self, args: Vec) -> Option { + let podman = &self.runner.podman; + match Command::new(podman) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => Some(child), + Err(e) => { + (self.log)(&format!( + "brokerd: cannot start {}: {}\n{}", + podman.display(), + e, + RUNBOOK + )); + None + } + } + } + + /// Step 7: the answer, by the table in the task. `status` is `None` when the time limit was + /// passed. `out` is the kept standard output, `err` the kept standard error (for the log only: + /// it never goes into a `RunError`). + fn answer( + &self, + name: &str, + status: Option, + out: Vec, + truncated: bool, + err: &str, + ) -> Result { + match status { + None => { + (self.log)(&format!( + "brokerd: stopped container {}: it ran past its time limit", + name + )); + Err(RunError::Failed(TIMED_OUT.to_string())) + } + Some(status) => match status.code() { + Some(0) | Some(1) => Ok(RunOutput { + content: String::from_utf8_lossy(&out).into_owned(), + truncated, + }), + Some(2) => { + (self.log)(err); + Err(RunError::Failed(COULD_NOT_RUN.to_string())) + } + Some(125..=127) => { + (self.log)(&format!("{err}\n{RUNBOOK}")); + Err(RunError::Unavailable(CANNOT_START.to_string())) + } + Some(137) => Err(RunError::Failed(KILLED.to_string())), + _ => { + (self.log)(&format!("brokerd: container {name} exited {status}\n{err}")); + Err(RunError::Failed(UNEXPECTED.to_string())) + } + }, + } + } +} + +impl Runtime for Podman { + /// Steps 1 and 2, then `run_container`. Already written. + fn run(&self, spec: &RunSpec) -> Result { + let n = self.next.fetch_add(1, Ordering::SeqCst); + let name = podman::container_name(spec.session(), spec.call(), n); + let args = podman::tool_args(spec, &self.runner, &name, None); + let input = spec.arguments().canonical_json(); + let limit = self.runner.time_limit(spec.tool()); + self.run_container(&name, args, input, limit) + } +} + +/// Step 4: the three threads that feed and read one container, so no pipe can block another. +struct Io { + writer: Option>, + stdout: Option, bool)>>, + stderr: Option, bool)>>, +} + +impl Io { + /// Take `child`'s three pipes (`child.stdin.take()` and so on) and start one thread for each: + /// write `input` to standard input and then drop it; `read_capped` standard output with `cap`; + /// `read_capped` standard error with `STDERR_KEPT`. A pipe that is `None` gets no thread. + fn start(child: &mut Child, input: String, cap: usize) -> Io { + let writer = child.stdin.take().map(|mut stdin| { + std::thread::spawn(move || { + let _ = stdin.write_all(input.as_bytes()); + }) + }); + let stdout = child + .stdout + .take() + .map(|stdout| std::thread::spawn(move || read_capped(stdout, cap))); + let stderr = child + .stderr + .take() + .map(|stderr| std::thread::spawn(move || read_capped(stderr, STDERR_KEPT))); + Io { + writer, + stdout, + stderr, + } + } + + /// Step 6: join the three threads. A thread that is missing or panicked counts as empty + /// (`join().ok()`, `unwrap_or_default()`). Returns the kept standard output, whether there was + /// more, and the kept standard error decoded with `from_utf8_lossy`. + fn finish(self) -> (Vec, bool, String) { + if let Some(handle) = self.writer { + let _ = handle.join(); + } + let (out, truncated) = match self.stdout { + Some(handle) => handle.join().ok().unwrap_or_default(), + None => (Vec::new(), false), + }; + let err = match self.stderr { + Some(handle) => { + let (bytes, _) = handle.join().ok().unwrap_or_default(); + String::from_utf8_lossy(&bytes).into_owned() + } + None => String::new(), + }; + (out, truncated, err) + } +} + +/// Everything `from` gives, keeping the first `cap` bytes; `true` if there was more. Reads on past +/// the cap, so the writer is never blocked or broken by a closed pipe. No indexing: `buf.get(..n)`. +fn read_capped(from: impl Read, cap: usize) -> (Vec, bool) { + let mut from = from; + let mut kept: Vec = Vec::new(); + let mut truncated = false; + let mut chunk = [0u8; 8192]; + loop { + let n = match from.read(&mut chunk) { + Ok(0) => break, + Ok(n) => n, + Err(_) => break, + }; + let remaining = cap - kept.len(); + if remaining == 0 { + truncated = true; + } else { + let take = remaining.min(n); + kept.extend_from_slice(&chunk[..take]); + if n > take { + truncated = true; + } + } + } + (kept, truncated) +} diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index d304524..3f08020 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -6,6 +6,7 @@ pub mod args; pub mod audit; pub mod broker; pub mod config; +pub mod container; pub mod grants; pub mod ledger; pub mod podman; diff --git a/crates/brokerd/tests/container.rs b/crates/brokerd/tests/container.rs new file mode 100644 index 0000000..df54f85 --- /dev/null +++ b/crates/brokerd/tests/container.rs @@ -0,0 +1,271 @@ +//! The Podman runtime against a fake `podman`: what it is given, and what each way a container can +//! end becomes (M3b spec, section 6). Every call goes through `runner::run`, as in `brokerd`. +//! Do not edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/fake_podman.rs"] +mod fake_podman; + +use std::time::{Duration, Instant}; + +use brokerd::container::{ + CANNOT_START, COULD_NOT_RUN, KILLED, Podman, RUNBOOK, TIMED_OUT, UNEXPECTED, +}; +use brokerd::policy::{Outcome, SessionState, decide}; +use brokerd::runner::run; +use build::{grant, now, read, request, set}; +use fake_podman::{Fake, Lines, serial}; +use proto::{DataClass, Mode, ToolRequest, ToolResponse}; + +fn call(podman: &Podman, req: ToolRequest, grants: Vec) -> ToolResponse { + let decision = match decide(req, &set(grants), SessionState::default(), now()) { + Outcome::Allowed(d) => d, + other => panic!("not allowed: {other:?}"), + }; + run(decision, podman) +} + +fn notes() -> Vec { + vec![grant("notes", "read_file", Mode::Auto).paths(&["/n"])] +} + +fn podman(fake: &Fake, extra: &str, log: &Lines) -> Podman { + Podman::new(fake.runner(extra), fake.dir.join("egress"), log.sink()) +} + +fn failed(message: &str) -> ToolResponse { + ToolResponse::Failed { + message: message.to_string(), + } +} + +#[test] +fn a_tool_that_succeeds_is_a_result_labelled_by_its_grant() { + let _s = serial(); + let fake = Fake::new("ok", r#"cat > "$D/stdin"; printf 'the file text'; exit 0"#); + let log = Lines::default(); + let got = call(&podman(&fake, "", &log), read("/n/a.md"), notes()); + assert_eq!( + got, + ToolResponse::Result { + content: "the file text".to_string(), + class: DataClass::Private, + untrusted: true, + truncated: false, + } + ); + assert_eq!( + fake.stdin(), + r#"{"path":"/n/a.md"}"#, + "the arguments go on standard input" + ); +} + +#[test] +fn podman_is_given_the_tool_argument_list_and_the_first_container_is_numbered_0() { + let _s = serial(); + let fake = Fake::new("args", r#"cat > /dev/null; exit 0"#); + let log = Lines::default(); + let p = podman(&fake, "", &log); + call(&p, read("/n/a.md"), notes()); + call(&p, read("/n/b.md"), notes()); + let calls = fake.calls(); + assert_eq!( + calls.len(), + 2, + "one podman run per call and nothing else: {calls:?}" + ); + assert_eq!(calls[0][3], "--name=boxmaker-s1-1-0"); + assert_eq!(calls[1][3], "--name=boxmaker-s1-1-1"); + // The whole list is `podman::tool_args`, tested as golden files in podman_args.rs. + assert_eq!(calls[0].first().map(String::as_str), Some("run")); + assert_eq!(calls[0].last().map(String::as_str), Some("read_file")); + assert!(calls[0].contains(&"--volume=/n:/n:ro".to_string())); +} + +#[test] +fn exit_1_is_the_tools_own_error_and_still_a_result() { + let _s = serial(); + let fake = Fake::new( + "e1", + "cat > /dev/null; printf 'read_file: /n/x: no such file'; exit 1", + ); + let log = Lines::default(); + let got = call(&podman(&fake, "", &log), read("/n/x"), notes()); + assert!( + matches!(&got, ToolResponse::Result { content, .. } if content == "read_file: /n/x: no such file"), + "{got:?}" + ); +} + +#[test] +fn every_other_ending_is_a_fixed_sentence() { + let cases = [ + ("2", COULD_NOT_RUN), + ("125", CANNOT_START), + ("126", CANNOT_START), + ("127", CANNOT_START), + ("137", KILLED), + ("3", UNEXPECTED), + ("124", UNEXPECTED), + ]; + for (code, sentence) in cases { + let _s = serial(); + let body = format!( + "cat > /dev/null; printf 'secret tool output'; echo 'podman said this' >&2; exit {code}" + ); + let fake = Fake::new("codes", &body); + let log = Lines::default(); + let got = call(&podman(&fake, "", &log), read("/n/a"), notes()); + assert_eq!(got, failed(sentence), "exit {code}"); + } +} + +#[test] +fn a_podman_failure_is_logged_with_the_runbook_pointer_and_its_stderr() { + let _s = serial(); + let fake = Fake::new( + "125", + "cat > /dev/null; echo 'Error: image not known' >&2; exit 125", + ); + let log = Lines::default(); + call(&podman(&fake, "", &log), read("/n/a"), notes()); + let text = log.all(); + assert!(text.contains("Error: image not known"), "{text}"); + assert!(text.contains(RUNBOOK), "{text}"); +} + +#[test] +fn a_podman_that_cannot_be_started_is_unavailable_and_logged() { + let _s = serial(); + let fake = Fake::new("missing", "exit 0"); + let mut runner = fake.runner(""); + runner.podman = fake.dir.join("no-such-podman"); + let log = Lines::default(); + let p = Podman::new(runner, fake.dir.join("egress"), log.sink()); + assert_eq!(call(&p, read("/n/a"), notes()), failed(CANNOT_START)); + assert!(log.all().contains(RUNBOOK), "{}", log.all()); +} + +#[test] +fn output_past_the_cap_is_cut_and_marked() { + let _s = serial(); + let fake = Fake::new( + "cap", + "cat > /dev/null; head -c 1000 /dev/zero | tr '\\0' x; exit 0", + ); + let log = Lines::default(); + let got = call( + &podman(&fake, "output_cap = 100", &log), + read("/n/a"), + notes(), + ); + assert!( + matches!(&got, ToolResponse::Result { content, truncated: true, .. } if *content == "x".repeat(100)), + "{got:?}" + ); + let exact = Fake::new( + "cap-exact", + "cat > /dev/null; head -c 100 /dev/zero | tr '\\0' x; exit 0", + ); + let got = call( + &podman(&exact, "output_cap = 100", &log), + read("/n/a"), + notes(), + ); + assert!( + matches!( + &got, + ToolResponse::Result { + truncated: false, + .. + } + ), + "{got:?}" + ); +} + +#[test] +fn output_that_is_not_utf8_is_replaced() { + let _s = serial(); + let fake = Fake::new("utf8", "cat > /dev/null; printf 'a\\377b'; exit 0"); + let log = Lines::default(); + let got = call(&podman(&fake, "", &log), read("/n/a"), notes()); + assert!( + matches!(&got, ToolResponse::Result { content, .. } if content == "a\u{fffd}b"), + "{got:?}" + ); +} + +#[test] +fn a_tool_past_its_time_limit_is_killed_removed_and_failed() { + let _s = serial(); + // `exec`, so killing the process kills the sleep and nothing holds the pipes open. + let fake = Fake::new("slow", "cat > /dev/null; exec sleep 30"); + let log = Lines::default(); + let started = Instant::now(); + let got = call( + &podman(&fake, "read_file_ms = 300", &log), + read("/n/a"), + notes(), + ); + let took = started.elapsed(); + assert_eq!(got, failed(TIMED_OUT)); + assert!(took >= Duration::from_millis(300), "{took:?}"); + assert!(took < Duration::from_secs(5), "{took:?}"); + let calls = fake.calls(); + assert_eq!(calls.len(), 3, "{calls:?}"); + assert_eq!(calls[1], ["kill", "boxmaker-s1-1-0"]); + assert_eq!(calls[2], ["rm", "-f", "boxmaker-s1-1-0"]); +} + +#[test] +fn a_large_argument_is_written_whole_while_the_tool_reads_it() { + let _s = serial(); + let fake = Fake::new("big", r#"cat > "$D/stdin"; printf done; exit 0"#); + let log = Lines::default(); + let content = "y".repeat(900_000); + let req = request( + "write_file", + &format!(r#"{{"path":"/w/big.txt","content":"{content}"}}"#), + ); + let got = call( + &podman(&fake, "", &log), + req, + vec![grant("w", "write_file", Mode::Auto).paths(&["/w"])], + ); + assert!( + matches!(&got, ToolResponse::Result { content, .. } if content == "done"), + "{got:?}" + ); + assert_eq!( + fake.stdin().len(), + content.len() + r#"{"path":"/w/big.txt","content":""}"#.len() + ); +} + +#[test] +fn a_tool_that_never_reads_its_input_still_ends() { + let _s = serial(); + let fake = Fake::new("noread", "printf ignored; exit 0"); + let log = Lines::default(); + let req = request( + "write_file", + &format!( + r#"{{"path":"/w/big.txt","content":"{}"}}"#, + "z".repeat(900_000) + ), + ); + let started = Instant::now(); + let got = call( + &podman(&fake, "write_file_ms = 5000", &log), + req, + vec![grant("w", "write_file", Mode::Auto).paths(&["/w"])], + ); + assert!( + matches!(&got, ToolResponse::Result { content, .. } if content == "ignored"), + "{got:?}" + ); + assert!(started.elapsed() < Duration::from_secs(4)); +} diff --git a/crates/brokerd/tests/support/fake_podman.rs b/crates/brokerd/tests/support/fake_podman.rs new file mode 100644 index 0000000..b491768 --- /dev/null +++ b/crates/brokerd/tests/support/fake_podman.rs @@ -0,0 +1,102 @@ +//! A fake `podman` for the runtime tests: a shell script that records every call's arguments and, +//! for `run`, does what the test says. Do not edit. +//! +//! Included with `#[path = "support/fake_podman.rs"] mod fake_podman;`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use brokerd::config::{Config, Runner}; + +static NEXT: AtomicU32 = AtomicU32::new(0); +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Tests that write a script and run it take turns. Otherwise another test's fork can hold the +/// script open for writing at the moment it is run, and running it fails with "text file busy" +/// (ETXTBSY), which has nothing to do with the code under test. +pub fn serial() -> MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|p| p.into_inner()) +} + +pub const IMAGE: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +pub struct Fake { + pub dir: PathBuf, + pub script: PathBuf, +} + +impl Drop for Fake { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +impl Fake { + /// A fake whose `run` does `run_body` (a shell fragment; `$D` is the fake's directory). Every + /// other command (`kill`, `rm`) is recorded and succeeds. + pub fn new(tag: &str, run_body: &str) -> Fake { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("bx-fp-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("podman"); + let text = format!( + "#!/bin/sh\nD='{}'\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done >> \"$D/calls\"\necho --- >> \"$D/calls\"\n[ \"$1\" = run ] || exit 0\n{run_body}\n", + dir.display() + ); + std::fs::write(&script, text).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + Fake { dir, script } + } + + /// Every call so far, each as its arguments. + pub fn calls(&self) -> Vec> { + let text = std::fs::read_to_string(self.dir.join("calls")).unwrap_or_default(); + let mut calls = Vec::new(); + let mut current = Vec::new(); + for line in text.lines() { + if line == "---" { + calls.push(std::mem::take(&mut current)); + } else { + current.push(line.to_string()); + } + } + calls + } + + /// What the last `run` read on standard input, if the body saved it to `$D/stdin`. + pub fn stdin(&self) -> String { + std::fs::read_to_string(self.dir.join("stdin")).unwrap_or_default() + } + + /// A `[runner]` using this fake, with `extra` lines added. + pub fn runner(&self, extra: &str) -> Runner { + let text = format!( + "[runner]\npodman = \"{}\"\nimage = \"{IMAGE}\"\n{extra}\n", + self.script.display() + ); + Config::parse(&text).unwrap().runner.unwrap() + } +} + +/// A log that keeps its lines. +#[derive(Clone, Default)] +pub struct Lines(pub Arc>>); + +impl Lines { + pub fn sink(&self) -> Arc { + let lines = Arc::clone(&self.0); + Arc::new(move |l: &str| lines.lock().unwrap().push(l.to_string())) + } + pub fn all(&self) -> String { + self.0.lock().unwrap().join("\n") + } +} + +pub fn path(p: &Path) -> String { + p.to_str().unwrap().to_string() +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 125ea55..5a9516e 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M3b/11-brokerd-container | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/container.rs`: the fixed-sentence constants (`COULD_NOT_RUN`, `CANNOT_START`, `KILLED`, `TIMED_OUT`, `UNEXPECTED`), `RUNBOOK`, `POLL`, `STDERR_KEPT` and the `Log` type; `Podman { runner, egress_dir, log, next: AtomicU64 }` with `new` (`next` starts at 0) and the public `egress_dir`. `Runtime::run` = `next.fetch_add` for the container number, `podman::container_name`, `podman::tool_args(spec, &runner, &name, None)`, `spec.arguments().canonical_json()`, `runner.time_limit(tool)`, then `run_container` (already written). `spawn` = `Command::new(&runner.podman)` with all three streams piped, on failure log `brokerd: cannot start {path}: {e}\n{RUNBOOK}` and return None. `Io::start` takes the three pipes and starts one thread each: write `input` then drop stdin, `read_capped` stdout with `cap`, `read_capped` stderr with `STDERR_KEPT`; `finish` joins all three (a missing or panicked thread counts as empty via `join().ok()`/`unwrap_or_default()`). `read_capped` reads past the cap with an 8 KiB buffer, keeping the first `cap` bytes and setting `truncated`, taking `remaining.min(n)` so it never indexes past what it kept. `wait` loops `child.try_wait()`, and once `limit` has elapsed runs `podman kill ` then `podman rm -f ` (each via the private `podman` helper — `.status()` with the three streams null, logging a line on non-success), then `child.kill()`/`child.wait()`, returning None. `podman` helper uses `&self.runner.podman`, not a `podman` field. `answer` follows the table: None→TIMED_OUT (log a line naming the container); exit 0/1→Ok with content `from_utf8_lossy(out)`; exit 2→COULD_NOT_RUN (log stderr); 125/126/127→CANNOT_START (log stderr then `\n{RUNBOOK}`); 137→KILLED; anything else incl. signal→UNEXPECTED (log status and stderr). The tool's output never enters a `RunError`; only the six constants do. Deleted the skeleton paragraph. Added `pub mod container;` to lib.rs after `config`. Copied `tests/support/fake_podman.rs` and `tests/container.rs`. `container` 11 passed ten runs; first gate failed on clippy `manual_range_patterns` (`125|126|127` → `125..=127`), fixed; `make gate` prints `gate: ok`. | ? | | M3b/10-brokerd-podman-args | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/podman.rs`: the `EGRESS_MOUNT`/`EGRESS_SOCKET`/`TOOLKIT` constants; `container_name` = `boxmaker---`; the private `hardening(pids, memory)` emitting the six shared flags (`--read-only` to `--memory=…`) once; the private `volume(host, container, mode)` built with `push` so a directory need not be UTF-8; `tool_args` = `run --rm -i --name= --label=boxmaker=tool --network=none`, then hardening with `runner.pids`/`runner.memory`, `--tmpfs=/tmp:rw,size=64m,mode=1777`, one `--volume=::ro|rw` per `spec.mounts()` in order, the egress volume when `egress` is Some, then ` /bin/toolkit `; `egress_args` = `run -d --rm --name=-egress --label=boxmaker=egress --network=`, hardening 64/128m, the egress volume, then ` /bin/toolkit egress-proxy --socket /run/egress/egress.sock --allow `. `RunSpec` gained private `session: SessionId` and `call: CallId` as its first two fields with `session()`/`call()` getters; `run` fills them from `decision.request()`, and the `compile_fail` doctest struct literal gains the two fields first. Copied `tests/podman_args.rs` and the six `podman/*.args` golden files. `podman_args` 7 passed, `runner` 8 passed, all doctests pass. First gate failed on clippy `redundant_closure` (`.map(|f| OsString::from(f))` → `.map(OsString::from)`). `make gate` prints `gate: ok`. | ? | | M3b/09-brokerd-runner-config | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/config.rs`: the `Runner` struct (`podman`, `image`, `egress_network`, `output_cap`, `memory`, `pids`, `read_file_ms`, `write_file_ms`, `shell_ms`, `http_fetch_ms`) with `#[serde(deny_unknown_fields)]` and one private `default_…()` per defaulted field; `image` is required with no default. `Config` gained `runner: Option` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `/run/egress`. `load` runs, after the `ttl_ms` check and only when `runner` is `Some`, the four checks in order (first problem wins): image must be `@sha256:<64 lowercase hex>` via `rsplit_once("@sha256:")` with a non-empty name and exactly 64 `0-9a-f`, memory must be digits then one of b/k/m/g (`valid_memory`), `egress_network`/`podman` non-empty, and the six non-negative fields checked for zero in order — each returns `ConfigError::Invalid`. `Config::parse` runs none of them. Copied `tests/config_runner.rs`, the five `runner_*.toml` fixtures, and the new `support/rig.rs`, which builds `Config` with `runner: None`. All brokerd tests pass; `config` and `config_runner` each 7 passed. First gate failed on clippy `incompatible_msrv`: `PathBuf::is_empty()` is stable since 1.98 but the MSRV is 1.95, fixed with `as_os_str().is_empty()` (the pattern the file already used for the socket paths). `make gate` prints `gate: ok`. | ? | | M3b/08-toolkit-egress-proxy | 2026-09-23 | done | 2 | fail | none | Copied `docs/plans/M3b/files/crates/toolkit/tests/egress.rs` to `crates/toolkit/tests/`. Wrote `crates/toolkit/src/egress.rs`: the reply-code and timeout constants; `Allow { patterns: Vec }` with `parse` splitting on ',' and rejecting any piece that fails `proto::hosts::valid_host_pattern` (an empty piece like `","`/`"x,"`/`""` errors) and `permits` = `valid_host(host) && any host_matches`; the `Dial` trait and `SystemDial` (`to_socket_addrs()?.collect()` and `connect_timeout`); `Proxy { allow, dial: Arc, handshake }` with `new`/`with_handshake_timeout`/`serve`/`handle`. `handle` shares one deadline across the whole handshake: `read_n` reads exactly the byte count the protocol gives, setting the read timeout to `deadline.checked_duration_since(now)` before each read and returning `None` (stop, no reply) on no time left, a timeout, a failed read, or 0 bytes — so a client trickling one byte per 100 ms is still cut at the deadline. The 11 exits are in order (egress.rs:133 version, :144 methods 0, :154 version/reserved, :160 command, :164 kind without reading the address, :172 zero length, :187 non-UTF-8, :190 port/host, :195 resolve / :203 no public addr, :208 connect, :211 success reply); step 9 takes the first address where `crate::addr::is_public` is true via `.find`, never trying the skipped non-public ones. `serve` is a single-threaded accept loop with an `AtomicUsize` count: over the limit is dropped at once, otherwise a `std::thread::Builder` thread handles it and uncounts itself on return, and `serve` never joins (so the second client is not starved). After the handshake the read timeout is cleared and two `try_clone`'d threads copy both ways with a half-close each way, then join. `main.rs` gained the `egress-proxy --socket --allow ` form before the tool form via `std::env::args_os().skip(1)` (without `skip(1)` the first element is the program path and the form never matches); `Allow::parse`/bind (no removal first — a pre-existing file is a mistake)/`serve` errors print to stderr and exit 2. `lib.rs` gained `pub mod egress;`. First gate failed on clippy `question_mark` (accept loop → `stream?`) and `manual_contains` (`methods.contains(&0)`); fixed both and `make gate` prints `gate: ok`. 15 egress tests pass ten runs in a row. | ? |