From 81ce5a3345f2e183b721a25db0a9418f0ec84c80 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Wed, 23 Sep 2026 01:07:21 -0700 Subject: [PATCH] brokerd: the [runner] section Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/brokerd/src/config.rs | 150 ++++++++++++++++++ crates/brokerd/tests/config_runner.rs | 140 ++++++++++++++++ .../tests/fixtures/config/runner_full.toml | 11 ++ .../tests/fixtures/config/runner_minimal.toml | 2 + .../fixtures/config/runner_no_image.toml | 2 + .../tests/fixtures/config/runner_tag.toml | 2 + .../fixtures/config/runner_unknown_key.toml | 3 + crates/brokerd/tests/support/rig.rs | 1 + docs/implementer-log.md | 1 + 9 files changed, 312 insertions(+) create mode 100644 crates/brokerd/tests/config_runner.rs create mode 100644 crates/brokerd/tests/fixtures/config/runner_full.toml create mode 100644 crates/brokerd/tests/fixtures/config/runner_minimal.toml create mode 100644 crates/brokerd/tests/fixtures/config/runner_no_image.toml create mode 100644 crates/brokerd/tests/fixtures/config/runner_tag.toml create mode 100644 crates/brokerd/tests/fixtures/config/runner_unknown_key.toml diff --git a/crates/brokerd/src/config.rs b/crates/brokerd/src/config.rs index 9d51e3e..f622d96 100644 --- a/crates/brokerd/src/config.rs +++ b/crates/brokerd/src/config.rs @@ -45,6 +45,79 @@ impl Default for Approvals { } } +fn default_podman() -> PathBuf { + PathBuf::from("podman") +} +fn default_egress_network() -> String { + "pasta".to_string() +} +fn default_output_cap() -> u64 { + 262_144 +} +fn default_memory() -> String { + "512m".to_string() +} +fn default_pids() -> u32 { + 128 +} +fn default_read_file_ms() -> u64 { + 30_000 +} +fn default_write_file_ms() -> u64 { + 30_000 +} +fn default_shell_ms() -> u64 { + 100_000 +} +fn default_http_fetch_ms() -> u64 { + 60_000 +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Runner { + #[serde(default = "default_podman")] + pub podman: PathBuf, + pub image: String, + #[serde(default = "default_egress_network")] + pub egress_network: String, + #[serde(default = "default_output_cap")] + pub output_cap: u64, + #[serde(default = "default_memory")] + pub memory: String, + #[serde(default = "default_pids")] + pub pids: u32, + #[serde(default = "default_read_file_ms")] + pub read_file_ms: u64, + #[serde(default = "default_write_file_ms")] + pub write_file_ms: u64, + #[serde(default = "default_shell_ms")] + pub shell_ms: u64, + #[serde(default = "default_http_fetch_ms")] + pub http_fetch_ms: u64, +} + +impl Runner { + /// The time limit for one call of `tool`: the matching `_ms` field. + pub fn time_limit(&self, tool: crate::args::ToolName) -> std::time::Duration { + let ms = match tool { + crate::args::ToolName::ReadFile => self.read_file_ms, + crate::args::ToolName::WriteFile => self.write_file_ms, + crate::args::ToolName::Shell => self.shell_ms, + crate::args::ToolName::HttpFetch => self.http_fetch_ms, + }; + std::time::Duration::from_millis(ms) + } +} + +/// `memory` is well formed if it is one or more digits followed by `b`, `k`, `m` or `g`. +fn valid_memory(memory: &str) -> bool { + let bytes = memory.as_bytes(); + bytes.len() >= 2 + && matches!(bytes[bytes.len() - 1], b'b' | b'k' | b'm' | b'g') + && bytes[..bytes.len() - 1].iter().all(|&c| c.is_ascii_digit()) +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Config { @@ -54,6 +127,8 @@ pub struct Config { pub sockets: Sockets, #[serde(default)] pub approvals: Approvals, + #[serde(default)] + pub runner: Option, } /// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame. @@ -99,6 +174,78 @@ impl Config { ), )); } + if let Some(runner) = &config.runner { + let where_image = format!( + "[runner] image is {}; it must be named by digest: @sha256:<64 hex digits>", + runner.image + ); + let (name, hex) = match runner.image.rsplit_once("@sha256:") { + Some(pair) => pair, + None => { + return Err(ConfigError::Invalid( + path.to_path_buf(), + where_image.clone(), + )); + } + }; + if name.is_empty() + || hex.len() != 64 + || !hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return Err(ConfigError::Invalid(path.to_path_buf(), where_image)); + } + if !valid_memory(&runner.memory) { + return Err(ConfigError::Invalid( + path.to_path_buf(), + format!( + "[runner] memory is {}; it must be a number and one of b, k, m, g", + runner.memory + ), + )); + } + if runner.egress_network.is_empty() || runner.podman.as_os_str().is_empty() { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] podman and egress_network must not be empty".to_string(), + )); + } + if runner.output_cap == 0 { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] output_cap must be at least 1".to_string(), + )); + } + if runner.pids == 0 { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] pids must be at least 1".to_string(), + )); + } + if runner.read_file_ms == 0 { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] read_file_ms must be at least 1".to_string(), + )); + } + if runner.write_file_ms == 0 { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] write_file_ms must be at least 1".to_string(), + )); + } + if runner.shell_ms == 0 { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] shell_ms must be at least 1".to_string(), + )); + } + if runner.http_fetch_ms == 0 { + return Err(ConfigError::Invalid( + path.to_path_buf(), + "[runner] http_fetch_ms must be at least 1".to_string(), + )); + } + } Ok(config) } pub fn broker_socket(&self) -> PathBuf { @@ -121,4 +268,7 @@ impl Config { pub fn state_dir(&self) -> PathBuf { self.paths.home.join("broker/sessions") } + pub fn egress_dir(&self) -> PathBuf { + self.paths.home.join("run/egress") + } } diff --git a/crates/brokerd/tests/config_runner.rs b/crates/brokerd/tests/config_runner.rs new file mode 100644 index 0000000..a437cb1 --- /dev/null +++ b/crates/brokerd/tests/config_runner.rs @@ -0,0 +1,140 @@ +//! `[runner]` in `brokerd.toml` (M3b spec, section 6). Do not edit. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use brokerd::args::ToolName; +use brokerd::config::{Config, ConfigError}; + +const DIGEST: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config") + .join(name) +} + +fn invalid(name: &str) -> String { + match Config::load(&fixture(name)) { + Err(ConfigError::Invalid(_, why)) => why, + other => panic!("{name}: expected Invalid, got {other:?}"), + } +} + +#[test] +fn without_a_runner_section_there_is_no_runner() { + assert_eq!(Config::load(&fixture("empty.toml")).unwrap().runner, None); +} + +#[test] +fn a_runner_with_only_an_image_gets_every_default() { + let r = Config::load(&fixture("runner_minimal.toml")) + .unwrap() + .runner + .unwrap(); + assert_eq!(r.podman, PathBuf::from("podman")); + assert_eq!(r.image, DIGEST); + assert_eq!(r.egress_network, "pasta"); + assert_eq!(r.output_cap, 262_144); + assert_eq!(r.memory, "512m"); + assert_eq!(r.pids, 128); + assert_eq!( + r.time_limit(ToolName::ReadFile), + Duration::from_millis(30_000) + ); + assert_eq!( + r.time_limit(ToolName::WriteFile), + Duration::from_millis(30_000) + ); + assert_eq!( + r.time_limit(ToolName::Shell), + Duration::from_millis(100_000) + ); + assert_eq!( + r.time_limit(ToolName::HttpFetch), + Duration::from_millis(60_000) + ); +} + +#[test] +fn every_runner_value_can_be_set() { + let r = Config::load(&fixture("runner_full.toml")) + .unwrap() + .runner + .unwrap(); + assert_eq!(r.podman, PathBuf::from("/run/current-system/sw/bin/podman")); + assert_eq!(r.egress_network, "slirp4netns"); + assert_eq!((r.output_cap, r.memory.as_str(), r.pids), (1000, "1g", 64)); + let limits: Vec = ToolName::ALL.iter().map(|t| r.time_limit(*t)).collect(); + assert_eq!(limits, [1, 2, 3, 4].map(Duration::from_millis)); +} + +#[test] +fn a_runner_needs_an_image_named_by_digest() { + assert!(matches!( + Config::load(&fixture("runner_no_image.toml")), + Err(ConfigError::Parse(..)) + )); + assert!(invalid("runner_tag.toml").contains("by digest")); +} + +#[test] +fn unknown_runner_keys_are_errors() { + assert!(matches!( + Config::load(&fixture("runner_unknown_key.toml")), + Err(ConfigError::Parse(..)) + )); +} + +#[test] +fn bad_values_are_errors_that_name_them() { + let dir = std::env::temp_dir().join(format!("bx-runner-cfg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cases = [ + ( + format!("image = \"x@sha256:{}\"", "0".repeat(63)), + "by digest", + ), + ( + format!("image = \"x@sha256:{}\"", "A".repeat(64)), + "by digest", + ), + ( + format!("image = \"@sha256:{}\"", "0".repeat(64)), + "by digest", + ), + (format!("image = \"{DIGEST}\"\nmemory = \"lots\""), "memory"), + (format!("image = \"{DIGEST}\"\nmemory = \"512\""), "memory"), + (format!("image = \"{DIGEST}\"\nmemory = \"m\""), "memory"), + ( + format!("image = \"{DIGEST}\"\noutput_cap = 0"), + "output_cap", + ), + (format!("image = \"{DIGEST}\"\npids = 0"), "pids"), + (format!("image = \"{DIGEST}\"\nshell_ms = 0"), "shell_ms"), + ( + format!("image = \"{DIGEST}\"\nhttp_fetch_ms = 0"), + "http_fetch_ms", + ), + ( + format!("image = \"{DIGEST}\"\negress_network = \"\""), + "egress_network", + ), + (format!("image = \"{DIGEST}\"\npodman = \"\""), "podman"), + ]; + for (n, (body, word)) in cases.iter().enumerate() { + let path = dir.join(format!("c{n}.toml")); + std::fs::write(&path, format!("[runner]\n{body}\n")).unwrap(); + match Config::load(&path) { + Err(ConfigError::Invalid(_, why)) => assert!(why.contains(word), "{body}: {why}"), + other => panic!("{body}: expected Invalid, got {other:?}"), + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_egress_directory_is_under_home() { + let c = Config::parse("[paths]\nhome = \"/h\"\n").unwrap(); + assert_eq!(c.egress_dir(), PathBuf::from("/h/run/egress")); +} diff --git a/crates/brokerd/tests/fixtures/config/runner_full.toml b/crates/brokerd/tests/fixtures/config/runner_full.toml new file mode 100644 index 0000000..af8d8ae --- /dev/null +++ b/crates/brokerd/tests/fixtures/config/runner_full.toml @@ -0,0 +1,11 @@ +[runner] +podman = "/run/current-system/sw/bin/podman" +image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +egress_network = "slirp4netns" +output_cap = 1000 +memory = "1g" +pids = 64 +read_file_ms = 1 +write_file_ms = 2 +shell_ms = 3 +http_fetch_ms = 4 diff --git a/crates/brokerd/tests/fixtures/config/runner_minimal.toml b/crates/brokerd/tests/fixtures/config/runner_minimal.toml new file mode 100644 index 0000000..bd96d73 --- /dev/null +++ b/crates/brokerd/tests/fixtures/config/runner_minimal.toml @@ -0,0 +1,2 @@ +[runner] +image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" diff --git a/crates/brokerd/tests/fixtures/config/runner_no_image.toml b/crates/brokerd/tests/fixtures/config/runner_no_image.toml new file mode 100644 index 0000000..8be1269 --- /dev/null +++ b/crates/brokerd/tests/fixtures/config/runner_no_image.toml @@ -0,0 +1,2 @@ +[runner] +podman = "podman" diff --git a/crates/brokerd/tests/fixtures/config/runner_tag.toml b/crates/brokerd/tests/fixtures/config/runner_tag.toml new file mode 100644 index 0000000..230ff48 --- /dev/null +++ b/crates/brokerd/tests/fixtures/config/runner_tag.toml @@ -0,0 +1,2 @@ +[runner] +image = "localhost/boxmaker-tools:latest" diff --git a/crates/brokerd/tests/fixtures/config/runner_unknown_key.toml b/crates/brokerd/tests/fixtures/config/runner_unknown_key.toml new file mode 100644 index 0000000..c304e8f --- /dev/null +++ b/crates/brokerd/tests/fixtures/config/runner_unknown_key.toml @@ -0,0 +1,3 @@ +[runner] +image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +network = "host" diff --git a/crates/brokerd/tests/support/rig.rs b/crates/brokerd/tests/support/rig.rs index ddbc24d..36de54e 100644 --- a/crates/brokerd/tests/support/rig.rs +++ b/crates/brokerd/tests/support/rig.rs @@ -40,6 +40,7 @@ impl Rig { }, sockets: Sockets::default(), approvals: Approvals { ttl_ms }, + runner: None, }; Rig { dir, diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 1878e5a..6be3f9d 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/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. | ? | | M3b/07-toolkit-addr | 2026-09-23 | done | 1 | pass | none | Wrote `crates/toolkit/src/addr.rs`: `is_public(ip)` matches on `IpAddr` and dispatches to `is_public_v4`/`is_public_v6`. The IPv4 function checks the 13 refused ranges in table order with early returns (lines 16-52) then returns `true`. The IPv6 function checks the two "judge as IPv4" rows first — `is_ipv4_mapped` for `::ffff:0:0/96` (lines 60-63) and `is_nat64` for `64:ff9b::/96` (lines 64-67) — reconstructing the last 32 bits as an `Ipv4Addr` via `(u32::from(s[6]) << 16) | u32::from(s[7])` with no `as` casts, then judging it through `is_public_v4`; the remaining rows `::/96` (line 68), `fc00::/7` (71), `fe80::/10` (74), `ff00::/8` (77) and `2001:db8::/32` (80) follow. `cargo fmt --all` first. Added `pub mod addr;` to `lib.rs` in alphabetical position (before `fetch`). All 4 addr tests pass; `make gate` prints `gate: ok` on the first run. | ? | | M3b/06-toolkit-fetch | 2026-09-23 | done | 3 | fail | none | Wrote `crates/toolkit/src/fetch.rs`: `CURL=/bin/curl`, `PROXY=socks5h://localhost/run/egress/egress.sock`, `CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`, `MAX_STDERR=65536`. `curl_args(url)` returns the 21 fixed strings in order ending in `--url `. `fetch` calls `fetch_with(Path::new(CURL), args)`. `fetch_with`: spawn curl with `curl_args(&args.url)`, null stdin, stdout/stderr piped (spawn failure -> `"http_fetch: cannot start {}: {e}"`); read stderr on its own thread via the `read_capped` helper (8 KiB buffer, keeps the first 64 KiB and drains/discards the rest so curl never blocks on a full pipe), main thread reads stdout to the end; `wait()` then join the thread (a panicked reader falls back to empty stderr); exit 0 -> `done(from_utf8_lossy(body))` with the `--write-out` status line already in it, else the first non-blank trimmed stderr line as `why` or `"curl exited {code}"` or `"curl was killed"` when there is no code, wrapped as `"http_fetch: {url}: {why}"`, and a wait failure -> `"http_fetch: cannot wait for curl: {e}"`. `lib.rs` gained `pub mod fetch` (between `files` and `input`) and an `"http_fetch"` arm parsing `HttpFetchArgs` like the other three tools. First gate run failed on a rustfmt line-wrap of the spawn-error return; second on clippy `manual_unwrap_or_default` for the stderr-join match, switched to `unwrap_or_default()`. 7 fetch tests pass ten runs; `make gate` prints `gate: ok`. | ? |