Skip to main content

agent_sandbox/
lib.rs

1//! `agent-sandbox` — pluggable execution sandbox for tool calls.
2//!
3//! Provides a provider abstraction so the same agent runtime can run under
4//! **Docker**, **Kata**, or **Cube** sandboxes. The **Docker** provider talks to
5//! the Docker **Engine API** through `bollard` — no `docker` CLI is required
6//! inside the container; they connect to the daemon over the mounted host socket
7//! (or whatever `DOCKER_HOST` points at). Kata is the same Engine API path but
8//! runs containers under the `kata` runtime (`HostConfig.runtime`). The **Cube**
9//! provider remains a thin CLI runner that shells out to the `cube` binary. The
10//! [`Sandbox`] trait is the stable
11//! seam the rest of the platform depends on
12//! (see `docs/adr/0003-sandbox-providers.md`).
13//!
14//! The deep codex integration (ADR-0005 §Decision) — running tool commands
15//! through codex's real `SandboxManager` — lives in the `aria-agent-cloud`
16//! runtime crate (`publish = false`) as `codex_sandbox::CodexSandbox`. Keeping
17//! it out of this published crate avoids dragging codex's unpublished git
18//! dependencies into the crates.io manifest (crates.io requires every
19//! dependency to resolve from the registry). `SandboxProvider::Codex` remains a
20//! valid selector so config stays forward-compatible; `from_provider` returns
21//! [`SandboxError::NotConfigured`] for it and the cloud runtime injects the real
22//! backend via `agent_core::Agent::with_sandbox`.
23
24use async_trait::async_trait;
25use bollard::container::{
26    Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
27};
28use bollard::exec::{CreateExecOptions, StartExecResults};
29use bollard::{Docker, API_DEFAULT_VERSION};
30use serde::{Deserialize, Serialize};
31use std::env;
32use thiserror::Error;
33use tokio::sync::OnceCell;
34
35/// Resource limits applied to every sandbox container/VM.
36///
37/// Parsed once from `.env` (`SANDBOX_*`) at construction; any parse error falls
38/// back to conservative defaults instead of panicking — the sandbox must build
39/// on hosts where these vars are unset. The Docker/Kata backends push these
40/// straight into a bollard `HostConfig`; the Cube backend best-effort maps them
41/// to `cube` CLI flags.
42#[derive(Debug, Clone, Copy)]
43pub struct SandboxResourceLimits {
44    /// Logical CPUs (e.g. `1.0`, `0.5`).
45    pub cpus: f64,
46    /// Memory cap in bytes (human-readable units are parsed by [`parse_size`]).
47    pub memory_bytes: i64,
48    /// Maximum number of processes (pids cgroup).
49    pub pids_limit: i64,
50}
51
52impl SandboxResourceLimits {
53    /// Read the limits from the process environment, falling back to
54    /// `cpus=1.0`, `memory=512m`, `pids_limit=256` on any parse error.
55    pub fn from_env() -> Self {
56        Self {
57            cpus: parse_cpu(env::var("SANDBOX_CPUS").ok(), 1.0),
58            memory_bytes: parse_memory(env::var("SANDBOX_MEMORY").ok(), 512 * 1024 * 1024),
59            pids_limit: parse_pids(env::var("SANDBOX_PIDS_LIMIT").ok(), 256),
60        }
61    }
62}
63
64/// Build a bollard `HostConfig` carrying the resource limits. `runtime` is only
65/// set for Kata (`"kata"`); `None` means the default Docker runtime.
66///
67/// `memory_swap = -1` disables swap accounting so a memory cap does not trip the
68/// cgroup "memory swap must be >= memory" constraint.
69pub fn build_host_config(
70    res: &SandboxResourceLimits,
71    runtime: Option<&str>,
72) -> bollard::service::HostConfig {
73    bollard::service::HostConfig {
74        nano_cpus: Some((res.cpus * 1e9).round() as i64),
75        memory: Some(res.memory_bytes),
76        memory_swap: Some(-1),
77        pids_limit: Some(res.pids_limit),
78        runtime: runtime.map(|s| s.to_string()),
79        ..Default::default()
80    }
81}
82
83fn parse_cpu(raw: Option<String>, default: f64) -> f64 {
84    match raw {
85        Some(s) => match s.trim().parse::<f64>() {
86            Ok(v) if v > 0.0 => v,
87            _ => {
88                tracing::warn!(value = %s.trim(), default, "invalid SANDBOX_CPUS; using default");
89                default
90            }
91        },
92        None => default,
93    }
94}
95
96fn parse_memory(raw: Option<String>, default: i64) -> i64 {
97    match raw {
98        Some(s) => match parse_size(&s) {
99            Some(v) => v,
100            None => {
101                tracing::warn!(value = %s.trim(), default, "invalid SANDBOX_MEMORY; using default");
102                default
103            }
104        },
105        None => default,
106    }
107}
108
109fn parse_pids(raw: Option<String>, default: i64) -> i64 {
110    match raw {
111        Some(s) => match s.trim().parse::<i64>() {
112            Ok(v) if v > 0 => v,
113            _ => {
114                tracing::warn!(value = %s.trim(), default, "invalid SANDBOX_PIDS_LIMIT; using default");
115                default
116            }
117        },
118        None => default,
119    }
120}
121
122/// Parse a human-readable size (`512m`, `1g`, `1024`) into bytes. No unit or `b`
123/// means bytes; `k/m/g/t` are powers of 1024. Returns `None` on any error or a
124/// non-positive value.
125fn parse_size(s: &str) -> Option<i64> {
126    let s = s.trim();
127    let split = s
128        .find(|c: char| !c.is_ascii_digit() && c != '.')
129        .unwrap_or(s.len());
130    let (num, unit) = s.split_at(split);
131    let value: f64 = num.trim().parse().ok()?;
132    let mult: i64 = match unit.trim().to_ascii_lowercase().as_str() {
133        "" | "b" => 1,
134        "k" => 1024,
135        "m" => 1024 * 1024,
136        "g" => 1024 * 1024 * 1024,
137        "t" => 1024 * 1024 * 1024 * 1024,
138        _ => return None,
139    };
140    if value <= 0.0 {
141        return None;
142    }
143    Some((value * mult as f64) as i64)
144}
145
146/// Specification of a command to execute inside a sandbox.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ExecSpec {
149    /// Container/VM image to run (provider-specific default if `None`).
150    pub image: Option<String>,
151    /// The command and its arguments.
152    pub command: Vec<String>,
153    /// Working directory inside the sandbox.
154    pub workdir: Option<String>,
155    /// Environment variables.
156    pub env: Vec<(String, String)>,
157    /// Wall-clock timeout in milliseconds.
158    pub timeout_ms: u64,
159}
160
161impl ExecSpec {
162    pub fn command(command: Vec<String>) -> Self {
163        Self {
164            image: None,
165            command,
166            workdir: None,
167            env: Vec::new(),
168            timeout_ms: 60_000,
169        }
170    }
171}
172
173/// Result of an execution.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ExecOutput {
176    pub exit_code: i32,
177    pub stdout: String,
178    pub stderr: String,
179}
180
181/// Opaque handle to a spawned sandbox session.
182#[derive(Debug, Clone)]
183pub struct SandboxHandle {
184    pub id: String,
185}
186
187#[derive(Debug, Error)]
188pub enum SandboxError {
189    #[error("sandbox spawn failed: {0}")]
190    Spawn(String),
191    #[error("sandbox exec failed: {0}")]
192    Exec(String),
193    #[error("sandbox not configured: {0}")]
194    NotConfigured(String),
195    #[error("io error: {0}")]
196    Io(#[from] std::io::Error),
197}
198
199/// Stable sandbox contract used by `agent-core` and `agent-cloud`.
200#[async_trait]
201pub trait Sandbox: Send + Sync {
202    /// Spawn a persistent sandbox session and return a handle.
203    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError>;
204    /// Execute `cmd` inside a previously spawned session.
205    async fn exec(
206        &self,
207        handle: &SandboxHandle,
208        cmd: &[String],
209    ) -> Result<ExecOutput, SandboxError>;
210    /// Tear down a sandbox session.
211    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError>;
212}
213
214/// Which sandbox backend to use.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum SandboxProvider {
217    Docker,
218    Kata,
219    Cube,
220    /// Deep codex integration: run tool commands through codex's `SandboxManager`
221    /// (ADR-0005). This is the production backend for the agent runtime.
222    Codex,
223}
224
225impl SandboxProvider {
226    pub fn parse(s: &str) -> Option<Self> {
227        match s.to_ascii_lowercase().as_str() {
228            "docker" => Some(SandboxProvider::Docker),
229            "kata" => Some(SandboxProvider::Kata),
230            "cube" => Some(SandboxProvider::Cube),
231            "codex" => Some(SandboxProvider::Codex),
232            _ => None,
233        }
234    }
235
236    pub fn as_str(&self) -> &'static str {
237        match self {
238            SandboxProvider::Docker => "docker",
239            SandboxProvider::Kata => "kata",
240            SandboxProvider::Cube => "cube",
241            SandboxProvider::Codex => "codex",
242        }
243    }
244}
245
246/// Generic CLI-backed sandbox. `runner` is the binary (e.g. `docker`);
247/// `run_args` are the args inserted before the image (e.g. `run --rm`, or
248/// `run --runtime=kata --rm`).
249struct CliSandbox {
250    provider: SandboxProvider,
251    runner: String,
252    run_args: Vec<String>,
253    default_image: String,
254}
255
256impl CliSandbox {
257    fn new(
258        provider: SandboxProvider,
259        runner: &str,
260        run_args: Vec<String>,
261        default_image: &str,
262    ) -> Self {
263        Self {
264            provider,
265            runner: runner.to_string(),
266            run_args,
267            default_image: default_image.to_string(),
268        }
269    }
270
271    fn image_of(&self, spec: &ExecSpec) -> String {
272        spec.image
273            .clone()
274            .unwrap_or_else(|| self.default_image.clone())
275    }
276
277    /// CLI args inserted before the image (introspection aid for tests).
278    #[cfg(test)]
279    fn run_args(&self) -> &[String] {
280        &self.run_args
281    }
282}
283
284#[async_trait]
285impl Sandbox for CliSandbox {
286    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
287        let image = self.image_of(spec);
288        // Create a long-lived session container; real commands run via `exec`.
289        let mut cmd = tokio::process::Command::new(&self.runner);
290        cmd.args(&self.run_args).arg(&image).args(["sleep", "3600"]);
291        let out = cmd
292            .output()
293            .await
294            .map_err(|e| SandboxError::Spawn(e.to_string()))?;
295        if !out.status.success() {
296            return Err(SandboxError::Spawn(
297                String::from_utf8_lossy(&out.stderr).to_string(),
298            ));
299        }
300        let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
301        Ok(SandboxHandle { id })
302    }
303
304    async fn exec(
305        &self,
306        handle: &SandboxHandle,
307        cmd: &[String],
308    ) -> Result<ExecOutput, SandboxError> {
309        if cmd.is_empty() {
310            return Err(SandboxError::Exec("empty command".into()));
311        }
312        let joined = shell_join(cmd);
313        let mut command = tokio::process::Command::new(&self.runner);
314        command
315            .arg("exec")
316            .arg(&handle.id)
317            .args(["sh", "-c", &joined]);
318        let out = command
319            .output()
320            .await
321            .map_err(|e| SandboxError::Exec(e.to_string()))?;
322        Ok(ExecOutput {
323            exit_code: out.status.code().unwrap_or(-1),
324            stdout: String::from_utf8_lossy(&out.stdout).to_string(),
325            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
326        })
327    }
328
329    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
330        let mut command = tokio::process::Command::new(&self.runner);
331        command.arg("rm").arg("-f").arg(&handle.id);
332        let out = command
333            .output()
334            .await
335            .map_err(|e| SandboxError::Exec(e.to_string()))?;
336        if !out.status.success() {
337            tracing::warn!(
338                provider = self.provider.as_str(),
339                stderr = %String::from_utf8_lossy(&out.stderr),
340                "sandbox destroy reported a non-zero status"
341            );
342        }
343        Ok(())
344    }
345}
346
347/// Connect timeout (seconds) used when probing Docker sockets.
348const DOCKER_TIMEOUT_SECS: u64 = 120;
349
350/// Well-known Docker daemon socket locations, probed in order.
351///
352/// `/var/run/docker.sock` is the classic Linux location, but it is **not**
353/// universal: Docker Desktop on macOS exposes the daemon at
354/// `~/.docker/run/docker.sock` (and only symlinks `/var/run/docker.sock` when
355/// the "default socket" option is enabled), Colima uses
356/// `~/.colima/default/docker.sock`, and rootless setups (Docker Desktop on
357/// Linux, Podman) live under `XDG_RUNTIME_DIR`. `ARIA_DOCKER_SOCKET` and
358/// `DOCKER_HOST` (`unix://…`) always win when set.
359fn candidate_sockets() -> Vec<String> {
360    let mut out: Vec<String> = Vec::new();
361    let mut push = |p: String| {
362        let p = p.trim().to_string();
363        if !p.is_empty() && !out.contains(&p) {
364            out.push(p);
365        }
366    };
367
368    if let Ok(p) = env::var("ARIA_DOCKER_SOCKET") {
369        push(p);
370    }
371    if let Ok(h) = env::var("DOCKER_HOST") {
372        if let Some(rest) = h.strip_prefix("unix://") {
373            push(rest.to_string());
374        }
375    }
376
377    #[cfg(unix)]
378    {
379        push("/var/run/docker.sock".into());
380        if let Ok(home) = env::var("HOME") {
381            // Docker Desktop (macOS) and Colima.
382            push(format!("{home}/.docker/run/docker.sock"));
383            push(format!("{home}/.colima/default/docker.sock"));
384        }
385        if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
386            // Rootless Docker Desktop (Linux) and rootless Podman.
387            push(format!("{xdg}/.docker/run/docker.sock"));
388            push(format!("{xdg}/podman/podman.sock"));
389        }
390        push("/run/podman/podman.sock".into());
391    }
392
393    out
394}
395
396/// Try each candidate socket in order and return the first connection that
397/// resolves. This only *constructs* a client (the socket file must exist); no
398/// daemon round-trip is performed, so a stale socket still fails later at
399/// `spawn`/`exec` with a normal [`SandboxError`].
400fn connect_first(sockets: &[String]) -> Result<Docker, SandboxError> {
401    let mut last: Option<String> = None;
402    for path in sockets {
403        match Docker::connect_with_socket(path, DOCKER_TIMEOUT_SECS, API_DEFAULT_VERSION) {
404            Ok(docker) => {
405                tracing::debug!(socket = %path, "connected to the Docker daemon");
406                return Ok(docker);
407            }
408            Err(e) => {
409                tracing::debug!(socket = %path, error = %e, "docker socket unavailable");
410                last = Some(e.to_string());
411            }
412        }
413    }
414    Err(SandboxError::NotConfigured(format!(
415        "no reachable Docker daemon: set DOCKER_HOST (or ARIA_DOCKER_SOCKET) to your daemon socket; probed: [{}]{}",
416        sockets.join(", "),
417        last.map(|e| format!(" (last error: {e})")).unwrap_or_default(),
418    )))
419}
420
421/// Docker sandbox — the **default** provider.
422///
423/// Talks to the Docker **Engine API** through `bollard` — no `docker` CLI is
424/// required inside the container. This matches the playground's deployment model:
425/// the Compose `cloud` service mounts the host `/var/run/docker.sock` (DooD) and
426/// the API is reached directly over that socket (or whatever `DOCKER_HOST`
427/// points at). `DOCKER_HOST` is honored (e.g. `unix:///var/run/docker.sock` or a
428/// remote `tcp://…`); when unset the well-known socket locations are probed.
429///
430/// The daemon connection is **lazy**: constructing a sandbox never panics and
431/// never fails, so the agent runtime (and the whole test suite) works on
432/// machines without a reachable daemon — including macOS, where Docker Desktop
433/// does not expose `/var/run/docker.sock` by default. The connection is
434/// established on the first `spawn`/`exec`/`destroy`, which then returns
435/// [`SandboxError::NotConfigured`] if no daemon could be reached.
436pub struct DockerSandbox {
437    docker: OnceCell<Docker>,
438    /// Explicit socket override; when set, discovery is skipped entirely.
439    sockets: Option<Vec<String>>,
440    /// Resource limits applied to every spawned sandbox container.
441    resources: SandboxResourceLimits,
442    /// Optional container runtime (e.g. `"kata"`). `None` = default runtime.
443    runtime: Option<String>,
444}
445
446impl DockerSandbox {
447    pub fn new() -> Self {
448        Self {
449            docker: OnceCell::new(),
450            sockets: None,
451            resources: SandboxResourceLimits::from_env(),
452            runtime: None,
453        }
454    }
455
456    /// Return the pinned container runtime, if any (e.g. `"kata"`). `None`
457    /// means the daemon's default runtime.
458    pub fn runtime(&self) -> Option<&str> {
459        self.runtime.as_deref()
460    }
461
462    /// Pin the sandbox to one socket path (no `DOCKER_HOST`/auto-discovery).
463    pub fn with_socket(path: impl Into<String>) -> Self {
464        Self {
465            docker: OnceCell::new(),
466            sockets: Some(vec![path.into()]),
467            resources: SandboxResourceLimits::from_env(),
468            runtime: None,
469        }
470    }
471
472    /// Build a sandbox that runs containers under a specific Docker runtime
473    /// (e.g. `"kata"`). Used by [`KataSandbox`]; reuses the same bollard client
474    /// and the same [`SandboxResourceLimits`] as the default Docker provider.
475    pub fn with_runtime(runtime: impl Into<String>) -> Self {
476        Self {
477            docker: OnceCell::new(),
478            sockets: None,
479            resources: SandboxResourceLimits::from_env(),
480            runtime: Some(runtime.into()),
481        }
482    }
483
484    /// Resolve (and memoize) the daemon client.
485    pub async fn client(&self) -> Result<&Docker, SandboxError> {
486        self.docker
487            .get_or_try_init(|| async {
488                match &self.sockets {
489                    Some(sockets) => connect_first(sockets),
490                    // `connect_with_local_defaults` honors `DOCKER_HOST` (including
491                    // `tcp://…` and the Windows named pipe) and TLS settings.
492                    None => match Docker::connect_with_local_defaults() {
493                        Ok(docker) => Ok(docker),
494                        Err(_) => connect_first(&candidate_sockets()),
495                    },
496                }
497            })
498            .await
499    }
500}
501
502#[async_trait]
503impl Sandbox for DockerSandbox {
504    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
505        let docker = self.client().await?;
506        let image = spec
507            .image
508            .clone()
509            .unwrap_or_else(|| "alpine:latest".to_string());
510        let name = format!("aria-sandbox-{}", uuid::Uuid::new_v4());
511        docker
512            .create_container(
513                Some(CreateContainerOptions {
514                    name: &name,
515                    platform: None,
516                }),
517                Config {
518                    image: Some(image),
519                    cmd: Some(vec!["sleep".to_string(), "3600".to_string()]),
520                    tty: Some(false),
521                    env: Some(env_to_docker(&spec.env)),
522                    working_dir: spec.workdir.clone(),
523                    host_config: Some(build_host_config(&self.resources, self.runtime.as_deref())),
524                    ..Default::default()
525                },
526            )
527            .await
528            .map_err(|e| SandboxError::Spawn(e.to_string()))?;
529        docker
530            .start_container(&name, None::<StartContainerOptions<String>>)
531            .await
532            .map_err(|e| SandboxError::Spawn(e.to_string()))?;
533        Ok(SandboxHandle { id: name })
534    }
535
536    async fn exec(
537        &self,
538        handle: &SandboxHandle,
539        cmd: &[String],
540    ) -> Result<ExecOutput, SandboxError> {
541        if cmd.is_empty() {
542            return Err(SandboxError::Exec("empty command".into()));
543        }
544        let joined = shell_join(cmd);
545        let docker = self.client().await?;
546        let exec = docker
547            .create_exec(
548                &handle.id,
549                CreateExecOptions {
550                    cmd: Some(vec!["sh".to_string(), "-c".to_string(), joined]),
551                    attach_stdout: Some(true),
552                    attach_stderr: Some(true),
553                    ..Default::default()
554                },
555            )
556            .await
557            .map_err(|e| SandboxError::Exec(e.to_string()))?;
558
559        let id = exec.id.clone();
560        match docker
561            .start_exec(&id, None)
562            .await
563            .map_err(|e| SandboxError::Exec(e.to_string()))?
564        {
565            StartExecResults::Attached { mut output, .. } => {
566                let mut stdout = String::new();
567                let mut stderr = String::new();
568                use futures::StreamExt;
569                while let Some(frame) = output.next().await {
570                    match frame.map_err(|e| SandboxError::Exec(e.to_string()))? {
571                        bollard::container::LogOutput::StdOut { message } => {
572                            stdout.push_str(&String::from_utf8_lossy(&message));
573                        }
574                        bollard::container::LogOutput::StdErr { message } => {
575                            stderr.push_str(&String::from_utf8_lossy(&message));
576                        }
577                        _ => {}
578                    }
579                }
580                let exit_code = docker
581                    .inspect_exec(&id)
582                    .await
583                    .map(|r| r.exit_code.unwrap_or(-1) as i32)
584                    .unwrap_or(-1);
585                Ok(ExecOutput {
586                    exit_code,
587                    stdout,
588                    stderr,
589                })
590            }
591            StartExecResults::Detached => Err(SandboxError::Exec(
592                "docker exec returned a detached stream".into(),
593            )),
594        }
595    }
596
597    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
598        let docker = self.client().await?;
599        docker
600            .remove_container(
601                &handle.id,
602                Some(RemoveContainerOptions {
603                    force: true,
604                    ..Default::default()
605                }),
606            )
607            .await
608            .map_err(|e| SandboxError::Exec(e.to_string()))?;
609        Ok(())
610    }
611}
612
613/// Convert `(KEY, VALUE)` pairs into Docker's `KEY=VALUE` env strings.
614fn env_to_docker(env: &[(String, String)]) -> Vec<String> {
615    env.iter().map(|(k, v)| format!("{k}={v}")).collect()
616}
617
618/// Kata sandbox — Docker Engine API with the `kata` runtime.
619///
620/// Kata containers are started exactly like Docker containers but with
621/// `HostConfig.runtime = "kata"`, so they reuse the same bollard client and the
622/// same [`SandboxResourceLimits`] as the default Docker provider. No `docker`
623/// CLI is involved.
624pub struct KataSandbox {
625    inner: DockerSandbox,
626}
627
628impl KataSandbox {
629    pub fn new() -> Self {
630        Self {
631            inner: DockerSandbox::with_runtime("kata"),
632        }
633    }
634
635    /// Runtime this Kata sandbox pins (`"kata"`).
636    pub fn runtime(&self) -> Option<&str> {
637        self.inner.runtime()
638    }
639}
640
641#[async_trait]
642impl Sandbox for KataSandbox {
643    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
644        self.inner.spawn(spec).await
645    }
646    async fn exec(
647        &self,
648        handle: &SandboxHandle,
649        cmd: &[String],
650    ) -> Result<ExecOutput, SandboxError> {
651        self.inner.exec(handle, cmd).await
652    }
653    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
654        self.inner.destroy(handle).await
655    }
656}
657
658/// Cube sandbox — shells out to the `cube` CLI. The exact flags depend on the
659/// deployed Cube runtime; adjust `run_args` per environment.
660pub struct CubeSandbox {
661    inner: CliSandbox,
662}
663
664impl CubeSandbox {
665    pub fn new() -> Self {
666        let res = SandboxResourceLimits::from_env();
667        // `cube` is a standalone microVM runtime with its own CLI; it does **not**
668        // speak the Docker Engine API, so resource limits are best-effort here.
669        // These flags mirror docker-style options and may need adjustment for
670        // your deployed Cube runtime.
671        let run_args = vec![
672            "sandbox".into(),
673            "run".into(),
674            "--rm".into(),
675            "--cpus".into(),
676            res.cpus.to_string(),
677            "--memory".into(),
678            res.memory_bytes.to_string(),
679        ];
680        Self {
681            inner: CliSandbox::new(SandboxProvider::Cube, "cube", run_args, "cube-image:latest"),
682        }
683    }
684
685    /// Resource flags this Cube sandbox passes to the `cube` CLI. Introspection
686    /// aid for tests; the Cube runtime may require different flags per
687    /// deployment, so this is not part of the public contract.
688    #[cfg(test)]
689    pub(crate) fn run_args(&self) -> &[String] {
690        self.inner.run_args()
691    }
692}
693
694#[async_trait]
695impl Sandbox for CubeSandbox {
696    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
697        self.inner.spawn(spec).await
698    }
699    async fn exec(
700        &self,
701        handle: &SandboxHandle,
702        cmd: &[String],
703    ) -> Result<ExecOutput, SandboxError> {
704        self.inner.exec(handle, cmd).await
705    }
706    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
707        self.inner.destroy(handle).await
708    }
709}
710
711impl Default for DockerSandbox {
712    fn default() -> Self {
713        Self::new()
714    }
715}
716
717impl Default for KataSandbox {
718    fn default() -> Self {
719        Self::new()
720    }
721}
722
723impl Default for CubeSandbox {
724    fn default() -> Self {
725        Self::new()
726    }
727}
728
729/// The platform default sandbox: Docker.
730pub fn default_sandbox() -> Box<dyn Sandbox> {
731    Box::new(DockerSandbox::new())
732}
733
734/// Build a sandbox from a provider selector (config-driven).
735///
736/// Returns [`SandboxError::NotConfigured`] for [`SandboxProvider::Codex`]: the
737/// codex backend (`codex_sandbox::CodexSandbox`) is provided by the
738/// `aria-agent-cloud` runtime (publish = false) so the published SDK stays free
739/// of codex's unpublished git dependencies. The cloud runtime injects it via
740/// `agent_core::Agent::with_sandbox`.
741pub fn from_provider(provider: SandboxProvider) -> Result<Box<dyn Sandbox>, SandboxError> {
742    match provider {
743        SandboxProvider::Docker => Ok(Box::new(DockerSandbox::new())),
744        SandboxProvider::Kata => Ok(Box::new(KataSandbox::new())),
745        SandboxProvider::Cube => Ok(Box::new(CubeSandbox::new())),
746        SandboxProvider::Codex => Err(SandboxError::NotConfigured(
747            "codex sandbox backend is provided by the aria-agent-cloud runtime; \
748             construct it there and inject via Agent::with_sandbox"
749                .into(),
750        )),
751    }
752}
753
754fn shell_join(cmd: &[String]) -> String {
755    cmd.iter()
756        .map(|a| {
757            if a.contains(char::is_whitespace) || a.contains('"') || a.contains('\'') {
758                format!("'{}'", a.replace('\'', "'\\''"))
759            } else {
760                a.clone()
761            }
762        })
763        .collect::<Vec<_>>()
764        .join(" ")
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn default_is_docker() {
773        let s = default_sandbox();
774        // type-erased; just ensure construction does not panic
775        let _ = s;
776    }
777
778    #[test]
779    fn docker_sandbox_construction_never_panics() {
780        // Regression: on macOS the daemon socket is not `/var/run/docker.sock`,
781        // and construction used to `expect()` on that path.
782        let _ = DockerSandbox::new();
783        let _ = DockerSandbox::with_socket("/definitely/not/a/docker.sock");
784        let _ = default_sandbox();
785    }
786
787    #[cfg(unix)]
788    #[test]
789    fn candidate_sockets_include_desktop_and_colima() {
790        let cands = candidate_sockets();
791        assert!(cands.contains(&"/var/run/docker.sock".to_string()));
792        if let Ok(home) = env::var("HOME") {
793            // Docker Desktop (macOS) keeps the daemon here by default.
794            assert!(cands.contains(&format!("{home}/.docker/run/docker.sock")));
795            assert!(cands.contains(&format!("{home}/.colima/default/docker.sock")));
796        }
797        // No duplicates.
798        let mut seen = std::collections::HashSet::new();
799        for c in &cands {
800            assert!(seen.insert(c.clone()), "duplicate candidate: {c}");
801        }
802    }
803
804    #[tokio::test]
805    async fn resolve_docker_returns_error_instead_of_panicking() {
806        // Whether or not a daemon exists locally, resolution must not panic;
807        // when it fails it must be a `NotConfigured` naming the env override.
808        match DockerSandbox::new().client().await {
809            Ok(_) => {}
810            Err(SandboxError::NotConfigured(msg)) => assert!(msg.contains("DOCKER_HOST")),
811            Err(other) => panic!("unexpected error: {other}"),
812        }
813    }
814
815    #[tokio::test]
816    async fn unreachable_daemon_surfaces_error_not_panic() {
817        let sandbox = DockerSandbox::with_socket("/definitely/not/a/docker.sock");
818        let spec = ExecSpec::command(vec!["true".into()]);
819        let res = sandbox.spawn(&spec).await;
820        assert!(res.is_err(), "spawn must fail when no daemon is reachable");
821        let err = sandbox
822            .exec(&SandboxHandle { id: "nope".into() }, &["true".into()])
823            .await;
824        assert!(err.is_err());
825        let destroyed = sandbox.destroy(SandboxHandle { id: "nope".into() }).await;
826        assert!(destroyed.is_err());
827    }
828
829    #[test]
830    fn provider_parsing() {
831        assert_eq!(
832            SandboxProvider::parse("docker"),
833            Some(SandboxProvider::Docker)
834        );
835        assert_eq!(SandboxProvider::parse("KATA"), Some(SandboxProvider::Kata));
836        assert_eq!(SandboxProvider::parse("cube"), Some(SandboxProvider::Cube));
837        assert_eq!(SandboxProvider::parse("podman"), None);
838        let _ = uuid::Uuid::new_v4();
839    }
840
841    #[test]
842    fn shell_join_quotes() {
843        assert_eq!(
844            shell_join(&["echo".into(), "hello world".into()]),
845            "echo 'hello world'"
846        );
847    }
848
849    #[test]
850    fn exec_spec_command_defaults() {
851        let s = ExecSpec::command(vec!["echo".into(), "hi".into()]);
852        assert_eq!(s.command, vec!["echo", "hi"]);
853        assert_eq!(s.timeout_ms, 60_000);
854        assert!(s.image.is_none());
855        assert!(s.workdir.is_none());
856        assert!(s.env.is_empty());
857    }
858
859    #[test]
860    fn shell_join_empty_and_escapes() {
861        assert_eq!(shell_join(&[]), "");
862        // A token containing a double-quote is single-quoted (no inner escaping needed).
863        assert_eq!(shell_join(&["a\"b".into()]), "'a\"b'");
864        // A token containing a single-quote is escaped as '\''.
865        assert_eq!(shell_join(&["it's".into()]), "'it'\\''s'");
866    }
867
868    #[test]
869    fn provider_as_str_roundtrip() {
870        for p in [
871            SandboxProvider::Docker,
872            SandboxProvider::Kata,
873            SandboxProvider::Cube,
874            SandboxProvider::Codex,
875        ] {
876            let s = p.as_str();
877            assert_eq!(SandboxProvider::parse(s), Some(p));
878        }
879    }
880
881    #[test]
882    fn provider_parse_is_case_insensitive_and_rejects_unknown() {
883        assert_eq!(
884            SandboxProvider::parse("DOCKER"),
885            Some(SandboxProvider::Docker)
886        );
887        assert_eq!(SandboxProvider::parse("Kata"), Some(SandboxProvider::Kata));
888        assert_eq!(
889            SandboxProvider::parse("codex"),
890            Some(SandboxProvider::Codex)
891        );
892        assert_eq!(SandboxProvider::parse("podman"), None);
893        assert_eq!(SandboxProvider::parse(""), None);
894    }
895
896    #[test]
897    fn from_provider_builds_all_published_variants() {
898        for p in [
899            SandboxProvider::Docker,
900            SandboxProvider::Kata,
901            SandboxProvider::Cube,
902        ] {
903            let _ = from_provider(p).expect("published provider must build");
904        }
905        // `codex` is intentionally not constructible here (backend lives in the
906        // cloud runtime); the selector is still parsed/serialized for config compat.
907        assert!(matches!(
908            from_provider(SandboxProvider::Codex),
909            Err(SandboxError::NotConfigured(_))
910        ));
911        let _ = CubeSandbox::new();
912        let _ = KataSandbox::new();
913    }
914
915    #[test]
916    fn resource_limit_parsers_default_and_reject_bad_input() {
917        // cpus
918        assert_eq!(parse_cpu(Some("1.5".into()), 1.0), 1.5);
919        assert_eq!(parse_cpu(Some("0".into()), 1.0), 1.0);
920        assert_eq!(parse_cpu(Some("nope".into()), 2.0), 2.0);
921        assert_eq!(parse_cpu(None, 1.0), 1.0);
922        // memory
923        assert_eq!(parse_memory(Some("512m".into()), 0), 512 * 1024 * 1024);
924        assert_eq!(parse_memory(Some("1g".into()), 0), 1024 * 1024 * 1024);
925        assert_eq!(parse_memory(Some("1024".into()), 0), 1024);
926        assert_eq!(parse_memory(Some("bad".into()), 999), 999);
927        assert_eq!(parse_memory(None, 123), 123);
928        // pids
929        assert_eq!(parse_pids(Some("100".into()), 256), 100);
930        assert_eq!(parse_pids(Some("-1".into()), 256), 256);
931        assert_eq!(parse_pids(None, 256), 256);
932    }
933
934    #[test]
935    fn kata_sandbox_wraps_docker_with_kata_runtime() {
936        // Kata must build without a daemon and wrap a Docker-backed sandbox.
937        let _ = KataSandbox::new();
938    }
939
940    #[test]
941    fn kata_runtime_is_kata() {
942        // Kata must pin the Docker runtime to "kata" and build without a daemon.
943        let kata = KataSandbox::new();
944        assert_eq!(kata.runtime(), Some("kata"));
945        let _ = kata;
946    }
947
948    #[test]
949    fn cube_and_kata_construction_never_panics() {
950        let _ = KataSandbox::new();
951        let _ = CubeSandbox::new();
952    }
953
954    #[test]
955    fn cube_sandbox_maps_resource_limits_to_cli_flags() {
956        // The Cube CLI must carry the parsed SANDBOX_* values into its run args.
957        // Computed against `from_env()` so the expectation stays in sync with the
958        // actual parsing (deterministic when the vars are unset).
959        let cube = CubeSandbox::new();
960        let res = SandboxResourceLimits::from_env();
961        let expected = [
962            "sandbox".to_string(),
963            "run".to_string(),
964            "--rm".to_string(),
965            "--cpus".to_string(),
966            res.cpus.to_string(),
967            "--memory".to_string(),
968            res.memory_bytes.to_string(),
969        ];
970        assert_eq!(cube.run_args(), &expected[..]);
971    }
972
973    #[tokio::test]
974    async fn kata_spawn_surfaces_error_without_daemon() {
975        // Kata shares the bollard path with Docker: spawn/exec/destroy must
976        // surface an error (not panic) when no daemon is reachable.
977        let kata = KataSandbox::new();
978        assert_eq!(kata.runtime(), Some("kata"));
979        let spec = ExecSpec::command(vec!["true".into()]);
980        let res = kata.spawn(&spec).await;
981        assert!(
982            res.is_err(),
983            "kata spawn must fail when no daemon is reachable"
984        );
985    }
986
987    #[tokio::test]
988    async fn cube_missing_binary_surfaces_error_not_panic() {
989        // Model the `cube` CLI being absent: route the same CliSandbox path that
990        // `CubeSandbox` uses through a non-existent runner. spawn/exec must fail
991        // with an error (not panic); destroy must never panic.
992        let sandbox = CliSandbox::new(
993            SandboxProvider::Cube,
994            "definitely-no-such-binary-xyz",
995            vec!["sandbox".into(), "run".into(), "--rm".into()],
996            "cube-image:latest",
997        );
998        let spec = ExecSpec::command(vec!["true".into()]);
999        let res = sandbox.spawn(&spec).await;
1000        assert!(
1001            res.is_err(),
1002            "spawn must error when the cube binary is missing"
1003        );
1004        let err = sandbox
1005            .exec(&SandboxHandle { id: "nope".into() }, &["true".into()])
1006            .await;
1007        assert!(
1008            err.is_err(),
1009            "exec must error when the cube binary is missing"
1010        );
1011        // destroy logs-and-continues on failure; it must not panic.
1012        let _ = sandbox.destroy(SandboxHandle { id: "nope".into() }).await;
1013    }
1014
1015    #[cfg(unix)]
1016    #[tokio::test]
1017    async fn cube_invalid_invocation_surfaces_error_not_panic() {
1018        // Model the `cube` CLI being present but rejecting our flags (or failing
1019        // for any reason): `false` is a real binary that always exits non-zero,
1020        // exercising the non-success -> Spawn error path through the same
1021        // CliSandbox that `CubeSandbox` uses. spawn/exec must surface an error,
1022        // never panic.
1023        let sandbox = CliSandbox::new(
1024            SandboxProvider::Cube,
1025            "false",
1026            vec!["sandbox".into(), "run".into(), "--rm".into()],
1027            "cube-image:latest",
1028        );
1029        let spec = ExecSpec::command(vec!["true".into()]);
1030        let res = sandbox.spawn(&spec).await;
1031        assert!(
1032            res.is_err(),
1033            "spawn must error when the cube invocation fails"
1034        );
1035        // exec spawns the present binary fine (no panic); the failed invocation
1036        // is reflected in the exit code, not as a spawn error. Only `spawn`
1037        // guards on success.
1038        let out = sandbox
1039            .exec(&SandboxHandle { id: "nope".into() }, &["true".into()])
1040            .await;
1041        assert!(
1042            out.is_ok(),
1043            "exec must not panic when the cube binary is present"
1044        );
1045        assert_eq!(out.unwrap().exit_code, 1);
1046        // destroy logs-and-continues on failure; it must not panic.
1047        let _ = sandbox.destroy(SandboxHandle { id: "nope".into() }).await;
1048    }
1049}