feat(config): make config writes atomic with rollback-safe replacement (#190)
* feat(runtime): add Docker runtime MVP and runtime-aware command builder * feat(security): add shell risk classification, approval gates, and action throttling * feat(gateway): add per-endpoint rate limiting and webhook idempotency * feat(config): make config writes atomic with rollback-safe replacement --------- Co-authored-by: chumyin <chumyin@users.noreply.github.com>
This commit is contained in:
parent
f1e3b1166d
commit
b0e1e32819
11 changed files with 1202 additions and 67 deletions
199
src/runtime/docker.rs
Normal file
199
src/runtime/docker.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue