1#![allow(dead_code)]
3
4use anyhow::{bail, Context, Result};
5use tokio::process::Command;
6
7use super::config::SandboxConfig;
8
9pub const EMBEDDED_DOCKERFILE: &str = include_str!("../../Dockerfile.sandbox");
12
13pub struct DockerSandbox {
15 container_id: Option<String>,
16 config: SandboxConfig,
17 workspace_path: String,
19 proxy_port: Option<u16>,
21}
22
23#[derive(Debug)]
25pub struct SandboxOutput {
26 pub stdout: String,
27 pub stderr: String,
28 pub exit_code: i32,
29}
30
31impl DockerSandbox {
32 pub fn new(config: SandboxConfig, workspace_path: impl Into<String>) -> Self {
33 Self {
34 container_id: None,
35 config,
36 workspace_path: workspace_path.into(),
37 proxy_port: None,
38 }
39 }
40
41 pub fn set_proxy(&mut self, port: u16) {
44 self.proxy_port = Some(port);
45 }
46
47 pub async fn image_exists(image: &str) -> bool {
49 Command::new("docker")
50 .args(["image", "inspect", image])
51 .stdout(std::process::Stdio::null())
52 .stderr(std::process::Stdio::null())
53 .status()
54 .await
55 .map(|s| s.success())
56 .unwrap_or(false)
57 }
58
59 pub async fn auto_build_image(image: &str) -> Result<()> {
62 let tmp = std::env::temp_dir().join("minion-sandbox-build");
63 std::fs::create_dir_all(&tmp)
64 .context("Failed to create temp dir for Docker build")?;
65
66 let dockerfile_path = tmp.join("Dockerfile");
67 std::fs::write(&dockerfile_path, EMBEDDED_DOCKERFILE)
68 .context("Failed to write embedded Dockerfile")?;
69
70 tracing::info!("Building Docker image '{image}' (this may take a few minutes on first run)...");
71 eprintln!(
72 "\n ⟳ Building Docker image '{}' — first run only, please wait...\n",
73 image,
74 );
75
76 let output = Command::new("docker")
77 .args(["build", "-t", image, "-f"])
78 .arg(&dockerfile_path)
79 .arg(&tmp)
80 .stdout(std::process::Stdio::inherit())
81 .stderr(std::process::Stdio::inherit())
82 .status()
83 .await
84 .context("Failed to run docker build")?;
85
86 let _ = std::fs::remove_dir_all(&tmp);
88
89 if !output.success() {
90 bail!(
91 "Docker build failed for image '{image}'. \
92 Check the output above for errors."
93 );
94 }
95
96 tracing::info!("Successfully built Docker image '{image}'");
97 Ok(())
98 }
99
100 pub async fn is_docker_available() -> bool {
102 Command::new("docker")
103 .args(["info", "--format", "{{.ServerVersion}}"])
104 .output()
105 .await
106 .map(|o| o.status.success())
107 .unwrap_or(false)
108 }
109
110 pub async fn is_sandbox_available() -> bool {
114 let output = Command::new("docker")
115 .args(["version", "--format", "{{.Client.Version}}"])
116 .output()
117 .await;
118
119 match output {
120 Ok(o) if o.status.success() => {
121 let version = String::from_utf8_lossy(&o.stdout);
122 let version = version.trim();
123 !version.is_empty()
126 }
127 _ => false,
128 }
129 }
130
131 async fn auto_detect_gh_token() {
137 if std::env::var("GH_TOKEN").is_ok() || std::env::var("GITHUB_TOKEN").is_ok() {
139 return;
140 }
141
142 let output = Command::new("gh")
143 .args(["auth", "token"])
144 .output()
145 .await;
146
147 if let Ok(o) = output {
148 if o.status.success() {
149 let token = String::from_utf8_lossy(&o.stdout).trim().to_string();
150 if !token.is_empty() {
151 std::env::set_var("GH_TOKEN", &token);
152 tracing::info!("Auto-detected GH_TOKEN from `gh auth token`");
153 }
154 }
155 }
156 }
157
158 pub async fn create(&mut self) -> Result<()> {
160 if !Self::is_sandbox_available().await {
161 bail!(
162 "Docker Sandbox is not available. \
163 Please install Docker Desktop 4.40+ (https://www.docker.com/products/docker-desktop/). \
164 Ensure the Docker daemon is running before retrying."
165 );
166 }
167
168 let image = self.config.image().to_string();
169 let workspace = &self.workspace_path;
170
171 let mut args = vec![
172 "create".to_string(),
173 "--rm".to_string(),
174 "-v".to_string(),
175 format!("{workspace}:/workspace"),
176 "-w".to_string(),
177 "/workspace".to_string(),
178 ];
179
180 Self::auto_detect_gh_token().await;
186
187 if let Some(proxy_port) = self.proxy_port {
191 for key in self.config.effective_env_with_proxy() {
192 if let Ok(val) = std::env::var(&key) {
193 args.extend(["-e".to_string(), format!("{key}={val}")]);
194 }
195 }
196 args.extend([
198 "-e".to_string(),
199 format!("ANTHROPIC_BASE_URL=http://host.docker.internal:{proxy_port}"),
200 ]);
201 args.extend([
203 "--add-host".to_string(),
204 "host.docker.internal:host-gateway".to_string(),
205 ]);
206 tracing::info!(proxy_port, "Container will use API proxy — ANTHROPIC_API_KEY not injected");
207 } else {
208 for key in self.config.effective_env() {
210 if let Ok(val) = std::env::var(&key) {
211 args.extend(["-e".to_string(), format!("{key}={val}")]);
212 }
213 }
214 }
215 args.extend(["-e".to_string(), "HOME=/root".to_string()]);
217
218 for vol in self.config.effective_volumes() {
222 args.extend(["-v".to_string(), vol]);
223 }
224
225 if let Some(cpus) = self.config.resources.cpus {
227 args.extend(["--cpus".to_string(), cpus.to_string()]);
228 }
229 if let Some(ref mem) = self.config.resources.memory {
230 args.extend(["--memory".to_string(), mem.clone()]);
231 }
232
233 for dns in &self.config.dns {
235 args.extend(["--dns".to_string(), dns.clone()]);
236 }
237
238 if !self.config.network.deny.is_empty() || !self.config.network.allow.is_empty() {
240 args.extend(["--network".to_string(), "bridge".to_string()]);
241 }
242
243 args.push(image);
244 args.push("sleep".to_string());
245 args.push("infinity".to_string());
246
247 let output = Command::new("docker")
248 .args(&args)
249 .output()
250 .await
251 .context("Failed to run docker create")?;
252
253 if !output.status.success() {
254 let stderr = String::from_utf8_lossy(&output.stderr);
255 bail!("docker create failed: {stderr}");
256 }
257
258 let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
259 self.container_id = Some(id.clone());
260
261 let start_output = Command::new("docker")
263 .args(["start", &id])
264 .output()
265 .await
266 .context("Failed to start container")?;
267
268 if !start_output.status.success() {
269 let stderr = String::from_utf8_lossy(&start_output.stderr);
270 bail!("docker start failed: {stderr}");
271 }
272
273 tracing::info!(container_id = %id, "Sandbox container started");
274 Ok(())
275 }
276
277 pub async fn copy_workspace(&self, src: &str) -> Result<()> {
288 let id = self.container_id.as_ref().context("Container not created")?;
289
290 let effective_exclude = self.config.effective_exclude();
291 if effective_exclude.is_empty() {
292 let output = Command::new("docker")
294 .args(["cp", &format!("{src}/."), &format!("{id}:/workspace")])
295 .output()
296 .await
297 .context("docker cp failed")?;
298
299 if !output.status.success() {
300 let stderr = String::from_utf8_lossy(&output.stderr);
301 bail!("docker cp workspace failed: {stderr}");
302 }
303 } else {
304 let mut excludes = String::new();
311 for pattern in &effective_exclude {
312 excludes.push_str(&format!(" --exclude='{pattern}'"));
313 }
314
315 let pipe_cmd = format!(
316 "tar -cf - --no-xattrs --no-mac-metadata{excludes} -C '{src}' . 2>/dev/null \
317 | docker exec -i {id} tar -xf - -C /workspace 2>/dev/null; \
318 exit 0"
319 );
320
321 let output = Command::new("/bin/sh")
322 .args(["-c", &pipe_cmd])
323 .output()
324 .await
325 .context("tar | docker exec pipe failed")?;
326
327 let verify = Command::new("docker")
332 .args(["exec", id, "test", "-d", "/workspace/.git"])
333 .output()
334 .await
335 .context("workspace verification failed")?;
336
337 if !verify.status.success() {
338 let stderr = String::from_utf8_lossy(&output.stderr);
339 bail!("docker cp workspace failed — .git directory not found in container: {stderr}");
340 }
341 }
342
343 Ok(())
344 }
345
346 pub async fn run_command(&self, cmd: &str) -> Result<SandboxOutput> {
348 let id = self.container_id.as_ref().context("Container not created")?;
349
350 tracing::debug!(container_id = %id, cmd = %cmd, "Sandbox: executing command");
351
352 let output = Command::new("docker")
353 .args(["exec", id, "/bin/sh", "-c", cmd])
354 .output()
355 .await
356 .context("docker exec failed")?;
357
358 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
359 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
360 let exit_code = output.status.code().unwrap_or(-1);
361
362 tracing::debug!(
363 exit_code,
364 stdout_len = stdout.len(),
365 stderr_len = stderr.len(),
366 stdout_preview = %if stdout.len() > 200 { &stdout[..200] } else { &stdout },
367 stderr_preview = %if stderr.len() > 200 { &stderr[..200] } else { &stderr },
368 "Sandbox: command completed"
369 );
370
371 Ok(SandboxOutput { stdout, stderr, exit_code })
372 }
373
374 pub async fn run_command_as_user(&self, cmd: &str, user: &str) -> Result<SandboxOutput> {
378 let id = self.container_id.as_ref().context("Container not created")?;
379
380 tracing::debug!(container_id = %id, cmd = %cmd, user = %user, "Sandbox: executing command as user");
381
382 let output = Command::new("docker")
383 .args(["exec", "--user", user, id, "/bin/sh", "-c", cmd])
384 .output()
385 .await
386 .context("docker exec failed")?;
387
388 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
389 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
390 let exit_code = output.status.code().unwrap_or(-1);
391
392 tracing::debug!(
393 exit_code,
394 stdout_len = stdout.len(),
395 stderr_len = stderr.len(),
396 "Sandbox: user command completed"
397 );
398
399 Ok(SandboxOutput { stdout, stderr, exit_code })
400 }
401
402 pub async fn copy_results(&self, dest: &str) -> Result<()> {
409 let id = self.container_id.as_ref().context("Container not created")?;
410
411 let check = Command::new("docker")
414 .args(["exec", id, "git", "-C", "/workspace", "status", "--porcelain"])
415 .output()
416 .await;
417
418 if let Ok(output) = check {
419 let changed = String::from_utf8_lossy(&output.stdout);
420 let changed = changed.trim();
421 if changed.is_empty() {
422 tracing::info!("No files changed in sandbox — skipping copy-back");
423 return Ok(());
424 }
425 tracing::info!(changed_files = %changed, "Sandbox has modified files — copying back");
426 }
427
428 let pipe_cmd = format!(
431 "docker exec {id} sh -c \
432 'cd /workspace && git diff --name-only HEAD 2>/dev/null; \
433 git ls-files --others --exclude-standard 2>/dev/null' \
434 | while read f; do \
435 docker cp \"{id}:/workspace/$f\" \"{dest}/$f\" 2>/dev/null; \
436 done; exit 0"
437 );
438
439 Command::new("/bin/sh")
440 .args(["-c", &pipe_cmd])
441 .output()
442 .await
443 .context("copy results from container failed")?;
444
445 Ok(())
446 }
447
448 pub async fn destroy(&mut self) -> Result<()> {
450 if let Some(id) = self.container_id.take() {
451 let output = Command::new("docker")
452 .args(["rm", "-f", &id])
453 .output()
454 .await
455 .context("docker rm failed")?;
456
457 if !output.status.success() {
458 let stderr = String::from_utf8_lossy(&output.stderr);
459 tracing::warn!("docker rm warning: {stderr}");
460 } else {
461 tracing::info!(container_id = %id, "Sandbox container destroyed");
462 }
463 }
464 Ok(())
465 }
466}
467
468impl Drop for DockerSandbox {
470 fn drop(&mut self) {
471 if let Some(id) = &self.container_id {
472 let _ = std::process::Command::new("docker")
474 .args(["rm", "-f", id])
475 .output();
476 }
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::sandbox::config::{NetworkConfig, ResourceConfig, SandboxConfig};
484
485 fn make_config() -> SandboxConfig {
486 SandboxConfig {
487 enabled: true,
488 image: Some("ubuntu:22.04".to_string()),
489 workspace: Some("/tmp/test".to_string()),
490 network: NetworkConfig::default(),
491 resources: ResourceConfig {
492 cpus: Some(1.0),
493 memory: Some("512m".to_string()),
494 },
495 env: vec![],
496 volumes: vec![],
497 exclude: vec![],
498 dns: vec![],
499 }
500 }
501
502 #[test]
503 fn sandbox_new_has_no_container() {
504 let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
505 assert!(sb.container_id.is_none());
506 }
507
508 #[test]
509 fn sandbox_destroy_when_no_container_is_noop() {
510 let mut sb = DockerSandbox::new(make_config(), "/tmp/workspace");
512 sb.container_id = None;
513 drop(sb); }
515
516 #[tokio::test]
519 async fn run_command_returns_stdout() {
520 let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
523 let result = sb.run_command("echo hello").await;
525 assert!(result.is_err());
526 assert!(result.unwrap_err().to_string().contains("Container not created"));
527
528 let r2 = sb.copy_results("/tmp/dest").await;
530 assert!(r2.is_err());
531
532 let r3 = sb.copy_workspace("/tmp/src").await;
533 assert!(r3.is_err());
534 }
535
536 #[test]
537 fn config_image_fallback() {
538 let cfg = SandboxConfig::default();
539 let sb = DockerSandbox::new(cfg, "/tmp");
540 assert_eq!(sb.config.image(), "minion-sandbox:latest");
541 }
542}