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:
Chummy 2026-02-16 01:18:45 +08:00 committed by GitHub
parent f1e3b1166d
commit b0e1e32819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1202 additions and 67 deletions

199
src/runtime/docker.rs Normal file
View 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());
}
}

View file

@ -1,6 +1,8 @@
pub mod docker;
pub mod native;
pub mod traits;
pub use docker::DockerRuntime;
pub use native::NativeRuntime;
pub use traits::RuntimeAdapter;
@ -10,18 +12,14 @@ use crate::config::RuntimeConfig;
pub fn create_runtime(config: &RuntimeConfig) -> anyhow::Result<Box<dyn RuntimeAdapter>> {
match config.kind.as_str() {
"native" => Ok(Box::new(NativeRuntime::new())),
"docker" => anyhow::bail!(
"runtime.kind='docker' is not implemented yet. Use runtime.kind='native' until container runtime support lands."
),
"docker" => Ok(Box::new(DockerRuntime::new(config.docker.clone()))),
"cloudflare" => anyhow::bail!(
"runtime.kind='cloudflare' is not implemented yet. Use runtime.kind='native' for now."
),
other if other.trim().is_empty() => anyhow::bail!(
"runtime.kind cannot be empty. Supported values: native"
),
other => anyhow::bail!(
"Unknown runtime kind '{other}'. Supported values: native"
),
other if other.trim().is_empty() => {
anyhow::bail!("runtime.kind cannot be empty. Supported values: native, docker")
}
other => anyhow::bail!("Unknown runtime kind '{other}'. Supported values: native, docker"),
}
}
@ -33,6 +31,7 @@ mod tests {
fn factory_native() {
let cfg = RuntimeConfig {
kind: "native".into(),
..RuntimeConfig::default()
};
let rt = create_runtime(&cfg).unwrap();
assert_eq!(rt.name(), "native");
@ -40,20 +39,21 @@ mod tests {
}
#[test]
fn factory_docker_errors() {
fn factory_docker() {
let cfg = RuntimeConfig {
kind: "docker".into(),
..RuntimeConfig::default()
};
match create_runtime(&cfg) {
Err(err) => assert!(err.to_string().contains("not implemented")),
Ok(_) => panic!("docker runtime should error"),
}
let rt = create_runtime(&cfg).unwrap();
assert_eq!(rt.name(), "docker");
assert!(rt.has_shell_access());
}
#[test]
fn factory_cloudflare_errors() {
let cfg = RuntimeConfig {
kind: "cloudflare".into(),
..RuntimeConfig::default()
};
match create_runtime(&cfg) {
Err(err) => assert!(err.to_string().contains("not implemented")),
@ -65,6 +65,7 @@ mod tests {
fn factory_unknown_errors() {
let cfg = RuntimeConfig {
kind: "wasm-edge-unknown".into(),
..RuntimeConfig::default()
};
match create_runtime(&cfg) {
Err(err) => assert!(err.to_string().contains("Unknown runtime kind")),
@ -76,6 +77,7 @@ mod tests {
fn factory_empty_errors() {
let cfg = RuntimeConfig {
kind: String::new(),
..RuntimeConfig::default()
};
match create_runtime(&cfg) {
Err(err) => assert!(err.to_string().contains("cannot be empty")),

View file

@ -1,5 +1,5 @@
use super::traits::RuntimeAdapter;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Native runtime — full access, runs on Mac/Linux/Docker/Raspberry Pi
pub struct NativeRuntime;
@ -33,6 +33,16 @@ impl RuntimeAdapter for NativeRuntime {
fn supports_long_running(&self) -> bool {
true
}
fn build_shell_command(
&self,
command: &str,
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command> {
let mut process = tokio::process::Command::new("sh");
process.arg("-c").arg(command).current_dir(workspace_dir);
Ok(process)
}
}
#[cfg(test)]
@ -69,4 +79,14 @@ mod tests {
let path = NativeRuntime::new().storage_path();
assert!(path.to_string_lossy().contains("zeroclaw"));
}
#[test]
fn native_builds_shell_command() {
let cwd = std::env::temp_dir();
let command = NativeRuntime::new()
.build_shell_command("echo hello", &cwd)
.unwrap();
let debug = format!("{command:?}");
assert!(debug.contains("echo hello"));
}
}

View file

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Runtime adapter — abstracts platform differences so the same agent
/// code runs on native, Docker, Cloudflare Workers, Raspberry Pi, etc.
@ -22,4 +22,11 @@ pub trait RuntimeAdapter: Send + Sync {
fn memory_budget(&self) -> u64 {
0
}
/// Build a shell command process for this runtime.
fn build_shell_command(
&self,
command: &str,
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command>;
}