Files
boxmaker/docs/plans/M3a/10-brokerd-runner.md
T
kyleandClaude Opus 5 e3f37da232 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>
2026-09-18 23:45:43 -07:00

5.5 KiB

M3a task 10: the runner seam

Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop) Commit subject: Replace the runner stub with the Runtime seam and RunSpec

Goal

brokerd::runner is where an allowed call becomes something a runtime can run. run takes a Decision by value, builds a RunSpec from it (the tool, the typed arguments, the directories to mount, the hosts it may reach) and asks a Runtime. A RunSpec can only be built here, from a Decision, so a runtime never sees a call policy did not allow. M3a's production runtime, Refusing, runs nothing; M3b adds Podman.

Files

  • Copy: crates/brokerd/tests/runner.rs, crates/brokerd/tests/support/runtime.rs (support/build.rs is already there from task 07)
  • Modify: crates/brokerd/src/runner.rs (it holds the M1 stub; replace all of it), docs/implementer-log.md

lib.rs already has pub mod runner;. Nothing else calls the old run(decision).

Interfaces

pub const REFUSING: &str = "the runner arrives in M3b";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount { pub path: String, pub writable: bool }

#[derive(Debug)]
pub struct RunSpec {                 // fields private, in this order; no constructor, no Clone
    tool: ToolName,                  // crate::args::ToolName
    arguments: ToolArgs,             // crate::args::ToolArgs
    mounts: Vec<Mount>,
    egress: Option<Vec<String>>,     // None: no network at all
}
impl RunSpec {
    pub fn tool(&self) -> ToolName;
    pub fn arguments(&self) -> &ToolArgs;
    pub fn mounts(&self) -> &[Mount];
    pub fn egress(&self) -> Option<&[String]>;      // self.egress.as_deref()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunOutput { pub content: String, pub truncated: bool }

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunError { Failed(String), Unavailable(String) }

pub trait Runtime: Send + Sync {
    fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
}

pub struct Refusing;               // Runtime: always Err(RunError::Unavailable(REFUSING.to_string()))

pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse;

What Decision gives you (task 07): args() -> &ToolArgs (and args().tool() -> ToolName), matched_path() -> Option<&str>, paths() -> &[String], hosts() -> &[String], label() -> Label with class: DataClass and untrusted: bool.

What run puts in the spec

tool is decision.args().tool(); arguments is decision.args().clone().

ToolArgs variant mounts egress
ReadFile the matched path, writable: false (none if matched_path() is None) None
WriteFile the matched path, writable: true (none if None) None
Shell every path in decision.paths(), in order, each writable: true None
HttpFetch none Some(decision.hosts().to_vec())

Only HttpFetch has network. Write the table as one match on the ToolArgs variant, with no _ arm.

What run answers

  1. Take let label = decision.label(); before building the spec.
  2. runtime.run(&spec) is Ok(output)ToolResponse::Result { content: output.content, class: label.class, untrusted: label.untrusted, truncated: output.truncated }.
  3. Err(RunError::Failed(m)) or Err(RunError::Unavailable(m))ToolResponse::Failed { message: m }, unchanged.

There is no other exit. A RunError's text reaches the model as a failed result labelled public and trusted, so a runtime may only put a fixed sentence in it; say so in its doc comment.

The doctests

Put these two in runner.rs's module doc comment (//!), as task 07 did for Decision. The first must fail to compile because the fields are private (todo!() fits any type, so it would compile if they were public); the second proves the getters are public:

//! ```compile_fail
//! let _ = brokerd::runner::RunSpec {
//!     tool: brokerd::args::ToolName::Shell,
//!     arguments: todo!(),
//!     mounts: Vec::new(),
//!     egress: None,
//! };
//! ```
//!
//! ```
//! fn tool_of(spec: &brokerd::runner::RunSpec) -> brokerd::args::ToolName {
//!     spec.tool()
//! }
//! ```

Steps

  • 1. Copy. git switch m3a, then cp docs/plans/M3a/files/crates/brokerd/tests/runner.rs crates/brokerd/tests/ and cp docs/plans/M3a/files/crates/brokerd/tests/support/runtime.rs crates/brokerd/tests/support/
  • 2. See the test fail. cargo test -p brokerd --test runner. Expected: it does not compile.
  • 3. Write runner.rs. Run cargo fmt --all.
  • 4. See the tests pass. cargo test -p brokerd --test runner. Expected: 8 passed. cargo test -p brokerd --doc: 9 doctests in all pass (7 from task 07, 2 new; cargo may print them on two test result lines).
  • 5. Prove the doctest has teeth. Make the four fields of RunSpec pub, run cargo test -p brokerd --doc, and see one doctest fail ("test compiled"). Make them private again and see it pass. Say in the log's Notes that you did this.
  • 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

  • runner reports 8 passed, the doctests pass, step 5 was done and logged, and make gate prints gate: ok.

Stop and report if

  • A test needs RunSpec to have a public constructor or to be Clone.
  • A test expects a runtime's error text to be changed, prefixed or labelled other than failed.