Skip to main content

nyx_agent_sandbox/
lib.rs

1//! Sandbox layer. Each backend isolates a single short-lived child process
2//! that runs an agent task (a dynamic verify, a payload-runner, an ai-tool
3//! call). The trait stays independent of every other nyx-agent crate so a
4//! future VM backend can ship without dragging core/api/ai changes along.
5//!
6//! This crate is published so the `nyx-agent` binary can be installed
7//! from crates.io with versioned internal dependencies. It is an
8//! implementation detail of Nyx Agent, not a stable public API.
9//!
10//! Backends shipped today:
11//!
12//! * `process`: fork+exec with no isolation upgrade. The unhardened
13//!   default used when an operator picks the `process` backend, or when
14//!   no stronger backend is available on this host.
15//! * `birdcage`: wraps the `birdcage` crate, which compiles to Linux
16//!   landlock + seccomp or macOS Seatbelt. FS deny-by-default plus a
17//!   single workspace-write exception; network deny unless
18//!   [`SandboxOpts::allow_loopback`] is set.
19//! * `libkrun`: macOS-first microVM via HVF (Linux+KVM also
20//!   supported). Routed through a `libkrun-runner` helper binary so
21//!   FFI symbol drift cannot crash the daemon.
22//! * `firecracker`: Linux+KVM microVM. Routed through a
23//!   `nyx-fc-runner` helper binary.
24//! * `docker`: fallback container backend used when no stronger
25//!   isolation is available; the chain-lane delegates to the
26//!   docker-compose env-builder for the actual spin-up.
27
28use std::collections::HashMap;
29use std::path::PathBuf;
30use std::time::Duration;
31
32use thiserror::Error;
33
34pub mod backend;
35pub mod chain_runner;
36pub mod env;
37pub mod payload_runner;
38pub mod shim;
39pub mod workspace;
40
41pub use backend::birdcage::BirdcageSandbox;
42pub use backend::firecracker::{firecracker_host_supported, FirecrackerSandbox, FirecrackerSpec};
43pub use backend::libkrun::{libkrun_host_supported, LibkrunSandbox, LibkrunSpec};
44pub use backend::process::ProcessSandbox;
45pub use chain_runner::{
46    ChainResult, ChainRun, ChainRunner, ChainRunnerError, ChainStep, ChainStepCapture,
47    ChainVerdict, InconclusiveReason,
48};
49pub use payload_runner::{
50    HarnessSource, HarnessSpecInput, PayloadRun, PayloadRunner, PayloadRunnerError,
51};
52
53/// Which backend produced (or is about to produce) a [`SandboxOutcome`].
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum BackendKind {
56    /// `fork`/`exec` only; no kernel isolation upgrade.
57    Process,
58    /// Landlock+seccomp on Linux, Seatbelt on macOS.
59    Birdcage,
60    /// libkrun microVM via HVF (macOS) or KVM (Linux).
61    Libkrun,
62    /// Firecracker microVM (Linux+KVM).
63    Firecracker,
64    /// docker container fallback. Chain-lane spin-up delegates to
65    /// the docker-compose env-builder.
66    Docker,
67}
68
69impl BackendKind {
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            BackendKind::Process => "process",
73            BackendKind::Birdcage => "birdcage",
74            BackendKind::Libkrun => "libkrun",
75            BackendKind::Firecracker => "firecracker",
76            BackendKind::Docker => "docker",
77        }
78    }
79}
80
81/// Which scan lane the sandbox runs under. The chain lane spins up the
82/// full dev-env replay alongside the AI-driven exploitation, which is
83/// expensive; it gets a stricter concurrency cap than the fast lane.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum Lane {
86    /// Static-pass + lightweight verifier work. Tolerates high
87    /// fan-out.
88    Fast,
89    /// Full env-replay + AI exploitation. RAM-bound.
90    Chain,
91}
92
93impl Lane {
94    pub fn as_str(&self) -> &'static str {
95        match self {
96            Lane::Fast => "fast",
97            Lane::Chain => "chain",
98        }
99    }
100}
101
102/// Per-lane simultaneous-spinup caps. The chain lane defaults to 2 (a
103/// full env-replay can easily consume several GB of RAM); the fast
104/// lane defaults to 8 (matches the `static_concurrency` ceiling on a
105/// typical 8-core dev box).
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct LaneConcurrency {
108    pub chain: usize,
109    pub fast: usize,
110}
111
112impl LaneConcurrency {
113    pub const DEFAULT_CHAIN: usize = 2;
114    pub const DEFAULT_FAST: usize = 8;
115
116    pub const fn defaults() -> Self {
117        Self { chain: Self::DEFAULT_CHAIN, fast: Self::DEFAULT_FAST }
118    }
119
120    pub fn for_lane(&self, lane: Lane) -> usize {
121        match lane {
122            Lane::Chain => self.chain,
123            Lane::Fast => self.fast,
124        }
125    }
126}
127
128impl Default for LaneConcurrency {
129    fn default() -> Self {
130        Self::defaults()
131    }
132}
133
134/// Refuse `(Lane::Fast, BackendKind::Birdcage)` when a caller asks for
135/// `allow_loopback`. Birdcage cannot scope loopback any tighter than
136/// "all network or none" (the field-level note on [`SandboxOpts::allow_loopback`]
137/// covers this), so opening it on the fast lane hands the lightweight
138/// verifier child a full egress channel it should never need. Chain-lane
139/// tasks legitimately want loopback to reach env-builder services on the
140/// host. Every other lane/backend combination passes through unchanged;
141/// backends that can scope loopback at the kernel (libkrun, firecracker)
142/// are free to allow it on either lane.
143pub fn permits_loopback(lane: Lane, backend: BackendKind) -> bool {
144    !matches!((lane, backend), (Lane::Fast, BackendKind::Birdcage))
145}
146
147/// Which backend the selector chose, plus a human-readable reason the
148/// doctor / live-scan UI surfaces verbatim.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct BackendSelection {
151    pub backend: BackendKind,
152    pub reason: String,
153}
154
155/// Operator-facing backend label. Mirrors
156/// `nyx_agent_core::config::SandboxBackend` but lives in this crate so
157/// the sandbox layer does not depend on core.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum BackendChoice {
160    /// Pick the strongest available backend for the lane at runtime.
161    Auto,
162    /// Pin to a specific backend; fall back if it cannot run here.
163    Pinned(BackendKind),
164}
165
166/// Pick a backend for `lane` honouring the operator's `choice`. Auto
167/// picks the strongest backend that can run on this host:
168///
169/// * Chain lane on macOS:   libkrun -> docker -> birdcage -> process.
170/// * Chain lane on Linux:   firecracker -> libkrun -> docker -> birdcage -> process.
171/// * Fast lane on macOS:    birdcage -> process.
172/// * Fast lane on Linux:    birdcage -> process.
173///
174/// A pinned choice that cannot run here is downgraded to the same
175/// auto-pick ladder with a reason explaining what failed.
176pub fn select_backend(choice: BackendChoice, lane: Lane) -> BackendSelection {
177    let auto = || auto_select(lane);
178    match choice {
179        BackendChoice::Auto => auto(),
180        BackendChoice::Pinned(kind) => match probe(kind) {
181            Ok(()) => {
182                BackendSelection { backend: kind, reason: format!("pinned to {}", kind.as_str()) }
183            }
184            Err(err) => {
185                let auto = auto();
186                BackendSelection {
187                    backend: auto.backend,
188                    reason: format!(
189                        "pinned {} unavailable ({err}); fell back to {} ({})",
190                        kind.as_str(),
191                        auto.backend.as_str(),
192                        auto.reason
193                    ),
194                }
195            }
196        },
197    }
198}
199
200fn auto_select(lane: Lane) -> BackendSelection {
201    let ladder = auto_ladder(lane);
202    for kind in ladder {
203        match probe(*kind) {
204            Ok(()) => {
205                return BackendSelection {
206                    backend: *kind,
207                    reason: format!("auto-selected for {} lane", lane.as_str()),
208                };
209            }
210            Err(_) => continue,
211        }
212    }
213    // ProcessSandbox always probes Ok; this branch is unreachable in
214    // practice but keeps the function total without a panic.
215    BackendSelection {
216        backend: BackendKind::Process,
217        reason: format!("auto-selected fallback for {} lane", lane.as_str()),
218    }
219}
220
221fn auto_ladder(lane: Lane) -> &'static [BackendKind] {
222    match lane {
223        Lane::Chain => {
224            #[cfg(target_os = "macos")]
225            {
226                &[
227                    BackendKind::Libkrun,
228                    BackendKind::Docker,
229                    BackendKind::Birdcage,
230                    BackendKind::Process,
231                ]
232            }
233            #[cfg(target_os = "linux")]
234            {
235                &[
236                    BackendKind::Firecracker,
237                    BackendKind::Libkrun,
238                    BackendKind::Docker,
239                    BackendKind::Birdcage,
240                    BackendKind::Process,
241                ]
242            }
243            #[cfg(not(any(target_os = "macos", target_os = "linux")))]
244            {
245                &[BackendKind::Docker, BackendKind::Process]
246            }
247        }
248        Lane::Fast => {
249            #[cfg(any(target_os = "macos", target_os = "linux"))]
250            {
251                &[BackendKind::Birdcage, BackendKind::Process]
252            }
253            #[cfg(not(any(target_os = "macos", target_os = "linux")))]
254            {
255                &[BackendKind::Process]
256            }
257        }
258    }
259}
260
261/// Options for a single sandboxed child.
262#[derive(Debug, Clone)]
263pub struct SandboxOpts {
264    /// Path the child can read and write. Must already exist.
265    pub workspace: PathBuf,
266    /// `argv[0]` plus arguments. `argv[0]` is the program to exec.
267    pub argv: Vec<String>,
268    /// Working directory. Defaults to `workspace`.
269    pub cwd: Option<PathBuf>,
270    /// Environment variables passed to the child. The parent's `env` is
271    /// not inherited.
272    pub env: Vec<(String, String)>,
273    /// Wall-clock timeout. Backends that miss it report
274    /// [`SandboxStatus::TimedOut`].
275    pub timeout: Duration,
276    /// Allow loopback network traffic. birdcage cannot scope further than
277    /// "all network or none": when set, all egress is allowed.
278    pub allow_loopback: bool,
279    /// Which scan lane spawned this child. `None` means lane-unannotated
280    /// (the caller did not opt in to lane policy enforcement). When set,
281    /// [`permits_loopback`] gates `allow_loopback` against the backend
282    /// at [`Sandbox::run`] time; the birdcage backend refuses
283    /// `(Lane::Fast, allow_loopback = true)` with [`SandboxError::Config`].
284    pub lane: Option<Lane>,
285    /// Extra read-only paths visible to the sandboxed child (defaults
286    /// like `/lib`, `/usr` are added by the backend).
287    pub allow_read: Vec<PathBuf>,
288    /// Extra read-write paths visible to the sandboxed child (in addition
289    /// to `workspace`).
290    pub allow_write: Vec<PathBuf>,
291    /// Cap captured stdout/stderr at this many bytes each. The child is
292    /// not killed when its output exceeds the cap; further bytes are
293    /// silently dropped.
294    pub max_output_bytes: usize,
295    /// Snapshot the workspace from this source directory before
296    /// spawning. When set, the backend builds a fresh COW snapshot
297    /// of `snapshot_from` via [`workspace::snapshot`] under a private
298    /// tempdir and overrides [`SandboxOpts::workspace`] to point at
299    /// the new copy; the tempdir is reaped on
300    /// [`Sandbox::wait`]/[`Sandbox::kill`]. Lets a caller hand the
301    /// sandbox a clean COW view of a source tree without staging the
302    /// copy itself. Defaults to `None` (the sandbox uses
303    /// `workspace` directly).
304    pub snapshot_from: Option<PathBuf>,
305    /// Workspace-relative paths the backend reads after the child
306    /// exits but before the sandbox tears down (in particular, before
307    /// `snapshot_from`'s tempdir drops). Each declared path lands in
308    /// [`SandboxOutcome::captured_files`] keyed by its workspace-relative
309    /// form: `Some(bytes)` when the file existed at capture time,
310    /// `None` when it did not. Lets callers observe per-run side
311    /// effects (e.g. a `SinkProbe` sentinel file) without keeping the
312    /// workspace alive past `wait()`.
313    pub capture_files: Vec<PathBuf>,
314}
315
316impl SandboxOpts {
317    /// New options with sane defaults for a short-lived agent task.
318    pub fn new(workspace: PathBuf, argv: Vec<String>) -> Self {
319        Self {
320            workspace,
321            argv,
322            cwd: None,
323            env: Vec::new(),
324            timeout: Duration::from_secs(30),
325            allow_loopback: false,
326            lane: None,
327            allow_read: Vec::new(),
328            allow_write: Vec::new(),
329            max_output_bytes: 1 << 20,
330            snapshot_from: None,
331            capture_files: Vec::new(),
332        }
333    }
334
335    /// Builder: snapshot `src` into a private COW tempdir at run time
336    /// and use it as the child's workspace. The tempdir is owned by
337    /// the backend's `RunningChild` and dropped (removed) when the
338    /// child is reaped. Calling [`SandboxOpts::workspace`] after this
339    /// is irrelevant; the backend overrides it with the snapshot
340    /// destination. Returns `self` so it composes with the other
341    /// `SandboxOpts` setters.
342    pub fn with_snapshot_from(mut self, src: PathBuf) -> Self {
343        self.snapshot_from = Some(src);
344        self
345    }
346
347    /// Builder: declare a workspace-relative path the backend should
348    /// read at capture time (after `wait`, before snapshot drop) and
349    /// stamp on [`SandboxOutcome::captured_files`]. Multiple calls
350    /// accumulate. Composes with the other `SandboxOpts` setters.
351    pub fn capture_file(mut self, rel: PathBuf) -> Self {
352        self.capture_files.push(rel);
353        self
354    }
355}
356
357/// Final state of a sandboxed child.
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum SandboxStatus {
360    /// Child exited with the recorded code.
361    Exited(i32),
362    /// Child died from a signal (Unix only; on other platforms reported as
363    /// `Exited(-1)`).
364    Signaled(i32),
365    /// Backend tore the child down because [`SandboxOpts::timeout`] fired.
366    TimedOut,
367    /// Caller invoked [`Sandbox::kill`].
368    Killed,
369}
370
371impl SandboxStatus {
372    /// Did the sandbox successfully contain the child? A `contained`
373    /// child either failed to exec, exited non-zero, was killed by the
374    /// kernel, or was torn down by the harness: anything except a clean
375    /// `exit(0)`. The escape regression suite asserts this.
376    pub fn contained(&self) -> bool {
377        !matches!(self, SandboxStatus::Exited(0))
378    }
379}
380
381/// The captured result of a single sandboxed run.
382#[derive(Debug, Clone)]
383pub struct SandboxOutcome {
384    pub backend: BackendKind,
385    pub status: SandboxStatus,
386    pub stdout: Vec<u8>,
387    pub stderr: Vec<u8>,
388    pub duration: Duration,
389    /// Birdcage exception refusals the shim collected during sandbox
390    /// setup. A non-empty list means at least one declared exception
391    /// (allow_read / allow_write / allow_env / Networking) did not
392    /// take effect, which typically explains a follow-on "permission
393    /// denied" inside the sandboxee. Always empty on backends that do
394    /// not exercise the shim's fd-3 report channel.
395    pub refusals: Vec<String>,
396    /// Files the backend captured from the post-wait workspace, keyed
397    /// by the workspace-relative path the caller declared via
398    /// [`SandboxOpts::capture_files`]. `Some(bytes)` when the file
399    /// existed; `None` when it did not. Lets callers observe per-run
400    /// side effects (e.g. a `SinkProbe` sentinel) without keeping the
401    /// workspace alive past `wait()`. Empty when the caller declared
402    /// no captures.
403    pub captured_files: HashMap<PathBuf, Option<Vec<u8>>>,
404}
405
406/// Sandbox error surface. Backend-specific failures are folded into the
407/// closest matching variant so callers can program against the trait
408/// without reaching for downcasts.
409#[derive(Debug, Error)]
410pub enum SandboxError {
411    /// The backend cannot run on this host (e.g. birdcage on Windows).
412    #[error("backend {backend} unavailable: {reason}")]
413    BackendUnavailable { backend: &'static str, reason: String },
414    /// `fork`/`exec` failed before any sandbox lock was applied.
415    #[error("spawn failed: {0}")]
416    Spawn(#[source] std::io::Error),
417    /// Workspace setup (the COW snapshot) failed.
418    #[error("workspace setup failed: {0}")]
419    Workspace(#[source] std::io::Error),
420    /// Misconfigured opts (empty argv, non-existent workspace, etc.).
421    #[error("sandbox config rejected: {0}")]
422    Config(String),
423    /// Caller invoked `kill`/`wait` in an order the backend cannot honour.
424    #[error("invalid sandbox state: {0}")]
425    State(&'static str),
426    /// Generic I/O failure while running the child.
427    #[error("io error: {0}")]
428    Io(#[from] std::io::Error),
429}
430
431/// The sandbox surface used by every consumer (chain lane, payload runner,
432/// dynamic verifier). Implementors own a single child at a time: call
433/// [`Sandbox::run`] once, then either [`Sandbox::wait`] or
434/// [`Sandbox::kill`] before another `run`.
435#[allow(async_fn_in_trait)]
436pub trait Sandbox: Send {
437    fn backend(&self) -> BackendKind;
438
439    /// Spawn the child described by `opts`. Returns once the kernel has
440    /// accepted the new process. The child may still be sandboxing
441    /// itself when this returns (birdcage runs its `lock()` in a
442    /// `pre_exec` hook, so the sandbox is in place by the time the
443    /// target binary's `main` runs).
444    async fn run(&mut self, opts: SandboxOpts) -> Result<(), SandboxError>;
445
446    /// SIGKILL the running child. Idempotent: calling on an already-exited
447    /// child returns `Ok(())`.
448    async fn kill(&mut self) -> Result<(), SandboxError>;
449
450    /// Block until the child exits, honouring the opts.timeout passed to
451    /// [`Sandbox::run`]. After this returns, [`Sandbox::logs`] yields the
452    /// captured output and the backend is ready for another `run`.
453    async fn wait(&mut self) -> Result<SandboxOutcome, SandboxError>;
454
455    /// Stdout, then stderr, captured from the most recent run. Only
456    /// meaningful after [`Sandbox::wait`].
457    fn logs(&self) -> (&[u8], &[u8]);
458}
459
460/// Lightweight readiness probe: returns the static set of backends
461/// that the platform *could* run. Construction-time probes
462/// ([`probe`]) further check that the kernel surface + helper binaries
463/// are present.
464pub fn available_backends() -> &'static [BackendKind] {
465    #[cfg(target_os = "macos")]
466    {
467        &[BackendKind::Process, BackendKind::Birdcage, BackendKind::Libkrun, BackendKind::Docker]
468    }
469    #[cfg(target_os = "linux")]
470    {
471        &[
472            BackendKind::Process,
473            BackendKind::Birdcage,
474            BackendKind::Libkrun,
475            BackendKind::Firecracker,
476            BackendKind::Docker,
477        ]
478    }
479    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
480    {
481        &[BackendKind::Process, BackendKind::Docker]
482    }
483}
484
485/// Return `Ok(())` if `backend` can be constructed on this host, else
486/// describe why it cannot. Callers use this to short-circuit a doctor
487/// check or to fall back to a weaker backend.
488pub fn probe(backend: BackendKind) -> Result<(), SandboxError> {
489    match backend {
490        BackendKind::Process => Ok(()),
491        BackendKind::Birdcage => {
492            #[cfg(any(target_os = "linux", target_os = "macos"))]
493            {
494                // The kernel surface exists; the shim binary is the
495                // second gate. Surfacing its absence here makes the
496                // doctor's `select_backend` ladder downgrade to
497                // `Process` instead of silently choosing Birdcage and
498                // tripping at the first `run()`.
499                backend::birdcage::BirdcageSandbox::new().map(|_| ())
500            }
501            #[cfg(not(any(target_os = "linux", target_os = "macos")))]
502            {
503                Err(SandboxError::BackendUnavailable {
504                    backend: "birdcage",
505                    reason: "requires Linux landlock or macOS Seatbelt".into(),
506                })
507            }
508        }
509        BackendKind::Libkrun => {
510            if !libkrun_host_supported() {
511                return Err(SandboxError::BackendUnavailable {
512                    backend: "libkrun",
513                    reason: "requires macOS with Hypervisor.framework or Linux with KVM".into(),
514                });
515            }
516            // Helper binary presence is the second gate.
517            backend::libkrun::LibkrunSandbox::new().map(|_| ())
518        }
519        BackendKind::Firecracker => {
520            if !firecracker_host_supported() {
521                return Err(SandboxError::BackendUnavailable {
522                    backend: "firecracker",
523                    reason: "requires Linux with /dev/kvm".into(),
524                });
525            }
526            backend::firecracker::FirecrackerSandbox::new().map(|_| ())
527        }
528        BackendKind::Docker => {
529            if backend::which_on_path("docker").is_some() {
530                Ok(())
531            } else {
532                Err(SandboxError::BackendUnavailable {
533                    backend: "docker",
534                    reason: "docker not found on PATH".into(),
535                })
536            }
537        }
538    }
539}
540
541/// Shared lock for tests that mutate process-wide env vars (notably
542/// `$NYX_LIBKRUN_RUNNER`). Tests in this crate run in the same lib-test
543/// binary and the default cargo test runner is multi-threaded, so two
544/// env-mutating tests can clobber each other's `set_var`/`remove_var`
545/// pairs mid-call. Hold this guard for the duration of any env
546/// mutation.
547#[cfg(test)]
548pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn lane_policy_refuses_fast_birdcage_loopback() {
556        assert!(!permits_loopback(Lane::Fast, BackendKind::Birdcage));
557    }
558
559    #[test]
560    fn lane_policy_allows_chain_birdcage_loopback() {
561        assert!(permits_loopback(Lane::Chain, BackendKind::Birdcage));
562    }
563
564    #[test]
565    fn lane_policy_allows_fast_process_loopback() {
566        // Process backend has no egress cage, so the policy waves it
567        // through on either lane; the gate only fires for birdcage on
568        // the fast lane.
569        assert!(permits_loopback(Lane::Fast, BackendKind::Process));
570    }
571
572    #[test]
573    fn lane_policy_allows_fast_libkrun_and_firecracker_loopback() {
574        assert!(permits_loopback(Lane::Fast, BackendKind::Libkrun));
575        assert!(permits_loopback(Lane::Fast, BackendKind::Firecracker));
576        assert!(permits_loopback(Lane::Fast, BackendKind::Docker));
577    }
578
579    #[test]
580    fn lane_concurrency_defaults_match_plan() {
581        let cap = LaneConcurrency::defaults();
582        assert_eq!(cap.chain, 2);
583        assert_eq!(cap.fast, 8);
584        assert_eq!(cap.for_lane(Lane::Chain), 2);
585        assert_eq!(cap.for_lane(Lane::Fast), 8);
586    }
587
588    #[test]
589    fn select_auto_chain_picks_strongest_for_host() {
590        let sel = select_backend(BackendChoice::Auto, Lane::Chain);
591        // The ladder is platform-specific; what matters is that some
592        // backend always selects, the auto reason is filled in, and
593        // the chosen backend probes Ok at the time of the call.
594        assert!(probe(sel.backend).is_ok(), "selected backend must probe Ok");
595        assert!(sel.reason.contains("chain"));
596    }
597
598    #[test]
599    fn select_auto_fast_picks_birdcage_or_process() {
600        let sel = select_backend(BackendChoice::Auto, Lane::Fast);
601        #[cfg(any(target_os = "linux", target_os = "macos"))]
602        {
603            assert!(matches!(sel.backend, BackendKind::Birdcage | BackendKind::Process));
604        }
605        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
606        {
607            assert_eq!(sel.backend, BackendKind::Process);
608        }
609    }
610
611    #[test]
612    fn select_pinned_falls_back_when_unavailable() {
613        // Force libkrun unavailable by pointing the env override at a
614        // non-existent helper. The selector should fall back to the
615        // auto-pick and stamp a reason explaining the downgrade.
616        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
617        std::env::set_var("NYX_LIBKRUN_RUNNER", "/definitely/does/not/exist/libkrun-runner");
618        let sel = select_backend(BackendChoice::Pinned(BackendKind::Libkrun), Lane::Chain);
619        std::env::remove_var("NYX_LIBKRUN_RUNNER");
620        assert_ne!(
621            sel.backend,
622            BackendKind::Libkrun,
623            "pinned libkrun must downgrade when runner is missing"
624        );
625        assert!(sel.reason.contains("unavailable"));
626        assert!(sel.reason.contains("fell back"));
627    }
628
629    #[test]
630    fn probe_process_is_always_ok() {
631        assert!(probe(BackendKind::Process).is_ok());
632    }
633
634    #[test]
635    fn available_backends_includes_process() {
636        let kinds = available_backends();
637        assert!(kinds.contains(&BackendKind::Process));
638    }
639
640    #[test]
641    fn backend_kind_as_str_round_trip() {
642        for k in [
643            BackendKind::Process,
644            BackendKind::Birdcage,
645            BackendKind::Libkrun,
646            BackendKind::Firecracker,
647            BackendKind::Docker,
648        ] {
649            assert!(!k.as_str().is_empty());
650        }
651    }
652}