Files
boxmaker/crates/brokerd/src/runner.rs
T
kyleandClaude Opus 5.5 2b3ae080b4 proto becomes bxcore, the one shared crate (M5b task 00)
Done the way the owner asked and the spike mapped: the crate and the
manifests renamed, then what the build reported fixed. Layers: 138
"proto unresolved" errors across 169 files; cargo fmt reordering
imports; six fixture paths built as strings in brokerd's and bxctl's
tests, which the compiler cannot see, and comments naming
crates/proto/; the crate-dependency check and its self-test. One more
the spike did not reach: a `\nproto =` key inside a printf format in
the self-test, which a word-boundary replacement skips because `n` and
`p` are both word characters.

make gate: gate: ok, 944 tests; no new entry in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:48:38 -07:00

176 lines
5.6 KiB
Rust

//! The runner seam: an allowed call becomes a `RunSpec` and is handed to a `Runtime`.
//!
//! A `RunSpec` can only be built here, from a `Decision`, so a runtime never sees a call policy
//! did not allow.
//!
//! ```compile_fail
//! let _ = brokerd::runner::RunSpec {
//! session: bxcore::SessionId::new("s1").unwrap(),
//! call: bxcore::CallId(1),
//! tool: brokerd::args::ToolName::Shell,
//! arguments: todo!(),
//! mounts: Vec::new(),
//! egress: None,
//! };
//! ```
//!
//! ```
//! fn tool_of(spec: &brokerd::runner::RunSpec) -> brokerd::args::ToolName {
//! spec.tool()
//! }
//! ```
use crate::args::{ToolArgs, ToolName};
use crate::policy::{Decision, Label, SessionState};
use bxcore::{CallId, SessionId, ToolResponse};
/// A directory mounted for one call: a path and whether the runtime may write to it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub path: String,
pub writable: bool,
}
/// What one call was turned into before it reached a `Runtime`: the session and call it is, the
/// tool, its arguments, the directories mounted and the hosts it may reach. Built only here, from a
/// `Decision`.
#[derive(Debug)]
pub struct RunSpec {
session: SessionId,
call: CallId,
tool: ToolName,
arguments: ToolArgs,
mounts: Vec<Mount>,
egress: Option<Vec<String>>,
state: SessionState,
}
impl RunSpec {
pub fn session(&self) -> &SessionId {
&self.session
}
pub fn call(&self) -> CallId {
self.call
}
pub fn tool(&self) -> ToolName {
self.tool
}
pub fn arguments(&self) -> &ToolArgs {
&self.arguments
}
pub fn mounts(&self) -> &[Mount] {
&self.mounts
}
pub fn egress(&self) -> Option<&[String]> {
self.egress.as_deref()
}
/// The session's state when the call was decided.
pub fn state(&self) -> SessionState {
self.state
}
}
/// A `Runtime`'s answer to one call. Its text reaches the model as a `failed` result labelled
/// public and trusted, so a runtime may only put a fixed sentence in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunError {
Failed(String),
Unavailable(String),
}
/// What a `Runtime` produced for one call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunOutput {
pub content: String,
pub truncated: bool,
}
/// Runs one call. `Send + Sync` so one `Runtime` can serve several brokers at once.
pub trait Runtime: Send + Sync {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
/// `run`, and a label for what came back when the runtime knows one (`memoryd` does: a
/// `recall` hit from an untrusted session). The runner keeps the stricter of this and the
/// grant's label. Container runtimes know none.
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
self.run(spec).map(|output| (output, None))
}
}
/// The production runtime for M3a. It runs nothing; every call is `Unavailable`.
pub struct Refusing;
impl Runtime for Refusing {
fn run(&self, _spec: &RunSpec) -> Result<RunOutput, RunError> {
Err(RunError::Unavailable(REFUSING.to_string()))
}
}
/// The sentence `Refusing` puts in every `Unavailable`: fixed, because it reaches the model.
pub const REFUSING: &str = "no tool runner is configured: brokerd.toml has no [runner] section";
/// Turn an allowed call into a `RunSpec`, run it, and answer. A `RunError`'s text reaches the
/// model unchanged, so a runtime may only put a fixed sentence in it.
pub fn run(decision: Decision, runtime: &dyn Runtime) -> bxcore::ToolResponse {
let label = decision.label();
let args = decision.args();
let (mounts, egress) = match args {
ToolArgs::ReadFile { .. } => (matched_mount(decision.matched_path(), false), None),
ToolArgs::WriteFile { .. } => (matched_mount(decision.matched_path(), true), None),
ToolArgs::Shell { .. } => (
decision
.paths()
.iter()
.map(|path| Mount {
path: path.clone(),
writable: true,
})
.collect(),
None,
),
ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())),
ToolArgs::Recall(_) | ToolArgs::Memory(_) => (Vec::new(), None),
};
let spec = RunSpec {
session: decision.request().session.clone(),
call: decision.request().call,
tool: args.tool(),
arguments: args.clone(),
mounts,
egress,
state: decision.state(),
};
match runtime.run_labelled(&spec) {
Ok((output, found)) => {
// Labels only get stricter: the higher class, and untrusted if either says so.
let (class, untrusted) = match found {
Some(found) => (
label.class.max(found.class),
label.untrusted || found.untrusted,
),
None => (label.class, label.untrusted),
};
ToolResponse::Result {
content: output.content,
class,
untrusted,
truncated: output.truncated,
}
}
Err(RunError::Failed(message) | RunError::Unavailable(message)) => {
ToolResponse::Failed { message }
}
}
}
/// The matched path as one mount, or none when the call matched no path.
fn matched_mount(path: Option<&str>, writable: bool) -> Vec<Mount> {
match path {
Some(at) => vec![Mount {
path: at.to_string(),
writable,
}],
None => Vec::new(),
}
}