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