use std::sync::Arc;
use crate::config::schema::NetworkMode;
use crate::config::schema::SandboxBackend;
use crate::config::schema::SandboxConfig;
use crate::config::size::parse_size;
use crate::log::Logger;
use crate::log::fields;
use crate::sandbox::SandboxHandle;
use crate::sandbox::backend::placed_prompt_path;
use crate::sandbox::backend::{
AGENT_HOME, AGENT_SESSIONS, AgentCommand, CapabilityReport, SESSION_LABEL, STATE_PATH,
SYSTEM_LABEL, SandboxLaunch, SandboxLaunchError, SandboxUnavailableError, WORKSPACE_PATH,
agent_command, sandbox_name,
};
use crate::sandbox::spawn::spawn_agent;
pub const RESTRICTED_NETWORK: &str = "pasta:--map-host-loopback,none,--map-guest-addr,none";
pub fn podman_args(config: &SandboxConfig, launch: &SandboxLaunch) -> Vec<String> {
let name = sandbox_name(&launch.session_id);
let network = if config.network == NetworkMode::None {
"none"
} else {
RESTRICTED_NETWORK
};
let mut args: Vec<String> = [
"run",
"--interactive",
"--rm",
"--name",
&name,
"--label",
&format!("{SYSTEM_LABEL}=true"),
"--label",
&format!("{SESSION_LABEL}={}", launch.session_id),
"--userns=keep-id",
"--read-only",
"--tmpfs",
"/tmp",
"--volume",
&format!("{}:{WORKSPACE_PATH}:rw,Z", launch.project_path),
"--volume",
&format!("{}:{STATE_PATH}:rw,Z", launch.state_dir),
"--workdir",
WORKSPACE_PATH,
"--cap-drop=ALL",
"--security-opt",
"no-new-privileges",
&format!("--network={network}"),
"--memory",
&config.memory,
"--cpus",
]
.iter()
.map(std::string::ToString::to_string)
.collect();
args.push(format!("{}", config.cpus));
args.push("--pids-limit".to_owned());
args.push(config.pids.to_string());
args.push("--ulimit".to_owned());
args.push(format!(
"fsize={}",
parse_size(&config.file_max).unwrap_or(0)
));
args.push("--env".to_owned());
args.push(format!("HOME={AGENT_HOME}"));
if let Some(env) = &config.env {
for (key, value) in env {
args.push("--env".to_owned());
args.push(format!("{key}={value}"));
}
}
for (key, value) in &launch.env {
args.push("--env".to_owned());
args.push(format!("{key}={value}"));
}
args.push(config.image.clone());
args.extend(agent_command(&AgentCommand {
session_dir: AGENT_SESSIONS.to_owned(),
provider: launch.provider.clone(),
model: launch.model.clone(),
system_prompt_path: placed_prompt_path(launch.system_prompt_path.as_ref()),
resume: launch.resume,
}));
args
}
pub struct PodmanSandbox {
config: SandboxConfig,
log: Logger,
run: super::Run,
}
pub fn run_podman(args: Vec<String>, cwd: Option<String>) -> super::RunFuture<super::RunResult> {
Box::pin(async move {
let mut command = tokio::process::Command::new("podman");
command
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output().await?;
Ok(super::RunResult {
code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
})
}
pub fn run_podman_arc() -> super::Run {
Arc::new(run_podman)
}
impl PodmanSandbox {
pub fn new(config: SandboxConfig, log: Logger, run: super::Run) -> Self {
Self { config, log, run }
}
async fn call(&self, args: &[&str]) -> super::RunResult {
let args: Vec<String> = args.iter().map(std::string::ToString::to_string).collect();
(self.run)(args, None)
.await
.unwrap_or_else(|error| super::RunResult {
code: -1,
stdout: String::new(),
stderr: error.to_string(),
})
}
pub async fn probe(&self) -> Result<CapabilityReport, SandboxUnavailableError> {
let mut reasons = Vec::new();
let info = self
.call(&["info", "--format", "{{.Host.Security.Rootless}}"])
.await;
if info.code != 0 {
reasons.push("podman is not installed, or `podman info` failed".to_owned());
} else if info.stdout.trim() != "true" {
reasons.push("podman is not running rootless, which this backend requires".to_owned());
}
if reasons.is_empty() {
let image = self.call(&["image", "exists", &self.config.image]).await;
if image.code != 0 {
reasons.push(format!(
"the configured image {} is not present; build it before starting",
self.config.image
));
}
}
if !reasons.is_empty() {
return Err(SandboxUnavailableError {
backend: SandboxBackend::Podman,
reasons,
});
}
let notes = vec![
format!(
"sessions run in rootless containers from {}",
self.config.image
),
if self.config.network == NetworkMode::None {
"sessions have no network, so the agent cannot reach a model provider".to_owned()
} else {
"sessions reach the model provider, and host services are unreachable".to_owned()
},
format!(
"limits per session: memory {}, cpus {}, pids {}",
self.config.memory, self.config.cpus, self.config.pids
),
format!(
"no single file may exceed {}, set as an fsize ulimit on the container",
self.config.file_max
),
format!(
"a session is stopped once it has written {}, which is measured rather than \
enforced",
self.config.disk
),
];
Ok(CapabilityReport {
backend: SandboxBackend::Podman,
gaps: Vec::new(),
notes,
})
}
pub fn launch(&self, launch: &SandboxLaunch) -> Result<SandboxHandle, SandboxLaunchError> {
let name = sandbox_name(&launch.session_id);
let args = podman_args(&self.config, launch);
let spawned = spawn_agent("podman", &args, None, None)
.map_err(|error| SandboxLaunchError(error.to_string()))?;
let spawned = Arc::new(spawned);
self.log.info(
"container started",
&fields([
("session", launch.session_id.as_str().into()),
("name", name.as_str().into()),
]),
);
Ok(SandboxHandle::Podman(Box::new(super::PodmanStop {
session_id: launch.session_id.clone(),
name,
project_path: launch.project_path.clone(),
spawned,
run: Arc::clone(&self.run),
grace_ms: self.config.grace_period_ms,
log: self.log.clone(),
stopped: std::sync::atomic::AtomicBool::new(false),
})))
}
pub async fn list_orphans(&self) -> Result<Vec<String>, SandboxLaunchError> {
let result = self
.call(&[
"ps",
"--all",
"--filter",
&format!("label={SYSTEM_LABEL}=true"),
"--format",
"{{.Names}}",
])
.await;
if result.code != 0 {
return Err(SandboxLaunchError(format!(
"could not list containers: {}",
result.stderr.trim()
)));
}
Ok(result
.stdout
.split('\n')
.map(|line| line.trim().to_owned())
.filter(|line| !line.is_empty())
.collect())
}
pub async fn remove_orphans(&self, names: &[String]) -> usize {
let mut removed = 0;
for name in names {
let result = self.call(&["rm", "--force", name]).await;
if result.code == 0 {
removed += 1;
} else {
self.log.warn(
"could not remove a leftover container",
&fields([("name", name.as_str().into())]),
);
}
}
removed
}
}
#[cfg(test)]
mod tests;