1use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use std::process::Command;
6
7use super::{BackendType, ExecOptions, ExecResult, Sandbox, SandboxConfig};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ContainerRuntime {
12 Docker,
13 Podman,
14}
15
16impl ContainerRuntime {
17 pub fn cmd(&self) -> &'static str {
19 match self {
20 ContainerRuntime::Docker => "docker",
21 ContainerRuntime::Podman => "podman",
22 }
23 }
24
25 pub fn to_backend_type(self) -> BackendType {
27 match self {
28 ContainerRuntime::Docker => BackendType::Docker,
29 ContainerRuntime::Podman => BackendType::Podman,
30 }
31 }
32}
33
34pub 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
43pub 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
52pub 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
70pub 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
81pub struct DockerSandbox {
83 name: String,
84 runtime: ContainerRuntime,
85 container_id: Option<String>,
86 running: bool,
87 persistent: bool,
89}
90
91impl DockerSandbox {
92 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 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 pub fn set_persistent(&mut self, persistent: bool) {
116 self.persistent = persistent;
117 }
118
119 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 fn container_name(&self) -> String {
128 format!("agentkernel-{}", self.name)
129 }
130}
131
132impl DockerSandbox {
133 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 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 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 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 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 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 let temp_dir = std::env::temp_dir();
178 let temp_file = temp_dir.join(format!("agentkernel-download-{}", uuid::Uuid::new_v4()));
179
180 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 let content = std::fs::read(&temp_file).context("Failed to read temp file")?;
194
195 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 let _ = Command::new(cmd)
210 .args(["rm", "-f", &container_name])
211 .output();
212
213 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 args.push(format!("--cpus={}", config.vcpus));
227 args.push(format!("--memory={}m", config.memory_mb));
228
229 if !config.network {
231 args.push("--network=none".to_string());
232 }
233
234 for pm in &config.ports {
236 args.push("-p".to_string());
237 args.push(pm.to_string());
238 }
239
240 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 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 for volume_spec in &config.volumes {
260 args.push("-v".to_string());
261 args.push(volume_spec.clone());
262 }
263
264 if config.read_only {
266 args.push("--read-only".to_string());
267 }
268
269 for (key, value) in &config.env {
271 args.push("-e".to_string());
272 args.push(format!("{}={}", key, value));
273 }
274
275 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 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 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 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 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 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 let record_path = std::env::var("AGENTKERNEL_RECORD").ok();
487
488 let status = if let Some(ref cast_path) = record_path {
489 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 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(), ];
548
549 args.push(format!("--cpus={}", config.vcpus));
551 args.push(format!("--memory={}m", config.memory_mb));
552
553 if !config.network {
555 args.push("--network=none".to_string());
556 }
557
558 for pm in &config.ports {
560 args.push("-p".to_string());
561 args.push(pm.to_string());
562 }
563
564 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 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 if config.read_only {
584 args.push("--read-only".to_string());
585 }
586
587 for (key, value) in &config.env {
589 args.push("-e".to_string());
590 args.push(format!("{}={}", key, value));
591 }
592
593 args.push(image.to_string());
595 args.extend(cmd.iter().cloned());
596
597 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 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}