brokerd: the [runner] section

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 01:07:21 -07:00
parent 95872d94b8
commit 81ce5a3345
9 changed files with 312 additions and 0 deletions
+150
View File
@@ -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<Runner>,
}
/// 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: <name>@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")
}
}
+140
View File
@@ -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<Duration> = 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"));
}
+11
View File
@@ -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
@@ -0,0 +1,2 @@
[runner]
image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -0,0 +1,2 @@
[runner]
podman = "podman"
+2
View File
@@ -0,0 +1,2 @@
[runner]
image = "localhost/boxmaker-tools:latest"
@@ -0,0 +1,3 @@
[runner]
image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
network = "host"
+1
View File
@@ -40,6 +40,7 @@ impl Rig {
},
sockets: Sockets::default(),
approvals: Approvals { ttl_ms },
runner: None,
};
Rig {
dir,