Skip to main content

leviath_cli/daemon/
sandbox_manager.rs

1//! Per-agent sandbox lifecycle and shell-command routing.
2//!
3//! A [`SandboxManager`] owns every sandbox an agent needs: it creates the
4//! containers its stages call for **eagerly at spawn** (blocking `docker run`,
5//! keyed and deduplicated by config signature so identical configs across stages
6//! share one warm container), routes each shell call to the *current* stage's
7//! sandbox, and tears every container down at reap. `namespace` and `none` kinds
8//! need no persistent state - they are pure per-exec command wrapping.
9//!
10//! It implements [`leviath_tools::ShellExecutor`], so the built-in shell tool
11//! runs its command inside the sandbox transparently; file tools stay on the
12//! host over the bind-mounted workdir. The create/dedup/error logic is factored
13//! behind an injected command-runner (`build_with`) so it is unit-testable
14//! with no container runtime installed.
15
16use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::path::Path;
19use std::process::Command as StdCommand;
20use std::sync::{Mutex as StdMutex, PoisonError};
21
22use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
23use leviath_sys::ContainerRunSpec;
24use leviath_tools::ShellExecutor;
25use tokio::process::Command as TokioCommand;
26
27/// POSIX shell used *inside* a container. Every image ships `/bin/sh`, whereas
28/// the host's detected shell (e.g. `/bin/zsh` on macOS) generally isn't present
29/// in the image - so container exec must use its own shell, not the host's.
30const CONTAINER_SHELL: &str = "sh";
31const CONTAINER_SHELL_FLAG: &str = "-c";
32
33/// Runs a sandbox lifecycle command (`docker run` / `docker rm`), returning the
34/// stderr text on failure. Injected so [`SandboxManager::build_with`] is testable
35/// without a real runtime.
36type CmdRunner<'a> = dyn Fn(&[String]) -> Result<(), String> + 'a;
37
38/// A container we started and are responsible for removing.
39#[derive(Debug, Clone)]
40struct LiveContainer {
41    /// The engine binary that started it (so teardown uses the same one).
42    engine: String,
43    name: String,
44}
45
46/// Owns an agent's sandboxes for its whole lifetime. Created at spawn, updated
47/// per stage via [`Self::set_stage`], torn down at reap via [`Self::destroy_all`].
48#[derive(Debug)]
49pub struct SandboxManager {
50    /// Live containers keyed by config signature; immutable after construction.
51    containers: HashMap<u64, LiveContainer>,
52    /// Per-stage-index resolved sandbox config; immutable after construction.
53    by_index: Vec<ToolSandboxConfig>,
54    /// Whether Linux namespaces are usable on this host - captured at build so
55    /// `build_command` (the `ShellExecutor` hot path) doesn't re-probe and both
56    /// its namespace arms are reachable regardless of the test platform.
57    namespace_ok: bool,
58    /// The current stage's config - the only mutable state, swapped on stage
59    /// change (the manager lives behind an `Arc`, so interior mutability).
60    current: StdMutex<ToolSandboxConfig>,
61}
62
63/// Signature identifying a distinct container: same engine + image + network +
64/// mounts → one shared warm container. (Deterministic within a process -
65/// `DefaultHasher` uses fixed keys.)
66fn signature(cfg: &ToolSandboxConfig) -> u64 {
67    let mut h = std::collections::hash_map::DefaultHasher::new();
68    cfg.engine.hash(&mut h);
69    cfg.image.hash(&mut h);
70    cfg.network.hash(&mut h);
71    cfg.mounts.hash(&mut h);
72    h.finish()
73}
74
75/// Reduce a run id to a Docker-safe name fragment (`[a-zA-Z0-9_.-]`).
76fn sanitize(run_id: &str) -> String {
77    run_id
78        .chars()
79        .map(|c| {
80            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
81                c
82            } else {
83                '-'
84            }
85        })
86        .collect()
87}
88
89/// Build the host shell command (no sandbox) - the exact prior behavior.
90fn host_command(shell: &str, flag: &str, command: &str, workdir: &Path) -> TokioCommand {
91    let mut c = leviath_sys::child_command_async(shell);
92    c.arg(flag).arg(command).current_dir(workdir);
93    c
94}
95
96impl SandboxManager {
97    /// Build the manager for an agent whose stages resolve (in order) to
98    /// `by_index`, bind-mounting `workdir`. `entry_index` selects the initial
99    /// stage's config. Returns `Ok(None)` when nothing is sandboxed (the common
100    /// case - the caller then attaches no executor and shell runs on the host).
101    /// Returns `Err` when a required runtime is unavailable and that config's
102    /// `on_unavailable` is `Error`.
103    pub fn build(
104        run_id: &str,
105        by_index: Vec<ToolSandboxConfig>,
106        workdir: &str,
107        entry_index: usize,
108    ) -> Result<Option<Self>, String> {
109        // Auto-detected default engine, used only when a container config doesn't
110        // name its own. Detection runs only if some stage actually needs a
111        // container.
112        let needs_container = by_index.iter().any(|c| c.kind == SandboxKind::Container);
113        let detected = needs_container
114            .then(leviath_sys::detect_container_engine)
115            .flatten();
116        let namespace_ok = leviath_sys::namespace_supported();
117        Self::build_with(
118            run_id,
119            by_index,
120            workdir,
121            entry_index,
122            detected,
123            namespace_ok,
124            &real_run,
125        )
126    }
127
128    /// Testable core of [`Self::build`]: `detected` is the auto-detected engine
129    /// (a config's own `engine` overrides it), `namespace_ok` reports namespace
130    /// availability, and `run` executes the lifecycle commands.
131    fn build_with(
132        run_id: &str,
133        by_index: Vec<ToolSandboxConfig>,
134        workdir: &str,
135        entry_index: usize,
136        detected: Option<String>,
137        namespace_ok: bool,
138        run: &CmdRunner,
139    ) -> Result<Option<Self>, String> {
140        // Fast path: no stage isolates anything → no executor, zero overhead.
141        if by_index.iter().all(|c| !c.is_active()) {
142            return Ok(None);
143        }
144
145        let mut containers: HashMap<u64, LiveContainer> = HashMap::new();
146        for cfg in &by_index {
147            match cfg.kind {
148                SandboxKind::None => {}
149                SandboxKind::Namespace => {
150                    if !namespace_ok {
151                        unavailable(
152                            cfg,
153                            "namespace sandbox requires Linux (unshare); this host lacks it",
154                            &containers,
155                            run,
156                        )?;
157                    }
158                }
159                SandboxKind::Container => {
160                    let sig = signature(cfg);
161                    if containers.contains_key(&sig) {
162                        continue; // identical config already has a warm container
163                    }
164                    // The config's own `engine` wins; else the auto-detected one.
165                    let Some(engine) = cfg.engine.clone().or_else(|| detected.clone()) else {
166                        unavailable(
167                            cfg,
168                            "no container engine found - install docker or podman, \
169                             or set `engine` in [sandbox]",
170                            &containers,
171                            run,
172                        )?;
173                        continue;
174                    };
175                    let Some(image) = cfg.image.as_deref() else {
176                        unavailable(
177                            cfg,
178                            "container sandbox requires an `image`",
179                            &containers,
180                            run,
181                        )?;
182                        continue;
183                    };
184                    let name = format!("leviath-{}-{:016x}", sanitize(run_id), sig);
185                    let spec = ContainerRunSpec {
186                        engine: &engine,
187                        image,
188                        workdir,
189                        network: cfg.network,
190                        mounts: &cfg.mounts,
191                        name: &name,
192                    };
193                    match run(&leviath_sys::container_run_argv(&spec)) {
194                        Ok(()) => {
195                            containers.insert(sig, LiveContainer { engine, name });
196                        }
197                        Err(stderr) => {
198                            let msg = format!(
199                                "failed to start container '{image}' via '{engine}': {stderr}"
200                            );
201                            unavailable(cfg, &msg, &containers, run)?;
202                        }
203                    }
204                }
205            }
206        }
207
208        let current = by_index.get(entry_index).cloned().unwrap_or_default();
209        Ok(Some(Self {
210            containers,
211            by_index,
212            namespace_ok,
213            current: StdMutex::new(current),
214        }))
215    }
216
217    /// Point the shell tool at the sandbox for stage `index` (called by the tool
218    /// service's `sync_stage` on every stage change).
219    pub fn set_stage(&self, index: usize) {
220        if let Some(cfg) = self.by_index.get(index) {
221            *self.current.lock().unwrap_or_else(PoisonError::into_inner) = cfg.clone();
222        }
223    }
224
225    /// Force-remove every container this manager started (best-effort). Called
226    /// once, at reap, before the agent entity is despawned.
227    pub fn destroy_all(&self) {
228        self.destroy_with(&real_run);
229    }
230
231    /// Testable core of [`Self::destroy_all`].
232    fn destroy_with(&self, run: &CmdRunner) {
233        for c in self.containers.values() {
234            let _ = run(&leviath_sys::container_rm_argv(&c.engine, &c.name));
235        }
236    }
237}
238
239/// Apply a config's `on_unavailable` policy: `Error` fails the build (after
240/// tearing down anything already created so no container leaks); `Warn` logs and
241/// lets the caller fall back to host execution.
242fn unavailable(
243    cfg: &ToolSandboxConfig,
244    reason: &str,
245    created: &HashMap<u64, LiveContainer>,
246    run: &CmdRunner,
247) -> Result<(), String> {
248    match cfg.on_unavailable {
249        OnUnavailable::Error => {
250            for c in created.values() {
251                let _ = run(&leviath_sys::container_rm_argv(&c.engine, &c.name));
252            }
253            Err(format!("sandbox unavailable: {reason}"))
254        }
255        OnUnavailable::Warn => {
256            tracing::warn!("sandbox unavailable ({reason}); falling back to host execution");
257            Ok(())
258        }
259    }
260}
261
262/// Real lifecycle-command runner: spawn synchronously, map a non-zero exit to
263/// its stderr.
264fn real_run(argv: &[String]) -> Result<(), String> {
265    let mut cmd = StdCommand::new(&argv[0]);
266    cmd.args(&argv[1..]);
267    // `docker run`/`docker rm` are bookkeeping the operator never watches, so
268    // they get no console window on Windows.
269    let output = cmd.output().map_err(|e| e.to_string())?;
270    if output.status.success() {
271        Ok(())
272    } else {
273        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
274    }
275}
276
277impl ShellExecutor for SandboxManager {
278    fn build_command(
279        &self,
280        shell: &str,
281        flag: &str,
282        command: &str,
283        workdir: &Path,
284    ) -> TokioCommand {
285        let cfg = self
286            .current
287            .lock()
288            .unwrap_or_else(PoisonError::into_inner)
289            .clone();
290        match cfg.kind {
291            SandboxKind::None => host_command(shell, flag, command, workdir),
292            SandboxKind::Namespace => {
293                if self.namespace_ok {
294                    let argv = leviath_sys::namespace_argv(shell, flag, command, cfg.network);
295                    let mut c = leviath_sys::child_command_async(&argv[0]);
296                    c.args(&argv[1..]).current_dir(workdir);
297                    c
298                } else {
299                    // Warn-fallback build kept the manager alive without a usable
300                    // namespace; run on the host.
301                    host_command(shell, flag, command, workdir)
302                }
303            }
304            SandboxKind::Container => match self.containers.get(&signature(&cfg)) {
305                Some(lc) => {
306                    let wd = workdir.to_string_lossy();
307                    // Use the container's own shell (`sh`), not the host-detected
308                    // one whose absolute path may not exist in the image.
309                    let argv = leviath_sys::container_exec_argv(
310                        &lc.engine,
311                        &lc.name,
312                        &wd,
313                        CONTAINER_SHELL,
314                        CONTAINER_SHELL_FLAG,
315                        command,
316                    );
317                    let mut c = leviath_sys::child_command_async(&argv[0]);
318                    c.args(&argv[1..]);
319                    c
320                }
321                // Warn-fallback: the container was never created; run on the host.
322                None => host_command(shell, flag, command, workdir),
323            },
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn container_cfg(
333        image: &str,
334        network: bool,
335        on_unavailable: OnUnavailable,
336    ) -> ToolSandboxConfig {
337        ToolSandboxConfig {
338            kind: SandboxKind::Container,
339            image: Some(image.to_string()),
340            network,
341            on_unavailable,
342            ..Default::default()
343        }
344    }
345
346    fn ns_cfg(on_unavailable: OnUnavailable) -> ToolSandboxConfig {
347        ToolSandboxConfig {
348            kind: SandboxKind::Namespace,
349            on_unavailable,
350            ..Default::default()
351        }
352    }
353
354    /// A runner that records every argv it was asked to run and always succeeds.
355    fn recording_runner(
356        log: &std::sync::Mutex<Vec<Vec<String>>>,
357    ) -> impl Fn(&[String]) -> Result<(), String> + '_ {
358        move |argv| {
359            log.lock().unwrap().push(argv.to_vec());
360            Ok(())
361        }
362    }
363
364    /// A no-op runner that always succeeds. Shared (rather than inline closures)
365    /// so a single instantiation is covered by the tests that actually invoke it.
366    fn ok_runner(_argv: &[String]) -> Result<(), String> {
367        Ok(())
368    }
369
370    #[test]
371    fn all_host_yields_no_manager() {
372        let by_index = vec![ToolSandboxConfig::default(), ToolSandboxConfig::default()];
373        let m = SandboxManager::build_with("run1", by_index, "/work", 0, None, true, &ok_runner)
374            .unwrap();
375        assert!(m.is_none());
376    }
377
378    #[test]
379    fn config_engine_overrides_autodetect() {
380        // Non-prescriptive: a config naming its own engine uses that binary even
381        // when nothing is auto-detected (`detected = None`).
382        let log = std::sync::Mutex::new(Vec::new());
383        let cfg = ToolSandboxConfig {
384            kind: SandboxKind::Container,
385            image: Some("alpine".to_string()),
386            engine: Some("nerdctl".to_string()),
387            on_unavailable: OnUnavailable::Error,
388            ..Default::default()
389        };
390        let m = SandboxManager::build_with(
391            "r",
392            vec![cfg],
393            "/w",
394            0,
395            None, // no engine auto-detected
396            true,
397            &recording_runner(&log),
398        )
399        .unwrap()
400        .unwrap();
401        assert_eq!(log.lock().unwrap()[0][0], "nerdctl");
402        assert_eq!(m.containers.values().next().unwrap().engine, "nerdctl");
403    }
404
405    #[test]
406    fn dedups_identical_container_configs() {
407        let log = std::sync::Mutex::new(Vec::new());
408        let cfg = container_cfg("ubuntu:24.04", true, OnUnavailable::Error);
409        let by_index = vec![cfg.clone(), cfg.clone(), cfg];
410        let m = SandboxManager::build_with(
411            "run-1",
412            by_index,
413            "/work",
414            0,
415            Some("docker".to_string()),
416            true,
417            &recording_runner(&log),
418        )
419        .unwrap()
420        .unwrap();
421        // Three identical stages → one container created.
422        assert_eq!(log.lock().unwrap().len(), 1);
423        assert_eq!(m.containers.len(), 1);
424        let argv = &log.lock().unwrap()[0];
425        assert_eq!(argv[0], "docker");
426        assert!(argv.contains(&"ubuntu:24.04".to_string()));
427    }
428
429    #[test]
430    fn distinct_container_configs_each_get_a_container() {
431        // Uses `ok_runner` (invoked twice here), which also covers the shared
432        // no-op runner referenced by the never-invoking error/host tests.
433        let by_index = vec![
434            container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
435            container_cfg("node:22-slim", false, OnUnavailable::Error),
436        ];
437        let m = SandboxManager::build_with(
438            "r",
439            by_index,
440            "/w",
441            0,
442            Some("docker".to_string()),
443            true,
444            &ok_runner,
445        )
446        .unwrap()
447        .unwrap();
448        assert_eq!(m.containers.len(), 2);
449    }
450
451    #[test]
452    fn missing_engine_errors_by_default() {
453        let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
454        let err =
455            SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner).unwrap_err();
456        assert!(err.contains("no container engine"));
457    }
458
459    #[test]
460    fn missing_engine_warns_and_falls_back() {
461        let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Warn)];
462        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
463            .unwrap()
464            .unwrap();
465        // No container created; build_command falls back to host.
466        assert!(m.containers.is_empty());
467        let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
468        assert_eq!(cmd.as_std().get_program(), "sh");
469    }
470
471    #[test]
472    fn container_without_image_errors() {
473        let cfg = ToolSandboxConfig {
474            kind: SandboxKind::Container,
475            image: None,
476            on_unavailable: OnUnavailable::Error,
477            ..Default::default()
478        };
479        let err = SandboxManager::build_with(
480            "r",
481            vec![cfg],
482            "/w",
483            0,
484            Some("docker".to_string()),
485            true,
486            &ok_runner,
487        )
488        .unwrap_err();
489        assert!(err.contains("requires an `image`"), "got: {err}");
490    }
491
492    #[test]
493    fn container_without_image_warns_and_skips() {
494        // Warn variant: the missing-image config is skipped (the `continue`
495        // after the warn) and the manager is still built with no container.
496        let cfg = ToolSandboxConfig {
497            kind: SandboxKind::Container,
498            image: None,
499            on_unavailable: OnUnavailable::Warn,
500            ..Default::default()
501        };
502        let m = SandboxManager::build_with(
503            "r",
504            vec![cfg],
505            "/w",
506            0,
507            Some("docker".to_string()),
508            true,
509            &ok_runner,
510        )
511        .unwrap()
512        .unwrap();
513        assert!(m.containers.is_empty());
514    }
515
516    #[test]
517    fn build_command_host_arm_when_current_stage_is_none() {
518        // Entry stage is host (None) while a later stage is sandboxed → the
519        // manager exists, and build_command on the host stage runs on the host.
520        let by_index = vec![
521            ToolSandboxConfig::default(),
522            container_cfg("ubuntu:24.04", true, OnUnavailable::Warn),
523        ];
524        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
525            .unwrap()
526            .unwrap();
527        let cmd = m.build_command("zsh", "-c", "echo hi", Path::new("/w"));
528        assert_eq!(cmd.as_std().get_program(), "zsh");
529    }
530
531    #[test]
532    fn container_start_failure_errors() {
533        let by_index = vec![container_cfg("bad:image", true, OnUnavailable::Error)];
534        let err = SandboxManager::build_with(
535            "r",
536            by_index,
537            "/w",
538            0,
539            Some("docker".to_string()),
540            true,
541            &|_| Err("no such image".to_string()),
542        )
543        .unwrap_err();
544        assert!(err.contains("failed to start container"));
545        assert!(err.contains("no such image"));
546    }
547
548    #[test]
549    fn error_teardown_removes_already_created_containers() {
550        // First stage's container starts fine; second fails → the first must be
551        // torn down (a `rm` appears in the log) before the error propagates.
552        let log = std::sync::Mutex::new(Vec::<Vec<String>>::new());
553        let run = |argv: &[String]| -> Result<(), String> {
554            log.lock().unwrap().push(argv.to_vec());
555            // Fail only the second image's run.
556            if argv.contains(&"node:22-slim".to_string()) {
557                Err("boom".to_string())
558            } else {
559                Ok(())
560            }
561        };
562        let by_index = vec![
563            container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
564            container_cfg("node:22-slim", true, OnUnavailable::Error),
565        ];
566        let err = SandboxManager::build_with(
567            "r",
568            by_index,
569            "/w",
570            0,
571            Some("docker".to_string()),
572            true,
573            &run,
574        )
575        .unwrap_err();
576        assert!(err.contains("failed to start container"));
577        let calls = log.lock().unwrap();
578        assert!(
579            calls
580                .iter()
581                .any(|c| c.first().map(String::as_str) == Some("docker")
582                    && c.contains(&"rm".to_string()))
583        );
584    }
585
586    #[test]
587    fn namespace_unavailable_errors_by_default() {
588        let by_index = vec![ns_cfg(OnUnavailable::Error)];
589        let err = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
590            .unwrap_err();
591        assert!(err.contains("namespace"));
592    }
593
594    #[test]
595    fn namespace_unavailable_warns_and_falls_back() {
596        let by_index = vec![ns_cfg(OnUnavailable::Warn)];
597        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
598            .unwrap()
599            .unwrap();
600        let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
601        assert_eq!(cmd.as_std().get_program(), "sh");
602    }
603
604    #[test]
605    fn set_stage_switches_current_config() {
606        let by_index = vec![
607            ToolSandboxConfig::default(), // stage 0: host
608            container_cfg("ubuntu:24.04", true, OnUnavailable::Warn), // stage 1: container
609        ];
610        // Warn so no engine needed; stage 1's container just won't exist → host.
611        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
612            .unwrap()
613            .unwrap();
614        assert_eq!(m.current.lock().unwrap().kind, SandboxKind::None);
615        m.set_stage(1);
616        assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
617        m.set_stage(99); // out of range: no-op, no panic
618        assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
619    }
620
621    #[test]
622    fn build_command_container_uses_docker_exec() {
623        let log = std::sync::Mutex::new(Vec::new());
624        let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
625        let m = SandboxManager::build_with(
626            "r",
627            by_index,
628            "/w",
629            0,
630            Some("docker".to_string()),
631            true,
632            &recording_runner(&log),
633        )
634        .unwrap()
635        .unwrap();
636        let cmd = m.build_command("sh", "-c", "ls", Path::new("/w"));
637        assert_eq!(cmd.as_std().get_program(), "docker");
638        let args: Vec<_> = cmd
639            .as_std()
640            .get_args()
641            .map(|a| a.to_string_lossy().to_string())
642            .collect();
643        assert_eq!(args[0], "exec");
644        assert!(args.contains(&"ls".to_string()));
645    }
646
647    #[test]
648    fn build_command_namespace_uses_unshare_when_supported() {
649        // `namespace_ok = true` is injected, so this exercises the `unshare` arm
650        // on any platform (the manager captured the flag at build time).
651        let by_index = vec![ns_cfg(OnUnavailable::Error)];
652        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
653            .unwrap()
654            .unwrap();
655        let cmd = m.build_command("sh", "-c", "whoami", Path::new("/w"));
656        assert_eq!(cmd.as_std().get_program(), "unshare");
657    }
658
659    #[test]
660    fn destroy_all_removes_every_container() {
661        let log = std::sync::Mutex::new(Vec::new());
662        let by_index = vec![
663            container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
664            container_cfg("node:22-slim", true, OnUnavailable::Error),
665        ];
666        let m = SandboxManager::build_with(
667            "r",
668            by_index,
669            "/w",
670            0,
671            Some("docker".to_string()),
672            true,
673            &recording_runner(&log),
674        )
675        .unwrap()
676        .unwrap();
677        let rm_log = std::sync::Mutex::new(Vec::new());
678        m.destroy_with(&recording_runner(&rm_log));
679        let calls = rm_log.lock().unwrap();
680        assert_eq!(calls.len(), 2);
681        assert!(calls.iter().all(|c| c.contains(&"rm".to_string())));
682    }
683
684    #[test]
685    fn sanitize_replaces_unsafe_chars() {
686        assert_eq!(sanitize("abc-123_x.y"), "abc-123_x-y");
687        assert_eq!(sanitize("a/b:c d"), "a-b-c-d");
688    }
689
690    #[test]
691    fn destroy_all_with_no_containers_is_a_noop() {
692        // A namespace manager has no containers; destroy_all must run cleanly
693        // (covers the real-runner entry point without invoking an engine).
694        let m = SandboxManager::build_with(
695            "r",
696            vec![ns_cfg(OnUnavailable::Warn)],
697            "/w",
698            0,
699            None,
700            false,
701            &ok_runner,
702        )
703        .unwrap()
704        .unwrap();
705        m.destroy_all();
706        assert!(m.containers.is_empty());
707    }
708
709    // `real_run` actually spawns a process, so its three arms are covered with
710    // trivial host commands. Split per-platform because there is no portable
711    // shell/no-op binary (mirrors why `format_command_output` was split out).
712    #[cfg(unix)]
713    #[test]
714    fn real_run_success_nonzero_and_spawn_error_unix() {
715        assert!(real_run(&["true".to_string()]).is_ok());
716        let err = real_run(&[
717            "sh".to_string(),
718            "-c".to_string(),
719            "echo boom 1>&2; exit 1".to_string(),
720        ])
721        .unwrap_err();
722        assert!(err.contains("boom"), "stderr should surface: {err}");
723        assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
724    }
725
726    #[cfg(windows)]
727    #[test]
728    fn real_run_success_nonzero_and_spawn_error_windows() {
729        assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 0".to_string()]).is_ok());
730        assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 1".to_string()]).is_err());
731        assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
732    }
733
734    // Live end-to-end verification against a real container engine is not a
735    // compiled test here - an `#[ignore]`d test still counts as uncovered against
736    // the crate's hard-100% coverage gate. To verify the real create → exec →
737    // destroy path manually (with a container daemon running):
738    //
739    //   docker pull alpine:latest
740    //   lev run <agent-with `[sandbox] kind="container" image="alpine"`> \
741    //       --task "run `cat /etc/os-release` and report the OS" --yolo
742    //
743    // The agent's shell runs INSIDE the container (reports `Alpine Linux`, which a
744    // non-Alpine host lacks), the bind-mounted workdir is visible, and the
745    // container is removed at reap (`docker ps -a` shows no leftover `leviath-*`).
746}