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 = TokioCommand::new(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 output = StdCommand::new(&argv[0])
266        .args(&argv[1..])
267        .output()
268        .map_err(|e| e.to_string())?;
269    if output.status.success() {
270        Ok(())
271    } else {
272        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
273    }
274}
275
276impl ShellExecutor for SandboxManager {
277    fn build_command(
278        &self,
279        shell: &str,
280        flag: &str,
281        command: &str,
282        workdir: &Path,
283    ) -> TokioCommand {
284        let cfg = self
285            .current
286            .lock()
287            .unwrap_or_else(PoisonError::into_inner)
288            .clone();
289        match cfg.kind {
290            SandboxKind::None => host_command(shell, flag, command, workdir),
291            SandboxKind::Namespace => {
292                if self.namespace_ok {
293                    let argv = leviath_sys::namespace_argv(shell, flag, command, cfg.network);
294                    let mut c = TokioCommand::new(&argv[0]);
295                    c.args(&argv[1..]).current_dir(workdir);
296                    c
297                } else {
298                    // Warn-fallback build kept the manager alive without a usable
299                    // namespace; run on the host.
300                    host_command(shell, flag, command, workdir)
301                }
302            }
303            SandboxKind::Container => match self.containers.get(&signature(&cfg)) {
304                Some(lc) => {
305                    let wd = workdir.to_string_lossy();
306                    // Use the container's own shell (`sh`), not the host-detected
307                    // one whose absolute path may not exist in the image.
308                    let argv = leviath_sys::container_exec_argv(
309                        &lc.engine,
310                        &lc.name,
311                        &wd,
312                        CONTAINER_SHELL,
313                        CONTAINER_SHELL_FLAG,
314                        command,
315                    );
316                    let mut c = TokioCommand::new(&argv[0]);
317                    c.args(&argv[1..]);
318                    c
319                }
320                // Warn-fallback: the container was never created; run on the host.
321                None => host_command(shell, flag, command, workdir),
322            },
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn container_cfg(
332        image: &str,
333        network: bool,
334        on_unavailable: OnUnavailable,
335    ) -> ToolSandboxConfig {
336        ToolSandboxConfig {
337            kind: SandboxKind::Container,
338            image: Some(image.to_string()),
339            network,
340            on_unavailable,
341            ..Default::default()
342        }
343    }
344
345    fn ns_cfg(on_unavailable: OnUnavailable) -> ToolSandboxConfig {
346        ToolSandboxConfig {
347            kind: SandboxKind::Namespace,
348            on_unavailable,
349            ..Default::default()
350        }
351    }
352
353    /// A runner that records every argv it was asked to run and always succeeds.
354    fn recording_runner(
355        log: &std::sync::Mutex<Vec<Vec<String>>>,
356    ) -> impl Fn(&[String]) -> Result<(), String> + '_ {
357        move |argv| {
358            log.lock().unwrap().push(argv.to_vec());
359            Ok(())
360        }
361    }
362
363    /// A no-op runner that always succeeds. Shared (rather than inline closures)
364    /// so a single instantiation is covered by the tests that actually invoke it.
365    fn ok_runner(_argv: &[String]) -> Result<(), String> {
366        Ok(())
367    }
368
369    #[test]
370    fn all_host_yields_no_manager() {
371        let by_index = vec![ToolSandboxConfig::default(), ToolSandboxConfig::default()];
372        let m = SandboxManager::build_with("run1", by_index, "/work", 0, None, true, &ok_runner)
373            .unwrap();
374        assert!(m.is_none());
375    }
376
377    #[test]
378    fn config_engine_overrides_autodetect() {
379        // Non-prescriptive: a config naming its own engine uses that binary even
380        // when nothing is auto-detected (`detected = None`).
381        let log = std::sync::Mutex::new(Vec::new());
382        let cfg = ToolSandboxConfig {
383            kind: SandboxKind::Container,
384            image: Some("alpine".to_string()),
385            engine: Some("nerdctl".to_string()),
386            on_unavailable: OnUnavailable::Error,
387            ..Default::default()
388        };
389        let m = SandboxManager::build_with(
390            "r",
391            vec![cfg],
392            "/w",
393            0,
394            None, // no engine auto-detected
395            true,
396            &recording_runner(&log),
397        )
398        .unwrap()
399        .unwrap();
400        assert_eq!(log.lock().unwrap()[0][0], "nerdctl");
401        assert_eq!(m.containers.values().next().unwrap().engine, "nerdctl");
402    }
403
404    #[test]
405    fn dedups_identical_container_configs() {
406        let log = std::sync::Mutex::new(Vec::new());
407        let cfg = container_cfg("ubuntu:24.04", true, OnUnavailable::Error);
408        let by_index = vec![cfg.clone(), cfg.clone(), cfg];
409        let m = SandboxManager::build_with(
410            "run-1",
411            by_index,
412            "/work",
413            0,
414            Some("docker".to_string()),
415            true,
416            &recording_runner(&log),
417        )
418        .unwrap()
419        .unwrap();
420        // Three identical stages → one container created.
421        assert_eq!(log.lock().unwrap().len(), 1);
422        assert_eq!(m.containers.len(), 1);
423        let argv = &log.lock().unwrap()[0];
424        assert_eq!(argv[0], "docker");
425        assert!(argv.contains(&"ubuntu:24.04".to_string()));
426    }
427
428    #[test]
429    fn distinct_container_configs_each_get_a_container() {
430        // Uses `ok_runner` (invoked twice here), which also covers the shared
431        // no-op runner referenced by the never-invoking error/host tests.
432        let by_index = vec![
433            container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
434            container_cfg("node:22-slim", false, OnUnavailable::Error),
435        ];
436        let m = SandboxManager::build_with(
437            "r",
438            by_index,
439            "/w",
440            0,
441            Some("docker".to_string()),
442            true,
443            &ok_runner,
444        )
445        .unwrap()
446        .unwrap();
447        assert_eq!(m.containers.len(), 2);
448    }
449
450    #[test]
451    fn missing_engine_errors_by_default() {
452        let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
453        let err =
454            SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner).unwrap_err();
455        assert!(err.contains("no container engine"));
456    }
457
458    #[test]
459    fn missing_engine_warns_and_falls_back() {
460        let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Warn)];
461        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
462            .unwrap()
463            .unwrap();
464        // No container created; build_command falls back to host.
465        assert!(m.containers.is_empty());
466        let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
467        assert_eq!(cmd.as_std().get_program(), "sh");
468    }
469
470    #[test]
471    fn container_without_image_errors() {
472        let cfg = ToolSandboxConfig {
473            kind: SandboxKind::Container,
474            image: None,
475            on_unavailable: OnUnavailable::Error,
476            ..Default::default()
477        };
478        let err = SandboxManager::build_with(
479            "r",
480            vec![cfg],
481            "/w",
482            0,
483            Some("docker".to_string()),
484            true,
485            &ok_runner,
486        )
487        .unwrap_err();
488        assert!(err.contains("requires an `image`"), "got: {err}");
489    }
490
491    #[test]
492    fn container_without_image_warns_and_skips() {
493        // Warn variant: the missing-image config is skipped (the `continue`
494        // after the warn) and the manager is still built with no container.
495        let cfg = ToolSandboxConfig {
496            kind: SandboxKind::Container,
497            image: None,
498            on_unavailable: OnUnavailable::Warn,
499            ..Default::default()
500        };
501        let m = SandboxManager::build_with(
502            "r",
503            vec![cfg],
504            "/w",
505            0,
506            Some("docker".to_string()),
507            true,
508            &ok_runner,
509        )
510        .unwrap()
511        .unwrap();
512        assert!(m.containers.is_empty());
513    }
514
515    #[test]
516    fn build_command_host_arm_when_current_stage_is_none() {
517        // Entry stage is host (None) while a later stage is sandboxed → the
518        // manager exists, and build_command on the host stage runs on the host.
519        let by_index = vec![
520            ToolSandboxConfig::default(),
521            container_cfg("ubuntu:24.04", true, OnUnavailable::Warn),
522        ];
523        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
524            .unwrap()
525            .unwrap();
526        let cmd = m.build_command("zsh", "-c", "echo hi", Path::new("/w"));
527        assert_eq!(cmd.as_std().get_program(), "zsh");
528    }
529
530    #[test]
531    fn container_start_failure_errors() {
532        let by_index = vec![container_cfg("bad:image", true, OnUnavailable::Error)];
533        let err = SandboxManager::build_with(
534            "r",
535            by_index,
536            "/w",
537            0,
538            Some("docker".to_string()),
539            true,
540            &|_| Err("no such image".to_string()),
541        )
542        .unwrap_err();
543        assert!(err.contains("failed to start container"));
544        assert!(err.contains("no such image"));
545    }
546
547    #[test]
548    fn error_teardown_removes_already_created_containers() {
549        // First stage's container starts fine; second fails → the first must be
550        // torn down (a `rm` appears in the log) before the error propagates.
551        let log = std::sync::Mutex::new(Vec::<Vec<String>>::new());
552        let run = |argv: &[String]| -> Result<(), String> {
553            log.lock().unwrap().push(argv.to_vec());
554            // Fail only the second image's run.
555            if argv.contains(&"node:22-slim".to_string()) {
556                Err("boom".to_string())
557            } else {
558                Ok(())
559            }
560        };
561        let by_index = vec![
562            container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
563            container_cfg("node:22-slim", true, OnUnavailable::Error),
564        ];
565        let err = SandboxManager::build_with(
566            "r",
567            by_index,
568            "/w",
569            0,
570            Some("docker".to_string()),
571            true,
572            &run,
573        )
574        .unwrap_err();
575        assert!(err.contains("failed to start container"));
576        let calls = log.lock().unwrap();
577        assert!(
578            calls
579                .iter()
580                .any(|c| c.first().map(String::as_str) == Some("docker")
581                    && c.contains(&"rm".to_string()))
582        );
583    }
584
585    #[test]
586    fn namespace_unavailable_errors_by_default() {
587        let by_index = vec![ns_cfg(OnUnavailable::Error)];
588        let err = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
589            .unwrap_err();
590        assert!(err.contains("namespace"));
591    }
592
593    #[test]
594    fn namespace_unavailable_warns_and_falls_back() {
595        let by_index = vec![ns_cfg(OnUnavailable::Warn)];
596        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
597            .unwrap()
598            .unwrap();
599        let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
600        assert_eq!(cmd.as_std().get_program(), "sh");
601    }
602
603    #[test]
604    fn set_stage_switches_current_config() {
605        let by_index = vec![
606            ToolSandboxConfig::default(), // stage 0: host
607            container_cfg("ubuntu:24.04", true, OnUnavailable::Warn), // stage 1: container
608        ];
609        // Warn so no engine needed; stage 1's container just won't exist → host.
610        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
611            .unwrap()
612            .unwrap();
613        assert_eq!(m.current.lock().unwrap().kind, SandboxKind::None);
614        m.set_stage(1);
615        assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
616        m.set_stage(99); // out of range: no-op, no panic
617        assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
618    }
619
620    #[test]
621    fn build_command_container_uses_docker_exec() {
622        let log = std::sync::Mutex::new(Vec::new());
623        let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
624        let m = SandboxManager::build_with(
625            "r",
626            by_index,
627            "/w",
628            0,
629            Some("docker".to_string()),
630            true,
631            &recording_runner(&log),
632        )
633        .unwrap()
634        .unwrap();
635        let cmd = m.build_command("sh", "-c", "ls", Path::new("/w"));
636        assert_eq!(cmd.as_std().get_program(), "docker");
637        let args: Vec<_> = cmd
638            .as_std()
639            .get_args()
640            .map(|a| a.to_string_lossy().to_string())
641            .collect();
642        assert_eq!(args[0], "exec");
643        assert!(args.contains(&"ls".to_string()));
644    }
645
646    #[test]
647    fn build_command_namespace_uses_unshare_when_supported() {
648        // `namespace_ok = true` is injected, so this exercises the `unshare` arm
649        // on any platform (the manager captured the flag at build time).
650        let by_index = vec![ns_cfg(OnUnavailable::Error)];
651        let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
652            .unwrap()
653            .unwrap();
654        let cmd = m.build_command("sh", "-c", "whoami", Path::new("/w"));
655        assert_eq!(cmd.as_std().get_program(), "unshare");
656    }
657
658    #[test]
659    fn destroy_all_removes_every_container() {
660        let log = std::sync::Mutex::new(Vec::new());
661        let by_index = vec![
662            container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
663            container_cfg("node:22-slim", true, OnUnavailable::Error),
664        ];
665        let m = SandboxManager::build_with(
666            "r",
667            by_index,
668            "/w",
669            0,
670            Some("docker".to_string()),
671            true,
672            &recording_runner(&log),
673        )
674        .unwrap()
675        .unwrap();
676        let rm_log = std::sync::Mutex::new(Vec::new());
677        m.destroy_with(&recording_runner(&rm_log));
678        let calls = rm_log.lock().unwrap();
679        assert_eq!(calls.len(), 2);
680        assert!(calls.iter().all(|c| c.contains(&"rm".to_string())));
681    }
682
683    #[test]
684    fn sanitize_replaces_unsafe_chars() {
685        assert_eq!(sanitize("abc-123_x.y"), "abc-123_x-y");
686        assert_eq!(sanitize("a/b:c d"), "a-b-c-d");
687    }
688
689    #[test]
690    fn destroy_all_with_no_containers_is_a_noop() {
691        // A namespace manager has no containers; destroy_all must run cleanly
692        // (covers the real-runner entry point without invoking an engine).
693        let m = SandboxManager::build_with(
694            "r",
695            vec![ns_cfg(OnUnavailable::Warn)],
696            "/w",
697            0,
698            None,
699            false,
700            &ok_runner,
701        )
702        .unwrap()
703        .unwrap();
704        m.destroy_all();
705        assert!(m.containers.is_empty());
706    }
707
708    // `real_run` actually spawns a process, so its three arms are covered with
709    // trivial host commands. Split per-platform because there is no portable
710    // shell/no-op binary (mirrors why `format_command_output` was split out).
711    #[cfg(unix)]
712    #[test]
713    fn real_run_success_nonzero_and_spawn_error_unix() {
714        assert!(real_run(&["true".to_string()]).is_ok());
715        let err = real_run(&[
716            "sh".to_string(),
717            "-c".to_string(),
718            "echo boom 1>&2; exit 1".to_string(),
719        ])
720        .unwrap_err();
721        assert!(err.contains("boom"), "stderr should surface: {err}");
722        assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
723    }
724
725    #[cfg(windows)]
726    #[test]
727    fn real_run_success_nonzero_and_spawn_error_windows() {
728        assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 0".to_string()]).is_ok());
729        assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 1".to_string()]).is_err());
730        assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
731    }
732
733    // Live end-to-end verification against a real container engine is not a
734    // compiled test here - an `#[ignore]`d test still counts as uncovered against
735    // the crate's hard-100% coverage gate. To verify the real create → exec →
736    // destroy path manually (with a container daemon running):
737    //
738    //   docker pull alpine:latest
739    //   lev run <agent-with `[sandbox] kind="container" image="alpine"`> \
740    //       --task "run `cat /etc/os-release` and report the OS" --yolo
741    //
742    // The agent's shell runs INSIDE the container (reports `Alpine Linux`, which a
743    // non-Alpine host lacks), the bind-mounted workdir is visible, and the
744    // container is removed at reap (`docker ps -a` shows no leftover `leviath-*`).
745}