use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
use kernel::{
AxCode, AxError, CostTier, Effect, EnvVarName, ExecArm, Payload, RenderIntent, RunId, Temporal,
Tool, ToolCall, ToolMeta, ToolName, ToolOutcome,
};
use serde_json::{Map, Value};
use crate::backlog::{Backlog, Exit, Started};
use crate::sandbox::{Fuel, Mount, Sandbox, SandboxExit, SandboxJob};
mod confinement;
mod yielding;
pub use confinement::{
Assurances, Confined, Confinement, Guarantee, Kept, Missing, Offerings, Placed, Placement,
parse_placement,
};
const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
pub struct ExecSetup {
pub workdir: PathBuf,
pub mounts: Vec<Mount>,
pub python_wasm: Option<PathBuf>,
pub shell: Option<PathBuf>,
pub fuel: Fuel,
pub env_passthrough: Vec<EnvVarName>,
pub domain: kernel::Address,
pub run: RunId,
pub limit: kernel::WriteLimit,
}
pub struct ExecTool {
setup: ExecSetup,
sandbox: Mutex<Box<dyn Sandbox>>,
backlog: Backlog,
confinement: Mutex<Confined>,
meta: ToolMeta,
}
impl ExecTool {
pub fn new(
setup: ExecSetup,
sandbox: Box<dyn Sandbox>,
backlog: Backlog,
) -> Result<ExecTool, AxError> {
let mut params = Map::new();
params.insert("type".to_owned(), Value::String("object".to_owned()));
let mut properties = Map::new();
let mut arm = Map::new();
arm.insert("type".to_owned(), Value::String("object".to_owned()));
arm.insert(
"description".to_owned(),
Value::String(
"one of {program:{path,args}}, {python:{code}}, {shell:{text}}; write the \
Python as a short script against the standard library - pathlib, difflib, re, \
itertools, collections - with no classes, no exception handlers and no \
comments, and read the error when it fails"
.to_owned(),
),
);
properties.insert("arm".to_owned(), Value::Object(arm));
let mut placement = Map::new();
placement.insert("type".to_owned(), Value::String("string".to_owned()));
placement.insert(
"description".to_owned(),
Value::String(
"where the program or shell line runs: `sandbox` (the default) or \
`host`"
.to_owned(),
),
);
properties.insert("where".to_owned(), Value::Object(placement));
params.insert("properties".to_owned(), Value::Object(properties));
params.insert(
"required".to_owned(),
Value::Array(vec![Value::String("arm".to_owned())]),
);
let domain = setup.domain.clone();
let confinement = Confined::detect();
let disclosure = format!(
"Run a program, a Python snippet, or a shell line. A program or shell \
line runs in this machine's confinement, {}. Ask for `where: host` to \
run one outside it. Use `read` and `search` for what is already written here; a \
command that prints it comes back without the version `edit` guards on.{}",
confinement.statement(),
Placement::told_under(setup.limit)
);
Ok(ExecTool {
setup,
sandbox: Mutex::new(sandbox),
backlog,
confinement: Mutex::new(confinement),
meta: ToolMeta {
name: ToolName::parse("exec")?,
disclosure,
params: Payload::new(params)?,
effect: Effect::Write { domain },
cost_tier: CostTier::Heavy,
timeout: None,
render: RenderIntent::Terminal,
temporal: Temporal::Timestamped,
},
})
}
fn run_program(
&self,
path: &str,
args: &[String],
placement: Placement,
) -> Result<ToolOutcome, AxError> {
let mut command = std::process::Command::new(path);
command.current_dir(&self.setup.workdir).args(args);
let what = if args.is_empty() {
path.to_owned()
} else {
format!("{path} {}", args.join(" "))
};
self.through_the_backlog(command, what, "program", placement)
}
fn through_the_backlog(
&self,
command: std::process::Command,
what: String,
arm: &str,
placement: Placement,
) -> Result<ToolOutcome, AxError> {
let mut command = yielding::one_level_down(command)?;
let inherited = self.inherited_environment();
command.env_clear();
for (key, value) in &inherited {
command.env(key, value);
}
let (command, placed) = match placement {
Placement::Host => (command, None),
Placement::Sandbox => {
let (command, placed) =
held(&self.confinement)?.place(command, &self.setup.workdir)?;
(command, Some(placed))
}
};
let started = self
.backlog
.run(self.setup.run, &self.setup.domain, what, command)?;
let result = match started {
Started::Settled {
exit,
stdout,
stderr,
} => {
if let Some(placed) = placed {
held(&self.confinement)?.settled(placed);
}
settled(&stdout, &stderr, exit, arm)?
}
Started::Backgrounded { id, what } => {
if let Some(placed) = placed {
held(&self.confinement)?.handed(id, placed);
}
backgrounded(&id, &what, arm)?
}
};
with_environment(result, &inherited)
}
fn inherited_environment(&self) -> BTreeMap<String, String> {
let mut chosen = BTreeMap::new();
let declared = self
.setup
.env_passthrough
.iter()
.map(EnvVarName::as_str)
.chain(ENV_ALLOWLIST);
for key in declared {
if let Ok(value) = std::env::var(key) {
chosen.insert(key.to_owned(), value);
}
}
chosen
}
fn run_python(&self, code: &str) -> Result<ToolOutcome, AxError> {
let Some(wasm) = self.setup.python_wasm.clone() else {
return Err(AxError::failure(
AxCode::ToolUnavailable,
"run python",
"no CPython-WASI component is configured",
)
.with_recovery("use the program arm, or configure a CPython-WASI component"));
};
let job = SandboxJob {
wasm,
argv: vec!["python".to_owned(), "-c".to_owned(), code.to_owned()],
env: Vec::new(),
stdin: Vec::new(),
mounts: self.setup.mounts.clone(),
fuel: self.setup.fuel,
};
let result = held(&self.sandbox)?.run(&job)?;
let exit = match &result.exit {
SandboxExit::Success => Exit::Ended { code: 0 },
SandboxExit::Failure { code } => Exit::Ended {
code: i32::try_from(*code).unwrap_or(i32::MAX),
},
SandboxExit::FuelExhausted => {
return exceptional(&result.stdout, &result.stderr, "fuel_exhausted", None);
}
SandboxExit::Trap { message } => {
return exceptional(
&result.stdout,
&result.stderr,
"trap",
Some(message.clone()),
);
}
};
settled(
&String::from_utf8_lossy(&result.stdout),
&String::from_utf8_lossy(&result.stderr),
exit,
"python",
)
}
fn run_shell(&self, text: &str, placement: Placement) -> Result<ToolOutcome, AxError> {
let Some(shell) = &self.setup.shell else {
return Err(AxError::failure(
AxCode::ToolUnavailable,
"run shell",
"no shell interpreter was found",
)
.with_recovery("use the program arm with an explicit executable"));
};
let flag = if cfg!(windows) { "/C" } else { "-c" };
let mut command = std::process::Command::new(shell);
command.current_dir(&self.setup.workdir).arg(flag).arg(text);
self.through_the_backlog(command, text.to_owned(), "shell", placement)
}
}
mod outcome;
use outcome::{backgrounded, exceptional, settled, with_backlog, with_environment};
fn no_host_python() -> AxError {
AxError::failure(
AxCode::SandboxDenied,
"run python on the host",
"the python arm runs a wasip1 guest and has no host form",
)
.with_recovery(
"use `arm: program` with a host interpreter, or leave `where` out to run the \
configured component",
)
}
pub fn parse_arm(args: &Map<String, Value>) -> Result<ExecArm, AxError> {
let arm = args.get("arm").ok_or_else(|| {
AxError::failure(AxCode::InvalidArgs, "run exec", "missing `arm`").with_recovery(
"name the arm in the call: `program` with a path and args, `python` \
with code, or `shell` with one command line",
)
})?;
serde_json::from_value(arm.clone()).map_err(|err| {
AxError::failure(
AxCode::InvalidArgs,
"run exec",
format!("unrecognised arm: {err}"),
)
.with_recovery("use one of program, python, shell")
})
}
impl Tool for ExecTool {
fn meta(&self) -> &ToolMeta {
&self.meta
}
fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
if call.name != self.meta.name {
return Err(AxError::failure(
AxCode::InvalidArgs,
"run exec",
format!("call routed to the wrong tool: {}", call.name.as_str()),
)
.with_recovery(format!(
"call `{}`, the name this tool answers to",
self.meta.name.as_str()
)));
}
let placement = parse_placement(call.args.as_map())?.opened_by(&self.setup)?;
let answer = match parse_arm(call.args.as_map())? {
ExecArm::Program { path, args } => self.run_program(&path, &args, placement),
ExecArm::Python { code } => match placement {
Placement::Sandbox => self.run_python(&code),
Placement::Host => Err(no_host_python()),
},
ExecArm::Shell { text } => self.run_shell(&text, placement),
}?;
let finished = self.backlog.harvest(self.setup.run)?;
held(&self.confinement)?.reaped(&finished);
with_backlog(answer, finished)
}
}
fn held<T>(lock: &Mutex<T>) -> Result<MutexGuard<'_, T>, AxError> {
lock.lock().map_err(|_| {
AxError::failure(
AxCode::ToolUnavailable,
"run exec",
"a previous command left this tool locked when its thread died",
)
.with_recovery("dispatch the work again; the next run starts with a fresh exec tool")
})
}
impl Drop for ExecTool {
fn drop(&mut self) {
self.backlog.release(self.setup.run);
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests;