Skip to main content

rustyclaw_core/runtime/
docker.rs

1//! Docker runtime implementation.
2//!
3//! Provides lightweight container isolation for agent command execution.
4//!
5//! Adapted from ZeroClaw (MIT OR Apache-2.0 licensed).
6
7use super::traits::RuntimeAdapter;
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12/// Docker runtime configuration.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DockerRuntimeConfig {
15    /// Docker image to use (e.g., "alpine:3.20").
16    #[serde(default = "default_docker_image")]
17    pub image: String,
18    /// Docker network mode (e.g., "none", "bridge", "host").
19    #[serde(default)]
20    pub network: String,
21    /// Memory limit in MB (optional).
22    #[serde(default)]
23    pub memory_limit_mb: Option<u64>,
24    /// CPU limit (e.g., 1.5 = 1.5 CPUs).
25    #[serde(default)]
26    pub cpu_limit: Option<f64>,
27    /// Mount the root filesystem as read-only.
28    #[serde(default)]
29    pub read_only_rootfs: bool,
30    /// Mount the workspace directory into the container.
31    #[serde(default = "default_true")]
32    pub mount_workspace: bool,
33    /// Allowed workspace root paths (if empty, any path is allowed).
34    #[serde(default)]
35    pub allowed_workspace_roots: Vec<String>,
36}
37
38fn default_docker_image() -> String {
39    "alpine:latest".to_string()
40}
41
42fn default_true() -> bool {
43    true
44}
45
46impl Default for DockerRuntimeConfig {
47    fn default() -> Self {
48        Self {
49            image: default_docker_image(),
50            network: String::new(),
51            memory_limit_mb: None,
52            cpu_limit: None,
53            read_only_rootfs: false,
54            mount_workspace: true,
55            allowed_workspace_roots: Vec::new(),
56        }
57    }
58}
59
60/// Docker runtime with lightweight container isolation.
61#[derive(Debug, Clone)]
62pub struct DockerRuntime {
63    config: DockerRuntimeConfig,
64}
65
66impl DockerRuntime {
67    pub fn new(config: DockerRuntimeConfig) -> Self {
68        Self { config }
69    }
70
71    fn workspace_mount_path(&self, workspace_dir: &Path) -> Result<PathBuf> {
72        let resolved = workspace_dir
73            .canonicalize()
74            .unwrap_or_else(|_| workspace_dir.to_path_buf());
75
76        if !resolved.is_absolute() {
77            anyhow::bail!(
78                "Docker runtime requires an absolute workspace path, got: {}",
79                resolved.display()
80            );
81        }
82
83        if resolved == Path::new("/") {
84            anyhow::bail!("Refusing to mount filesystem root (/) into docker runtime");
85        }
86
87        if self.config.allowed_workspace_roots.is_empty() {
88            return Ok(resolved);
89        }
90
91        let allowed = self.config.allowed_workspace_roots.iter().any(|root| {
92            let root_path = Path::new(root)
93                .canonicalize()
94                .unwrap_or_else(|_| PathBuf::from(root));
95            resolved.starts_with(root_path)
96        });
97
98        if !allowed {
99            anyhow::bail!(
100                "Workspace path {} is not in runtime.docker.allowed_workspace_roots",
101                resolved.display()
102            );
103        }
104
105        Ok(resolved)
106    }
107}
108
109impl RuntimeAdapter for DockerRuntime {
110    fn name(&self) -> &str {
111        "docker"
112    }
113
114    fn has_shell_access(&self) -> bool {
115        true
116    }
117
118    fn has_filesystem_access(&self) -> bool {
119        self.config.mount_workspace
120    }
121
122    fn storage_path(&self) -> PathBuf {
123        if self.config.mount_workspace {
124            PathBuf::from("/workspace/.rustyclaw")
125        } else {
126            PathBuf::from("/tmp/.rustyclaw")
127        }
128    }
129
130    fn supports_long_running(&self) -> bool {
131        false
132    }
133
134    fn memory_budget(&self) -> u64 {
135        self.config
136            .memory_limit_mb
137            .map_or(0, |mb| mb.saturating_mul(1024 * 1024))
138    }
139
140    fn build_shell_command(
141        &self,
142        command: &str,
143        workspace_dir: &Path,
144    ) -> anyhow::Result<tokio::process::Command> {
145        let mut process = tokio::process::Command::new("docker");
146        process
147            .arg("run")
148            .arg("--rm")
149            .arg("--init")
150            .arg("--interactive");
151
152        let network = self.config.network.trim();
153        if !network.is_empty() {
154            process.arg("--network").arg(network);
155        }
156
157        if let Some(memory_limit_mb) = self.config.memory_limit_mb.filter(|mb| *mb > 0) {
158            process.arg("--memory").arg(format!("{memory_limit_mb}m"));
159        }
160
161        if let Some(cpu_limit) = self.config.cpu_limit.filter(|cpus| *cpus > 0.0) {
162            process.arg("--cpus").arg(cpu_limit.to_string());
163        }
164
165        if self.config.read_only_rootfs {
166            process.arg("--read-only");
167        }
168
169        if self.config.mount_workspace {
170            let host_workspace = self.workspace_mount_path(workspace_dir).with_context(|| {
171                format!(
172                    "Failed to validate workspace mount path {}",
173                    workspace_dir.display()
174                )
175            })?;
176
177            process
178                .arg("--volume")
179                .arg(format!("{}:/workspace:rw", host_workspace.display()))
180                .arg("--workdir")
181                .arg("/workspace");
182        }
183
184        process
185            .arg(self.config.image.trim())
186            .arg("sh")
187            .arg("-c")
188            .arg(command);
189
190        Ok(process)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn docker_runtime_name() {
200        let runtime = DockerRuntime::new(DockerRuntimeConfig::default());
201        assert_eq!(runtime.name(), "docker");
202    }
203
204    #[test]
205    fn docker_runtime_memory_budget() {
206        let mut cfg = DockerRuntimeConfig::default();
207        cfg.memory_limit_mb = Some(256);
208        let runtime = DockerRuntime::new(cfg);
209        assert_eq!(runtime.memory_budget(), 256 * 1024 * 1024);
210    }
211
212    #[test]
213    fn docker_build_shell_command_includes_runtime_flags() {
214        let cfg = DockerRuntimeConfig {
215            image: "alpine:3.20".into(),
216            network: "none".into(),
217            memory_limit_mb: Some(128),
218            cpu_limit: Some(1.5),
219            read_only_rootfs: true,
220            mount_workspace: true,
221            allowed_workspace_roots: Vec::new(),
222        };
223        let runtime = DockerRuntime::new(cfg);
224
225        let workspace = std::env::temp_dir();
226        let command = runtime
227            .build_shell_command("echo hello", &workspace)
228            .unwrap();
229        let debug = format!("{command:?}");
230
231        assert!(debug.contains("docker"));
232        assert!(debug.contains("--memory"));
233        assert!(debug.contains("128m"));
234        assert!(debug.contains("--cpus"));
235        assert!(debug.contains("1.5"));
236        assert!(debug.contains("--workdir"));
237        assert!(debug.contains("echo hello"));
238    }
239
240    #[test]
241    fn docker_workspace_allowlist_blocks_outside_paths() {
242        let cfg = DockerRuntimeConfig {
243            allowed_workspace_roots: vec!["/tmp/allowed".into()],
244            ..DockerRuntimeConfig::default()
245        };
246        let runtime = DockerRuntime::new(cfg);
247
248        let outside = PathBuf::from("/tmp/blocked_workspace");
249        let result = runtime.build_shell_command("echo test", &outside);
250
251        assert!(result.is_err());
252    }
253
254    #[test]
255    fn docker_build_shell_command_includes_network_flag() {
256        let cfg = DockerRuntimeConfig {
257            network: "none".into(),
258            ..DockerRuntimeConfig::default()
259        };
260        let runtime = DockerRuntime::new(cfg);
261        let workspace = std::env::temp_dir();
262        let cmd = runtime
263            .build_shell_command("echo hello", &workspace)
264            .unwrap();
265        let debug = format!("{cmd:?}");
266        assert!(
267            debug.contains("--network") && debug.contains("none"),
268            "must include --network none for isolation"
269        );
270    }
271
272    #[test]
273    fn docker_build_shell_command_includes_read_only_flag() {
274        let cfg = DockerRuntimeConfig {
275            read_only_rootfs: true,
276            ..DockerRuntimeConfig::default()
277        };
278        let runtime = DockerRuntime::new(cfg);
279        let workspace = std::env::temp_dir();
280        let cmd = runtime
281            .build_shell_command("echo hello", &workspace)
282            .unwrap();
283        let debug = format!("{cmd:?}");
284        assert!(
285            debug.contains("--read-only"),
286            "must include --read-only flag when read_only_rootfs is set"
287        );
288    }
289
290    #[cfg(unix)]
291    #[test]
292    fn docker_refuses_root_mount() {
293        let cfg = DockerRuntimeConfig {
294            mount_workspace: true,
295            ..DockerRuntimeConfig::default()
296        };
297        let runtime = DockerRuntime::new(cfg);
298        let result = runtime.build_shell_command("echo test", Path::new("/"));
299        assert!(
300            result.is_err(),
301            "mounting filesystem root (/) must be refused"
302        );
303        let error_chain = format!("{:#}", result.unwrap_err());
304        assert!(
305            error_chain.contains("root"),
306            "expected root-mount error chain, got: {error_chain}"
307        );
308    }
309
310    #[test]
311    fn docker_no_memory_flag_when_not_configured() {
312        let cfg = DockerRuntimeConfig {
313            memory_limit_mb: None,
314            ..DockerRuntimeConfig::default()
315        };
316        let runtime = DockerRuntime::new(cfg);
317        let workspace = std::env::temp_dir();
318        let cmd = runtime
319            .build_shell_command("echo hello", &workspace)
320            .unwrap();
321        let debug = format!("{cmd:?}");
322        assert!(
323            !debug.contains("--memory"),
324            "should not include --memory when not configured"
325        );
326    }
327}