Specify and plan M3b: the runner and the tools
A draft spec for the owner's review and 13 offline tasks with their given tests: shared tool arguments and host rules in proto, the sealed fetch target (M3a finding 14), the toolkit tools and SOCKS5 egress proxy, and brokerd's [runner], podman argument lists, runtime and proxy lifecycle. Each task's tests were run against a reference at that task's end state (560 to 638 tests, clippy clean); the reference is not in the repository. Adds the runner-unavailable runbook entry and tip T23 (ETXTBSY in script tests). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<build::Build>) -> 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<build::Build> {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user