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}