Skip to main content

minion_engine/sandbox/
docker.rs

1// Sandbox API — some items used only in integration paths
2#![allow(dead_code)]
3
4use anyhow::{bail, Context, Result};
5use tokio::process::Command;
6
7use super::config::SandboxConfig;
8
9/// The reference Dockerfile embedded at compile time so `cargo install`
10/// users get auto-build without needing the source tree.
11pub const EMBEDDED_DOCKERFILE: &str = include_str!("../../Dockerfile.sandbox");
12
13/// Manages a Docker sandbox container lifecycle
14pub struct DockerSandbox {
15    container_id: Option<String>,
16    config: SandboxConfig,
17    /// Host workspace path to mount
18    workspace_path: String,
19    /// When set, proxy port for ANTHROPIC_BASE_URL (secrets stay on host)
20    proxy_port: Option<u16>,
21}
22
23/// Result of running a command inside the sandbox
24#[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    /// Enable the API proxy: the container will use ANTHROPIC_BASE_URL
42    /// instead of receiving the raw API key.
43    pub fn set_proxy(&mut self, port: u16) {
44        self.proxy_port = Some(port);
45    }
46
47    /// Check if a Docker image exists locally.
48    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    /// Auto-build the default sandbox image from the embedded Dockerfile.
60    /// Writes the Dockerfile to a temp dir and runs `docker build`.
61    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        // Clean up temp dir (best-effort)
87        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    /// Check if Docker is available in PATH
101    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    /// Check if Docker Desktop >= 4.40 is available (required for Docker Sandbox).
111    /// Falls back to checking that `docker` is simply available when version
112    /// detection fails (useful in CI environments where Docker CE is sufficient).
113    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                // Docker Desktop 4.40+ ships Docker Engine 26.x+
124                // We accept any Docker that responds to version check
125                !version.is_empty()
126            }
127            _ => false,
128        }
129    }
130
131    /// Auto-detect `GH_TOKEN` from the `gh` CLI if not already set.
132    ///
133    /// Many developers authenticate via `gh auth login` but never export
134    /// `GH_TOKEN`.  This method bridges the gap so that `gh` commands
135    /// inside the Docker sandbox work out of the box.
136    async fn auto_detect_gh_token() {
137        // Skip if the user already has a token in the environment
138        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    /// Create the sandbox container (without starting it)
159    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        // ── Auto-detect credentials ────────────────────────────────
181        // If GH_TOKEN / GITHUB_TOKEN are not in the environment but the
182        // `gh` CLI is authenticated, auto-populate GH_TOKEN so that
183        // `gh` commands work inside the container without the user
184        // having to manually pass `GH_TOKEN=$(gh auth token)`.
185        Self::auto_detect_gh_token().await;
186
187        // ── Environment variables ───────────────────────────────────
188        // When proxy is active, exclude secrets from the container env
189        // and inject ANTHROPIC_BASE_URL pointing to the host proxy instead.
190        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            // Point Claude CLI and API clients to the host proxy
197            args.extend([
198                "-e".to_string(),
199                format!("ANTHROPIC_BASE_URL=http://host.docker.internal:{proxy_port}"),
200            ]);
201            // Ensure host.docker.internal resolves (required on Linux)
202            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            // No proxy — forward all env vars directly (legacy behavior)
209            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        // Always set HOME so credential files are found at the expected path
216        args.extend(["-e".to_string(), "HOME=/root".to_string()]);
217
218        // ── Extra volume mounts ─────────────────────────────────────
219        // Mount credential directories (e.g. ~/.config/gh, ~/.claude, ~/.ssh)
220        // read-only so that tools inside the container can authenticate.
221        for vol in self.config.effective_volumes() {
222            args.extend(["-v".to_string(), vol]);
223        }
224
225        // ── Resource limits ─────────────────────────────────────────
226        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        // ── DNS servers ─────────────────────────────────────────────
234        for dns in &self.config.dns {
235            args.extend(["--dns".to_string(), dns.clone()]);
236        }
237
238        // ── Network configuration ───────────────────────────────────
239        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        // Start the container
262        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    /// Copy a host directory into the running sandbox container.
278    ///
279    /// When the config has `exclude` patterns, we use `tar --exclude` piped
280    /// into `docker cp` to skip large directories like node_modules/ and
281    /// target/ that would otherwise make the copy prohibitively slow.
282    ///
283    /// Note: macOS tar emits many harmless warnings about extended attributes
284    /// (LIBARCHIVE.xattr.*) when the receiving Linux tar doesn't understand
285    /// them.  We suppress these via `--no-xattrs` and `--no-mac-metadata`
286    /// flags and only fail on *real* errors (e.g. source directory missing).
287    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            // Fast path: no exclusions, use plain docker cp
293            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            // Use shell pipe: tar --exclude | docker exec -i tar
305            // --no-xattrs and --no-mac-metadata suppress macOS extended
306            // attribute warnings that would otherwise cause tar to exit
307            // with a non-zero status.
308            // We also use 2>/dev/null on the receiving tar to silence
309            // "Ignoring unknown extended header keyword" warnings.
310            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            // We don't check exit status here because tar may return non-zero
328            // due to harmless permission errors on .git/objects (loose objects
329            // that belong to pack files and aren't individually readable).
330            // Instead, we verify the workspace was actually populated.
331            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    /// Run a shell command inside the sandbox and return the output
347    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    /// Run a shell command inside the sandbox as a specific user.
375    /// Used for agent steps that need non-root execution (Claude CLI
376    /// refuses `--dangerously-skip-permissions` when running as root).
377    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    /// Copy results from the sandbox back to the host.
403    ///
404    /// First checks whether any files were actually modified inside the
405    /// container (via `git status --porcelain`). If nothing changed, the
406    /// copy is skipped entirely — this is the common case for read-only
407    /// workflows like code-review.
408    pub async fn copy_results(&self, dest: &str) -> Result<()> {
409        let id = self.container_id.as_ref().context("Container not created")?;
410
411        // Check if any files were modified inside the container.
412        // If nothing changed, skip the (potentially slow) copy-back.
413        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        // Copy only the changed files back using git ls-files
429        // This is much faster than copying the entire workspace.
430        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    /// Stop and remove the sandbox container (safe to call even if not created)
449    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
468/// Drop impl ensures cleanup even if destroy() was not called explicitly
469impl Drop for DockerSandbox {
470    fn drop(&mut self) {
471        if let Some(id) = &self.container_id {
472            // Best-effort synchronous cleanup via std::process::Command
473            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        // drop without a container_id should not panic
511        let mut sb = DockerSandbox::new(make_config(), "/tmp/workspace");
512        sb.container_id = None;
513        drop(sb); // triggers Drop impl
514    }
515
516    /// Mock-based test: verify that the docker commands would be constructed correctly.
517    /// Uses a fake docker binary that records the command-line arguments.
518    #[tokio::test]
519    async fn run_command_returns_stdout() {
520        // We can't run real Docker in tests, so we just verify that the DockerSandbox
521        // structure is correct and the error path works.
522        let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
523        // Without a container_id, run_command should return an error
524        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        // Same for copy_results and copy_workspace
529        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}