Skip to main content

agentkernel/backend/
docker.rs

1//! Docker/Podman container backend implementing the Sandbox trait.
2
3use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use std::process::Command;
6
7use super::{BackendType, ExecOptions, ExecResult, Sandbox, SandboxConfig};
8
9/// Container runtime to use
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ContainerRuntime {
12    Docker,
13    Podman,
14}
15
16impl ContainerRuntime {
17    /// Get the command name for this runtime
18    pub fn cmd(&self) -> &'static str {
19        match self {
20            ContainerRuntime::Docker => "docker",
21            ContainerRuntime::Podman => "podman",
22        }
23    }
24
25    /// Convert to BackendType
26    pub fn to_backend_type(self) -> BackendType {
27        match self {
28            ContainerRuntime::Docker => BackendType::Docker,
29            ContainerRuntime::Podman => BackendType::Podman,
30        }
31    }
32}
33
34/// Check if Docker is available
35pub fn docker_available() -> bool {
36    Command::new("docker")
37        .arg("version")
38        .output()
39        .map(|o| o.status.success())
40        .unwrap_or(false)
41}
42
43/// Check if Podman is available
44pub fn podman_available() -> bool {
45    Command::new("podman")
46        .arg("version")
47        .output()
48        .map(|o| o.status.success())
49        .unwrap_or(false)
50}
51
52/// Get the IP address of a running container by name.
53pub fn get_container_ip(container_name: &str) -> Option<String> {
54    let output = Command::new("docker")
55        .args([
56            "inspect",
57            "-f",
58            "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
59            container_name,
60        ])
61        .output()
62        .ok()?;
63    if !output.status.success() {
64        return None;
65    }
66    let ip = String::from_utf8_lossy(&output.stdout).trim().to_string();
67    if ip.is_empty() { None } else { Some(ip) }
68}
69
70/// Detect the best available container runtime
71pub fn detect_container_runtime() -> Option<ContainerRuntime> {
72    if podman_available() {
73        Some(ContainerRuntime::Podman)
74    } else if docker_available() {
75        Some(ContainerRuntime::Docker)
76    } else {
77        None
78    }
79}
80
81/// Docker/Podman container sandbox
82pub struct DockerSandbox {
83    name: String,
84    runtime: ContainerRuntime,
85    container_id: Option<String>,
86    running: bool,
87    /// If true, don't clean up container in Drop (for persistent sandboxes)
88    persistent: bool,
89}
90
91impl DockerSandbox {
92    /// Create a new Docker sandbox with the specified runtime
93    pub fn new(name: &str, runtime: ContainerRuntime) -> Self {
94        Self {
95            name: name.to_string(),
96            runtime,
97            container_id: None,
98            running: false,
99            persistent: false,
100        }
101    }
102
103    /// Create a persistent Docker sandbox (won't be cleaned up in Drop)
104    pub fn new_persistent(name: &str, runtime: ContainerRuntime) -> Self {
105        Self {
106            name: name.to_string(),
107            runtime,
108            container_id: None,
109            running: false,
110            persistent: true,
111        }
112    }
113
114    /// Mark this sandbox as persistent (won't be cleaned up in Drop)
115    pub fn set_persistent(&mut self, persistent: bool) {
116        self.persistent = persistent;
117    }
118
119    /// Create a new Docker sandbox with auto-detected runtime
120    pub fn with_detected_runtime(name: &str) -> Result<Self> {
121        let runtime = detect_container_runtime()
122            .ok_or_else(|| anyhow::anyhow!("No container runtime available"))?;
123        Ok(Self::new(name, runtime))
124    }
125
126    /// Get the container name
127    fn container_name(&self) -> String {
128        format!("agentkernel-{}", self.name)
129    }
130}
131
132impl DockerSandbox {
133    /// Write a file to the container using docker cp
134    async fn write_file_impl(&self, path: &str, content: &[u8]) -> Result<()> {
135        let container_name = self.container_name();
136        let cmd = self.runtime.cmd();
137
138        // Create a temporary file to copy
139        let temp_dir = std::env::temp_dir();
140        let temp_file = temp_dir.join(format!("agentkernel-upload-{}", uuid::Uuid::new_v4()));
141        std::fs::write(&temp_file, content).context("Failed to write temp file")?;
142
143        // Ensure parent directory exists in container
144        let parent = std::path::Path::new(path)
145            .parent()
146            .map(|p| p.to_string_lossy().to_string())
147            .unwrap_or_else(|| "/".to_string());
148
149        let _ = Command::new(cmd)
150            .args(["exec", &container_name, "mkdir", "-p", &parent])
151            .output();
152
153        // Copy file into container
154        let dest = format!("{}:{}", container_name, path);
155        let output = Command::new(cmd)
156            .args(["cp", temp_file.to_str().unwrap(), &dest])
157            .output()
158            .context("Failed to copy file to container")?;
159
160        // Clean up temp file
161        let _ = std::fs::remove_file(&temp_file);
162
163        if !output.status.success() {
164            let stderr = String::from_utf8_lossy(&output.stderr);
165            bail!("docker cp failed: {}", stderr);
166        }
167
168        Ok(())
169    }
170
171    /// Read a file from the container using docker cp
172    async fn read_file_impl(&self, path: &str) -> Result<Vec<u8>> {
173        let container_name = self.container_name();
174        let cmd = self.runtime.cmd();
175
176        // Create temp file for output
177        let temp_dir = std::env::temp_dir();
178        let temp_file = temp_dir.join(format!("agentkernel-download-{}", uuid::Uuid::new_v4()));
179
180        // Copy file from container
181        let src = format!("{}:{}", container_name, path);
182        let output = Command::new(cmd)
183            .args(["cp", &src, temp_file.to_str().unwrap()])
184            .output()
185            .context("Failed to copy file from container")?;
186
187        if !output.status.success() {
188            let stderr = String::from_utf8_lossy(&output.stderr);
189            bail!("docker cp failed: {}", stderr);
190        }
191
192        // Read and return content
193        let content = std::fs::read(&temp_file).context("Failed to read temp file")?;
194
195        // Clean up
196        let _ = std::fs::remove_file(&temp_file);
197
198        Ok(content)
199    }
200}
201
202#[async_trait]
203impl Sandbox for DockerSandbox {
204    async fn start(&mut self, config: &SandboxConfig) -> Result<()> {
205        let cmd = self.runtime.cmd();
206        let container_name = self.container_name();
207
208        // Remove any existing container with this name
209        let _ = Command::new(cmd)
210            .args(["rm", "-f", &container_name])
211            .output();
212
213        // Build container arguments
214        // Note: We use --rm for ephemeral containers but persistent sandboxes
215        // will have their containers survive because Drop cleanup is skipped
216        let mut args = vec![
217            "run".to_string(),
218            "-d".to_string(),
219            "--name".to_string(),
220            container_name.clone(),
221            "--hostname".to_string(),
222            "agentkernel".to_string(),
223        ];
224
225        // Add resource limits
226        args.push(format!("--cpus={}", config.vcpus));
227        args.push(format!("--memory={}m", config.memory_mb));
228
229        // Network configuration
230        if !config.network {
231            args.push("--network=none".to_string());
232        }
233
234        // Port mappings (-p host:container[/udp])
235        for pm in &config.ports {
236            args.push("-p".to_string());
237            args.push(pm.to_string());
238        }
239
240        // Mount working directory if requested
241        if config.mount_cwd
242            && let Some(ref work_dir) = config.work_dir
243        {
244            args.push("-v".to_string());
245            args.push(format!("{}:/workspace", work_dir));
246            args.push("-w".to_string());
247            args.push("/workspace".to_string());
248        }
249
250        // Mount home directory if requested
251        if config.mount_home
252            && let Some(home) = std::env::var_os("HOME")
253        {
254            args.push("-v".to_string());
255            args.push(format!("{}:/home/user:ro", home.to_string_lossy()));
256        }
257
258        // Mount persistent volumes
259        for volume_spec in &config.volumes {
260            args.push("-v".to_string());
261            args.push(volume_spec.clone());
262        }
263
264        // Read-only root filesystem
265        if config.read_only {
266            args.push("--read-only".to_string());
267        }
268
269        // Add environment variables
270        for (key, value) in &config.env {
271            args.push("-e".to_string());
272            args.push(format!("{}={}", key, value));
273        }
274
275        // Add entrypoint override to keep container running
276        args.extend([
277            "--entrypoint".to_string(),
278            "sh".to_string(),
279            config.image.clone(),
280            "-c".to_string(),
281            "while true; do sleep 3600; done".to_string(),
282        ]);
283
284        // Start container
285        let output = Command::new(cmd)
286            .args(&args)
287            .output()
288            .context("Failed to start container")?;
289
290        if !output.status.success() {
291            let stderr = String::from_utf8_lossy(&output.stderr);
292            bail!("Failed to start container: {}", stderr);
293        }
294
295        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
296        self.container_id = Some(container_id);
297        self.running = true;
298
299        Ok(())
300    }
301
302    async fn exec(&mut self, cmd: &[&str]) -> Result<ExecResult> {
303        self.exec_with_options(cmd, &ExecOptions::default()).await
304    }
305
306    async fn exec_with_env(&mut self, cmd: &[&str], env: &[String]) -> Result<ExecResult> {
307        self.exec_with_options(
308            cmd,
309            &ExecOptions {
310                env: env.to_vec(),
311                ..Default::default()
312            },
313        )
314        .await
315    }
316
317    async fn exec_with_options(&mut self, cmd: &[&str], opts: &ExecOptions) -> Result<ExecResult> {
318        let runtime_cmd = self.runtime.cmd();
319        let container_name = self.container_name();
320
321        let mut args = vec!["exec".to_string()];
322
323        if let Some(ref workdir) = opts.workdir {
324            args.push("-w".to_string());
325            args.push(workdir.clone());
326        }
327
328        if let Some(ref user) = opts.user {
329            args.push("-u".to_string());
330            args.push(user.clone());
331        }
332
333        for e in &opts.env {
334            args.push("-e".to_string());
335            args.push(e.clone());
336        }
337
338        args.push(container_name);
339        args.extend(cmd.iter().map(|s| s.to_string()));
340
341        let output = Command::new(runtime_cmd)
342            .args(&args)
343            .output()
344            .context("Failed to run command in container")?;
345
346        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
347        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
348        let exit_code = output.status.code().unwrap_or(-1);
349
350        Ok(ExecResult {
351            exit_code,
352            stdout,
353            stderr,
354        })
355    }
356
357    async fn stop(&mut self) -> Result<()> {
358        let container_name = self.container_name();
359
360        // Use rm -f to kill and remove in one operation
361        let _ = Command::new(self.runtime.cmd())
362            .args(["rm", "-f", &container_name])
363            .output();
364
365        self.container_id = None;
366        self.running = false;
367        Ok(())
368    }
369
370    async fn resize(&mut self, vcpus: u32, memory_mb: u64) -> Result<bool> {
371        let container_name = self.container_name();
372        let output = Command::new(self.runtime.cmd())
373            .args([
374                "update",
375                "--cpus",
376                &vcpus.to_string(),
377                "--memory",
378                &format!("{}m", memory_mb),
379                &container_name,
380            ])
381            .output()
382            .context("Failed to resize container")?;
383
384        if !output.status.success() {
385            let stderr = String::from_utf8_lossy(&output.stderr);
386            eprintln!(
387                "Warning: in-place resize not supported for '{}': {}",
388                container_name,
389                stderr.trim()
390            );
391            return Ok(false);
392        }
393
394        Ok(true)
395    }
396
397    fn name(&self) -> &str {
398        &self.name
399    }
400
401    fn backend_type(&self) -> BackendType {
402        self.runtime.to_backend_type()
403    }
404
405    fn is_running(&self) -> bool {
406        // Check Docker directly - don't rely on internal state since
407        // we might be reconnecting to an existing container
408        let container_name = self.container_name();
409        Command::new(self.runtime.cmd())
410            .args(["ps", "-q", "-f", &format!("name={}", container_name)])
411            .output()
412            .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
413            .unwrap_or(false)
414    }
415
416    async fn write_file_unchecked(&mut self, path: &str, content: &[u8]) -> Result<()> {
417        self.write_file_impl(path, content).await
418    }
419
420    async fn read_file_unchecked(&mut self, path: &str) -> Result<Vec<u8>> {
421        self.read_file_impl(path).await
422    }
423
424    async fn remove_file_unchecked(&mut self, path: &str) -> Result<()> {
425        let container_name = self.container_name();
426        let output = Command::new(self.runtime.cmd())
427            .args(["exec", &container_name, "rm", "-f", path])
428            .output()
429            .context("Failed to remove file in container")?;
430
431        if !output.status.success() {
432            let stderr = String::from_utf8_lossy(&output.stderr);
433            bail!("rm failed: {}", stderr);
434        }
435
436        Ok(())
437    }
438
439    async fn mkdir_unchecked(&mut self, path: &str, recursive: bool) -> Result<()> {
440        let container_name = self.container_name();
441        let mut args = vec!["exec", &container_name, "mkdir"];
442        if recursive {
443            args.push("-p");
444        }
445        args.push(path);
446
447        let output = Command::new(self.runtime.cmd())
448            .args(&args)
449            .output()
450            .context("Failed to create directory in container")?;
451
452        if !output.status.success() {
453            let stderr = String::from_utf8_lossy(&output.stderr);
454            bail!("mkdir failed: {}", stderr);
455        }
456
457        Ok(())
458    }
459
460    async fn attach(&mut self, shell: Option<&str>) -> Result<i32> {
461        self.attach_with_env(shell, &[]).await
462    }
463
464    async fn attach_with_env(&mut self, shell: Option<&str>, env: &[String]) -> Result<i32> {
465        // Check Docker directly since we might be reconnecting to an existing container
466        if !self.is_running() {
467            bail!("Container is not running");
468        }
469
470        let container_name = self.container_name();
471        let shell_cmd = shell.unwrap_or("/bin/sh");
472
473        // Build the docker exec command
474        let mut docker_args = vec!["exec".to_string(), "-it".to_string()];
475        for e in env {
476            docker_args.push("-e".to_string());
477            docker_args.push(e.clone());
478        }
479        docker_args.push(container_name);
480        docker_args.push(shell_cmd.to_string());
481
482        let runtime_cmd = self.runtime.cmd();
483
484        // Check if recording was requested via AGENTKERNEL_RECORD env var
485        // (set by the attach command handler when --record is passed)
486        let record_path = std::env::var("AGENTKERNEL_RECORD").ok();
487
488        let status = if let Some(ref cast_path) = record_path {
489            // Wrap with `script` to capture PTY I/O for session recording.
490            // macOS: script -q <file> <cmd> [args...]
491            // Linux: script -qc "<cmd> [args...]" <file>
492            let full_cmd = std::iter::once(runtime_cmd.to_string())
493                .chain(docker_args.iter().cloned())
494                .collect::<Vec<_>>()
495                .join(" ");
496
497            let mut script_args = if cfg!(target_os = "macos") {
498                vec!["-q".to_string(), cast_path.clone(), runtime_cmd.to_string()]
499            } else {
500                vec![
501                    "-q".to_string(),
502                    "-c".to_string(),
503                    full_cmd,
504                    cast_path.clone(),
505                ]
506            };
507
508            if cfg!(target_os = "macos") {
509                script_args.extend(docker_args);
510            }
511
512            std::process::Command::new("script")
513                .args(&script_args)
514                .stdin(std::process::Stdio::inherit())
515                .stdout(std::process::Stdio::inherit())
516                .stderr(std::process::Stdio::inherit())
517                .status()
518                .context("Failed to record session with script")?
519        } else {
520            std::process::Command::new(runtime_cmd)
521                .args(&docker_args)
522                .stdin(std::process::Stdio::inherit())
523                .stdout(std::process::Stdio::inherit())
524                .stderr(std::process::Stdio::inherit())
525                .status()
526                .context("Failed to attach to container")?
527        };
528
529        Ok(status.code().unwrap_or(-1))
530    }
531}
532
533impl DockerSandbox {
534    /// Run a command in a temporary container using `docker run --rm`
535    /// This is faster than create→start→exec→stop for one-shot commands
536    pub fn run_ephemeral_cmd(
537        runtime: ContainerRuntime,
538        image: &str,
539        cmd: &[String],
540        config: &SandboxConfig,
541    ) -> Result<ExecResult> {
542        let runtime_cmd = runtime.cmd();
543
544        let mut args = vec![
545            "run".to_string(),
546            "--rm".to_string(), // auto-remove after exit
547        ];
548
549        // Add resource limits
550        args.push(format!("--cpus={}", config.vcpus));
551        args.push(format!("--memory={}m", config.memory_mb));
552
553        // Network configuration
554        if !config.network {
555            args.push("--network=none".to_string());
556        }
557
558        // Port mappings (-p host:container[/udp])
559        for pm in &config.ports {
560            args.push("-p".to_string());
561            args.push(pm.to_string());
562        }
563
564        // Mount working directory if requested
565        if config.mount_cwd
566            && let Some(ref work_dir) = config.work_dir
567        {
568            args.push("-v".to_string());
569            args.push(format!("{}:/workspace", work_dir));
570            args.push("-w".to_string());
571            args.push("/workspace".to_string());
572        }
573
574        // Mount home directory if requested (read-only)
575        if config.mount_home
576            && let Some(home) = std::env::var_os("HOME")
577        {
578            args.push("-v".to_string());
579            args.push(format!("{}:/home/user:ro", home.to_string_lossy()));
580        }
581
582        // Read-only root filesystem
583        if config.read_only {
584            args.push("--read-only".to_string());
585        }
586
587        // Add environment variables
588        for (key, value) in &config.env {
589            args.push("-e".to_string());
590            args.push(format!("{}={}", key, value));
591        }
592
593        // Image and command
594        args.push(image.to_string());
595        args.extend(cmd.iter().cloned());
596
597        // Run the container
598        let output = Command::new(runtime_cmd)
599            .args(&args)
600            .output()
601            .context("Failed to run container")?;
602
603        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
604        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
605        let exit_code = output.status.code().unwrap_or(-1);
606
607        Ok(ExecResult {
608            exit_code,
609            stdout,
610            stderr,
611        })
612    }
613}
614
615impl Drop for DockerSandbox {
616    fn drop(&mut self) {
617        // Only clean up if running and not marked as persistent
618        if self.running && !self.persistent {
619            let container_name = self.container_name();
620            let _ = Command::new(self.runtime.cmd())
621                .args(["rm", "-f", &container_name])
622                .output();
623        }
624    }
625}