Replace the runner stub with the Runtime seam and RunSpec

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-19 16:43:56 -07:00
parent ded7eb8c50
commit 57734ebb9a
4 changed files with 378 additions and 6 deletions
+132 -6
View File
@@ -1,12 +1,138 @@
//! Tool runner stub. Real tool execution arrives in M3.
//! 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 {
//! 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;
use proto::ToolResponse;
/// Takes the Decision by value, so one decision cannot run a tool twice.
pub fn run(decision: Decision) -> ToolResponse {
let _ = decision;
ToolResponse::Failed {
message: "no tool runner until M3".to_string(),
/// 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 tool, its arguments, the
/// directories mounted and the hosts it may reach. Built only here, from a `Decision`.
#[derive(Debug)]
pub struct RunSpec {
tool: ToolName,
arguments: ToolArgs,
mounts: Vec<Mount>,
egress: Option<Vec<String>>,
}
impl RunSpec {
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()
}
}
/// 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>;
}
/// 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 = "the runner arrives in M3b";
/// 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) -> proto::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())),
};
let spec = RunSpec {
tool: args.tool(),
arguments: args.clone(),
mounts,
egress,
};
match runtime.run(&spec) {
Ok(output) => ToolResponse::Result {
content: output.content,
class: label.class,
untrusted: label.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(),
}
}