zeroclaw/src/runtime/docker.rs

275 lines
8 KiB
Rust

use super::traits::RuntimeAdapter;
use crate::config::DockerRuntimeConfig;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
/// Docker runtime with lightweight container isolation.
#[derive(Debug, Clone)]
pub struct DockerRuntime {
config: DockerRuntimeConfig,
}
impl DockerRuntime {
pub fn new(config: DockerRuntimeConfig) -> Self {
Self { config }
}
fn workspace_mount_path(&self, workspace_dir: &Path) -> Result<PathBuf> {
let resolved = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
if !resolved.is_absolute() {
anyhow::bail!(
"Docker runtime requires an absolute workspace path, got: {}",
resolved.display()
);
}
if resolved == Path::new("/") {
anyhow::bail!("Refusing to mount filesystem root (/) into docker runtime");
}
if self.config.allowed_workspace_roots.is_empty() {
return Ok(resolved);
}
let allowed = self.config.allowed_workspace_roots.iter().any(|root| {
let root_path = Path::new(root)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(root));
resolved.starts_with(root_path)
});
if !allowed {
anyhow::bail!(
"Workspace path {} is not in runtime.docker.allowed_workspace_roots",
resolved.display()
);
}
Ok(resolved)
}
}
impl RuntimeAdapter for DockerRuntime {
fn name(&self) -> &str {
"docker"
}
fn has_shell_access(&self) -> bool {
true
}
fn has_filesystem_access(&self) -> bool {
self.config.mount_workspace
}
fn storage_path(&self) -> PathBuf {
if self.config.mount_workspace {
PathBuf::from("/workspace/.zeroclaw")
} else {
PathBuf::from("/tmp/.zeroclaw")
}
}
fn supports_long_running(&self) -> bool {
false
}
fn memory_budget(&self) -> u64 {
self.config
.memory_limit_mb
.map_or(0, |mb| mb.saturating_mul(1024 * 1024))
}
fn build_shell_command(
&self,
command: &str,
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command> {
let mut process = tokio::process::Command::new("docker");
process
.arg("run")
.arg("--rm")
.arg("--init")
.arg("--interactive");
let network = self.config.network.trim();
if !network.is_empty() {
process.arg("--network").arg(network);
}
if let Some(memory_limit_mb) = self.config.memory_limit_mb.filter(|mb| *mb > 0) {
process.arg("--memory").arg(format!("{memory_limit_mb}m"));
}
if let Some(cpu_limit) = self.config.cpu_limit.filter(|cpus| *cpus > 0.0) {
process.arg("--cpus").arg(cpu_limit.to_string());
}
if self.config.read_only_rootfs {
process.arg("--read-only");
}
if self.config.mount_workspace {
let host_workspace = self.workspace_mount_path(workspace_dir).with_context(|| {
format!(
"Failed to validate workspace mount path {}",
workspace_dir.display()
)
})?;
process
.arg("--volume")
.arg(format!("{}:/workspace:rw", host_workspace.display()))
.arg("--workdir")
.arg("/workspace");
}
process
.arg(self.config.image.trim())
.arg("sh")
.arg("-c")
.arg(command);
Ok(process)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn docker_runtime_name() {
let runtime = DockerRuntime::new(DockerRuntimeConfig::default());
assert_eq!(runtime.name(), "docker");
}
#[test]
fn docker_runtime_memory_budget() {
let mut cfg = DockerRuntimeConfig::default();
cfg.memory_limit_mb = Some(256);
let runtime = DockerRuntime::new(cfg);
assert_eq!(runtime.memory_budget(), 256 * 1024 * 1024);
}
#[test]
fn docker_build_shell_command_includes_runtime_flags() {
let cfg = DockerRuntimeConfig {
image: "alpine:3.20".into(),
network: "none".into(),
memory_limit_mb: Some(128),
cpu_limit: Some(1.5),
read_only_rootfs: true,
mount_workspace: true,
allowed_workspace_roots: Vec::new(),
};
let runtime = DockerRuntime::new(cfg);
let workspace = std::env::temp_dir();
let command = runtime
.build_shell_command("echo hello", &workspace)
.unwrap();
let debug = format!("{command:?}");
assert!(debug.contains("docker"));
assert!(debug.contains("--memory"));
assert!(debug.contains("128m"));
assert!(debug.contains("--cpus"));
assert!(debug.contains("1.5"));
assert!(debug.contains("--workdir"));
assert!(debug.contains("echo hello"));
}
#[test]
fn docker_workspace_allowlist_blocks_outside_paths() {
let cfg = DockerRuntimeConfig {
allowed_workspace_roots: vec!["/tmp/allowed".into()],
..DockerRuntimeConfig::default()
};
let runtime = DockerRuntime::new(cfg);
let outside = PathBuf::from("/tmp/blocked_workspace");
let result = runtime.build_shell_command("echo test", &outside);
assert!(result.is_err());
}
// ── §3.3 / §3.4 Docker mount & network isolation tests ──
#[test]
fn docker_build_shell_command_includes_network_flag() {
let cfg = DockerRuntimeConfig {
network: "none".into(),
..DockerRuntimeConfig::default()
};
let runtime = DockerRuntime::new(cfg);
let workspace = std::env::temp_dir();
let cmd = runtime
.build_shell_command("echo hello", &workspace)
.unwrap();
let debug = format!("{cmd:?}");
assert!(
debug.contains("--network") && debug.contains("none"),
"must include --network none for isolation"
);
}
#[test]
fn docker_build_shell_command_includes_read_only_flag() {
let cfg = DockerRuntimeConfig {
read_only_rootfs: true,
..DockerRuntimeConfig::default()
};
let runtime = DockerRuntime::new(cfg);
let workspace = std::env::temp_dir();
let cmd = runtime
.build_shell_command("echo hello", &workspace)
.unwrap();
let debug = format!("{cmd:?}");
assert!(
debug.contains("--read-only"),
"must include --read-only flag when read_only_rootfs is set"
);
}
#[cfg(unix)]
#[test]
fn docker_refuses_root_mount() {
let cfg = DockerRuntimeConfig {
mount_workspace: true,
..DockerRuntimeConfig::default()
};
let runtime = DockerRuntime::new(cfg);
let result = runtime.build_shell_command("echo test", Path::new("/"));
assert!(
result.is_err(),
"mounting filesystem root (/) must be refused"
);
let error_chain = format!("{:#}", result.unwrap_err());
assert!(
error_chain.contains("root"),
"expected root-mount error chain, got: {error_chain}"
);
}
#[test]
fn docker_no_memory_flag_when_not_configured() {
let cfg = DockerRuntimeConfig {
memory_limit_mb: None,
..DockerRuntimeConfig::default()
};
let runtime = DockerRuntime::new(cfg);
let workspace = std::env::temp_dir();
let cmd = runtime
.build_shell_command("echo hello", &workspace)
.unwrap();
let debug = format!("{cmd:?}");
assert!(
!debug.contains("--memory"),
"should not include --memory when not configured"
);
}
}