rustyclaw_core/runtime/
docker.rs1use super::traits::RuntimeAdapter;
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DockerRuntimeConfig {
15 #[serde(default = "default_docker_image")]
17 pub image: String,
18 #[serde(default)]
20 pub network: String,
21 #[serde(default)]
23 pub memory_limit_mb: Option<u64>,
24 #[serde(default)]
26 pub cpu_limit: Option<f64>,
27 #[serde(default)]
29 pub read_only_rootfs: bool,
30 #[serde(default = "default_true")]
32 pub mount_workspace: bool,
33 #[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#[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}