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