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}
20
21/// Result of running a command inside the sandbox
22#[derive(Debug)]
23pub struct SandboxOutput {
24    pub stdout: String,
25    pub stderr: String,
26    pub exit_code: i32,
27}
28
29impl DockerSandbox {
30    pub fn new(config: SandboxConfig, workspace_path: impl Into<String>) -> Self {
31        Self {
32            container_id: None,
33            config,
34            workspace_path: workspace_path.into(),
35        }
36    }
37
38    /// Check if a Docker image exists locally.
39    pub async fn image_exists(image: &str) -> bool {
40        Command::new("docker")
41            .args(["image", "inspect", image])
42            .stdout(std::process::Stdio::null())
43            .stderr(std::process::Stdio::null())
44            .status()
45            .await
46            .map(|s| s.success())
47            .unwrap_or(false)
48    }
49
50    /// Auto-build the default sandbox image from the embedded Dockerfile.
51    /// Writes the Dockerfile to a temp dir and runs `docker build`.
52    pub async fn auto_build_image(image: &str) -> Result<()> {
53        let tmp = std::env::temp_dir().join("minion-sandbox-build");
54        std::fs::create_dir_all(&tmp)
55            .context("Failed to create temp dir for Docker build")?;
56
57        let dockerfile_path = tmp.join("Dockerfile");
58        std::fs::write(&dockerfile_path, EMBEDDED_DOCKERFILE)
59            .context("Failed to write embedded Dockerfile")?;
60
61        tracing::info!("Building Docker image '{image}' (this may take a few minutes on first run)...");
62        eprintln!(
63            "\n  ⟳ Building Docker image '{}' — first run only, please wait...\n",
64            image,
65        );
66
67        let output = Command::new("docker")
68            .args(["build", "-t", image, "-f"])
69            .arg(&dockerfile_path)
70            .arg(&tmp)
71            .stdout(std::process::Stdio::inherit())
72            .stderr(std::process::Stdio::inherit())
73            .status()
74            .await
75            .context("Failed to run docker build")?;
76
77        // Clean up temp dir (best-effort)
78        let _ = std::fs::remove_dir_all(&tmp);
79
80        if !output.success() {
81            bail!(
82                "Docker build failed for image '{image}'. \
83                 Check the output above for errors."
84            );
85        }
86
87        tracing::info!("Successfully built Docker image '{image}'");
88        Ok(())
89    }
90
91    /// Check if Docker is available in PATH
92    pub async fn is_docker_available() -> bool {
93        Command::new("docker")
94            .args(["info", "--format", "{{.ServerVersion}}"])
95            .output()
96            .await
97            .map(|o| o.status.success())
98            .unwrap_or(false)
99    }
100
101    /// Check if Docker Desktop >= 4.40 is available (required for Docker Sandbox).
102    /// Falls back to checking that `docker` is simply available when version
103    /// detection fails (useful in CI environments where Docker CE is sufficient).
104    pub async fn is_sandbox_available() -> bool {
105        let output = Command::new("docker")
106            .args(["version", "--format", "{{.Client.Version}}"])
107            .output()
108            .await;
109
110        match output {
111            Ok(o) if o.status.success() => {
112                let version = String::from_utf8_lossy(&o.stdout);
113                let version = version.trim();
114                // Docker Desktop 4.40+ ships Docker Engine 26.x+
115                // We accept any Docker that responds to version check
116                !version.is_empty()
117            }
118            _ => false,
119        }
120    }
121
122    /// Auto-detect `GH_TOKEN` from the `gh` CLI if not already set.
123    ///
124    /// Many developers authenticate via `gh auth login` but never export
125    /// `GH_TOKEN`.  This method bridges the gap so that `gh` commands
126    /// inside the Docker sandbox work out of the box.
127    async fn auto_detect_gh_token() {
128        // Skip if the user already has a token in the environment
129        if std::env::var("GH_TOKEN").is_ok() || std::env::var("GITHUB_TOKEN").is_ok() {
130            return;
131        }
132
133        let output = Command::new("gh")
134            .args(["auth", "token"])
135            .output()
136            .await;
137
138        if let Ok(o) = output {
139            if o.status.success() {
140                let token = String::from_utf8_lossy(&o.stdout).trim().to_string();
141                if !token.is_empty() {
142                    std::env::set_var("GH_TOKEN", &token);
143                    tracing::info!("Auto-detected GH_TOKEN from `gh auth token`");
144                }
145            }
146        }
147    }
148
149    /// Create the sandbox container (without starting it)
150    pub async fn create(&mut self) -> Result<()> {
151        if !Self::is_sandbox_available().await {
152            bail!(
153                "Docker Sandbox is not available. \
154                 Please install Docker Desktop 4.40+ (https://www.docker.com/products/docker-desktop/). \
155                 Ensure the Docker daemon is running before retrying."
156            );
157        }
158
159        let image = self.config.image().to_string();
160        let workspace = &self.workspace_path;
161
162        let mut args = vec![
163            "create".to_string(),
164            "--rm".to_string(),
165            "-v".to_string(),
166            format!("{workspace}:/workspace"),
167            "-w".to_string(),
168            "/workspace".to_string(),
169        ];
170
171        // ── Auto-detect credentials ────────────────────────────────
172        // If GH_TOKEN / GITHUB_TOKEN are not in the environment but the
173        // `gh` CLI is authenticated, auto-populate GH_TOKEN so that
174        // `gh` commands work inside the container without the user
175        // having to manually pass `GH_TOKEN=$(gh auth token)`.
176        Self::auto_detect_gh_token().await;
177
178        // ── Environment variables ───────────────────────────────────
179        // Forward host env vars into the container so that CLI tools
180        // (gh, claude, git) and API clients can authenticate.
181        for key in self.config.effective_env() {
182            if let Ok(val) = std::env::var(&key) {
183                args.extend(["-e".to_string(), format!("{key}={val}")]);
184            }
185        }
186        // Always set HOME so credential files are found at the expected path
187        args.extend(["-e".to_string(), "HOME=/root".to_string()]);
188
189        // ── Extra volume mounts ─────────────────────────────────────
190        // Mount credential directories (e.g. ~/.config/gh, ~/.claude, ~/.ssh)
191        // read-only so that tools inside the container can authenticate.
192        for vol in self.config.effective_volumes() {
193            args.extend(["-v".to_string(), vol]);
194        }
195
196        // ── Resource limits ─────────────────────────────────────────
197        if let Some(cpus) = self.config.resources.cpus {
198            args.extend(["--cpus".to_string(), cpus.to_string()]);
199        }
200        if let Some(ref mem) = self.config.resources.memory {
201            args.extend(["--memory".to_string(), mem.clone()]);
202        }
203
204        // ── DNS servers ─────────────────────────────────────────────
205        for dns in &self.config.dns {
206            args.extend(["--dns".to_string(), dns.clone()]);
207        }
208
209        // ── Network configuration ───────────────────────────────────
210        if !self.config.network.deny.is_empty() || !self.config.network.allow.is_empty() {
211            args.extend(["--network".to_string(), "bridge".to_string()]);
212        }
213
214        args.push(image);
215        args.push("sleep".to_string());
216        args.push("infinity".to_string());
217
218        let output = Command::new("docker")
219            .args(&args)
220            .output()
221            .await
222            .context("Failed to run docker create")?;
223
224        if !output.status.success() {
225            let stderr = String::from_utf8_lossy(&output.stderr);
226            bail!("docker create failed: {stderr}");
227        }
228
229        let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
230        self.container_id = Some(id.clone());
231
232        // Start the container
233        let start_output = Command::new("docker")
234            .args(["start", &id])
235            .output()
236            .await
237            .context("Failed to start container")?;
238
239        if !start_output.status.success() {
240            let stderr = String::from_utf8_lossy(&start_output.stderr);
241            bail!("docker start failed: {stderr}");
242        }
243
244        tracing::info!(container_id = %id, "Sandbox container started");
245        Ok(())
246    }
247
248    /// Copy a host directory into the running sandbox container.
249    ///
250    /// When the config has `exclude` patterns, we use `tar --exclude` piped
251    /// into `docker cp` to skip large directories like node_modules/ and
252    /// target/ that would otherwise make the copy prohibitively slow.
253    ///
254    /// Note: macOS tar emits many harmless warnings about extended attributes
255    /// (LIBARCHIVE.xattr.*) when the receiving Linux tar doesn't understand
256    /// them.  We suppress these via `--no-xattrs` and `--no-mac-metadata`
257    /// flags and only fail on *real* errors (e.g. source directory missing).
258    pub async fn copy_workspace(&self, src: &str) -> Result<()> {
259        let id = self.container_id.as_ref().context("Container not created")?;
260
261        let effective_exclude = self.config.effective_exclude();
262        if effective_exclude.is_empty() {
263            // Fast path: no exclusions, use plain docker cp
264            let output = Command::new("docker")
265                .args(["cp", &format!("{src}/."), &format!("{id}:/workspace")])
266                .output()
267                .await
268                .context("docker cp failed")?;
269
270            if !output.status.success() {
271                let stderr = String::from_utf8_lossy(&output.stderr);
272                bail!("docker cp workspace failed: {stderr}");
273            }
274        } else {
275            // Use shell pipe: tar --exclude | docker exec -i tar
276            // --no-xattrs and --no-mac-metadata suppress macOS extended
277            // attribute warnings that would otherwise cause tar to exit
278            // with a non-zero status.
279            // We also use 2>/dev/null on the receiving tar to silence
280            // "Ignoring unknown extended header keyword" warnings.
281            let mut excludes = String::new();
282            for pattern in &effective_exclude {
283                excludes.push_str(&format!(" --exclude='{pattern}'"));
284            }
285
286            let pipe_cmd = format!(
287                "tar -cf - --no-xattrs --no-mac-metadata{excludes} -C '{src}' . 2>/dev/null \
288                 | docker exec -i {id} tar -xf - -C /workspace 2>/dev/null; \
289                 exit 0"
290            );
291
292            let output = Command::new("/bin/sh")
293                .args(["-c", &pipe_cmd])
294                .output()
295                .await
296                .context("tar | docker exec pipe failed")?;
297
298            // We don't check exit status here because tar may return non-zero
299            // due to harmless permission errors on .git/objects (loose objects
300            // that belong to pack files and aren't individually readable).
301            // Instead, we verify the workspace was actually populated.
302            let verify = Command::new("docker")
303                .args(["exec", id, "test", "-d", "/workspace/.git"])
304                .output()
305                .await
306                .context("workspace verification failed")?;
307
308            if !verify.status.success() {
309                let stderr = String::from_utf8_lossy(&output.stderr);
310                bail!("docker cp workspace failed — .git directory not found in container: {stderr}");
311            }
312        }
313
314        Ok(())
315    }
316
317    /// Run a shell command inside the sandbox and return the output
318    pub async fn run_command(&self, cmd: &str) -> Result<SandboxOutput> {
319        let id = self.container_id.as_ref().context("Container not created")?;
320
321        tracing::debug!(container_id = %id, cmd = %cmd, "Sandbox: executing command");
322
323        let output = Command::new("docker")
324            .args(["exec", id, "/bin/sh", "-c", cmd])
325            .output()
326            .await
327            .context("docker exec failed")?;
328
329        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
330        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
331        let exit_code = output.status.code().unwrap_or(-1);
332
333        tracing::debug!(
334            exit_code,
335            stdout_len = stdout.len(),
336            stderr_len = stderr.len(),
337            stdout_preview = %if stdout.len() > 200 { &stdout[..200] } else { &stdout },
338            stderr_preview = %if stderr.len() > 200 { &stderr[..200] } else { &stderr },
339            "Sandbox: command completed"
340        );
341
342        Ok(SandboxOutput { stdout, stderr, exit_code })
343    }
344
345    /// Copy results from the sandbox back to the host.
346    ///
347    /// First checks whether any files were actually modified inside the
348    /// container (via `git status --porcelain`). If nothing changed, the
349    /// copy is skipped entirely — this is the common case for read-only
350    /// workflows like code-review.
351    pub async fn copy_results(&self, dest: &str) -> Result<()> {
352        let id = self.container_id.as_ref().context("Container not created")?;
353
354        // Check if any files were modified inside the container.
355        // If nothing changed, skip the (potentially slow) copy-back.
356        let check = Command::new("docker")
357            .args(["exec", id, "git", "-C", "/workspace", "status", "--porcelain"])
358            .output()
359            .await;
360
361        if let Ok(output) = check {
362            let changed = String::from_utf8_lossy(&output.stdout);
363            let changed = changed.trim();
364            if changed.is_empty() {
365                tracing::info!("No files changed in sandbox — skipping copy-back");
366                return Ok(());
367            }
368            tracing::info!(changed_files = %changed, "Sandbox has modified files — copying back");
369        }
370
371        // Copy only the changed files back using git ls-files
372        // This is much faster than copying the entire workspace.
373        let pipe_cmd = format!(
374            "docker exec {id} sh -c \
375             'cd /workspace && git diff --name-only HEAD 2>/dev/null; \
376              git ls-files --others --exclude-standard 2>/dev/null' \
377             | while read f; do \
378                 docker cp \"{id}:/workspace/$f\" \"{dest}/$f\" 2>/dev/null; \
379               done; exit 0"
380        );
381
382        Command::new("/bin/sh")
383            .args(["-c", &pipe_cmd])
384            .output()
385            .await
386            .context("copy results from container failed")?;
387
388        Ok(())
389    }
390
391    /// Stop and remove the sandbox container (safe to call even if not created)
392    pub async fn destroy(&mut self) -> Result<()> {
393        if let Some(id) = self.container_id.take() {
394            let output = Command::new("docker")
395                .args(["rm", "-f", &id])
396                .output()
397                .await
398                .context("docker rm failed")?;
399
400            if !output.status.success() {
401                let stderr = String::from_utf8_lossy(&output.stderr);
402                tracing::warn!("docker rm warning: {stderr}");
403            } else {
404                tracing::info!(container_id = %id, "Sandbox container destroyed");
405            }
406        }
407        Ok(())
408    }
409}
410
411/// Drop impl ensures cleanup even if destroy() was not called explicitly
412impl Drop for DockerSandbox {
413    fn drop(&mut self) {
414        if let Some(id) = &self.container_id {
415            // Best-effort synchronous cleanup via std::process::Command
416            let _ = std::process::Command::new("docker")
417                .args(["rm", "-f", id])
418                .output();
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::sandbox::config::{NetworkConfig, ResourceConfig, SandboxConfig};
427
428    fn make_config() -> SandboxConfig {
429        SandboxConfig {
430            enabled: true,
431            image: Some("ubuntu:22.04".to_string()),
432            workspace: Some("/tmp/test".to_string()),
433            network: NetworkConfig::default(),
434            resources: ResourceConfig {
435                cpus: Some(1.0),
436                memory: Some("512m".to_string()),
437            },
438            env: vec![],
439            volumes: vec![],
440            exclude: vec![],
441            dns: vec![],
442        }
443    }
444
445    #[test]
446    fn sandbox_new_has_no_container() {
447        let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
448        assert!(sb.container_id.is_none());
449    }
450
451    #[test]
452    fn sandbox_destroy_when_no_container_is_noop() {
453        // drop without a container_id should not panic
454        let mut sb = DockerSandbox::new(make_config(), "/tmp/workspace");
455        sb.container_id = None;
456        drop(sb); // triggers Drop impl
457    }
458
459    /// Mock-based test: verify that the docker commands would be constructed correctly.
460    /// Uses a fake docker binary that records the command-line arguments.
461    #[tokio::test]
462    async fn run_command_returns_stdout() {
463        // We can't run real Docker in tests, so we just verify that the DockerSandbox
464        // structure is correct and the error path works.
465        let sb = DockerSandbox::new(make_config(), "/tmp/workspace");
466        // Without a container_id, run_command should return an error
467        let result = sb.run_command("echo hello").await;
468        assert!(result.is_err());
469        assert!(result.unwrap_err().to_string().contains("Container not created"));
470
471        // Same for copy_results and copy_workspace
472        let r2 = sb.copy_results("/tmp/dest").await;
473        assert!(r2.is_err());
474
475        let r3 = sb.copy_workspace("/tmp/src").await;
476        assert!(r3.is_err());
477    }
478
479    #[test]
480    fn config_image_fallback() {
481        let cfg = SandboxConfig::default();
482        let sb = DockerSandbox::new(cfg, "/tmp");
483        assert_eq!(sb.config.image(), "minion-sandbox:latest");
484    }
485}