From e3bf5a6f2412e656ed1019e30924c2caf092f79e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sat, 7 Mar 2026 15:53:12 -0800 Subject: [PATCH 1/2] feat(cli): switch community sandbox registry to CloudFront CDN Update the default --from registry from ghcr.io/nvidia to the CloudFront distribution so community sandbox images are pulled from the same CDN as other Navigator images. --- architecture/sandbox-custom-containers.md | 4 ++-- crates/navigator-cli/src/main.rs | 2 +- crates/navigator-cli/src/run.rs | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/architecture/sandbox-custom-containers.md b/architecture/sandbox-custom-containers.md index 64d297e41d..f64bb4c264 100644 --- a/architecture/sandbox-custom-containers.md +++ b/architecture/sandbox-custom-containers.md @@ -8,7 +8,7 @@ The `--from` flag accepts four kinds of input: | Input | Example | Behavior | |-------|---------|----------| -| **Community sandbox name** | `--from openclaw` | Resolves to `ghcr.io/nvidia/nemoclaw-community/sandboxes/openclaw:latest` | +| **Community sandbox name** | `--from openclaw` | Resolves to `d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes/openclaw:latest` | | **Dockerfile path** | `--from ./Dockerfile` | Builds the image, pushes it into the cluster, then creates the sandbox | | **Directory with Dockerfile** | `--from ./my-sandbox/` | Uses the directory as the build context | | **Full image reference** | `--from myregistry.com/img:tag` | Uses the image directly | @@ -22,7 +22,7 @@ The CLI classifies the value in this order: 3. **Contains `/`, `:`, or `.`** — treated as a full container image reference. 4. **Otherwise** — treated as a community sandbox name, expanded to `{NEMOCLAW_COMMUNITY_REGISTRY}/{name}:latest`. -The community registry prefix defaults to `ghcr.io/nvidia/nemoclaw-community/sandboxes` and can be overridden with the `NEMOCLAW_COMMUNITY_REGISTRY` environment variable. +The community registry prefix defaults to `d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes` and can be overridden with the `NEMOCLAW_COMMUNITY_REGISTRY` environment variable. ### Dockerfile build flow diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 0d6faad3f7..b33de07d0b 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -646,7 +646,7 @@ enum SandboxCommands { /// image reference (e.g., `myregistry.com/img:tag`). /// /// Community names are resolved to - /// `ghcr.io/nvidia/nemoclaw-community/sandboxes/:latest` + /// `d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes/:latest` /// (override the prefix with `NEMOCLAW_COMMUNITY_REGISTRY`). /// /// When given a Dockerfile or directory, the image is built and pushed diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index 8482968390..b8132bfb82 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -1379,7 +1379,8 @@ pub async fn sandbox_create( /// Bare sandbox names (e.g., `openclaw`) are expanded to /// `{prefix}/{name}:latest` using this value. Override with the /// `NEMOCLAW_COMMUNITY_REGISTRY` environment variable. -const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/nemoclaw-community/sandboxes"; +const DEFAULT_COMMUNITY_REGISTRY: &str = + "d1i0nduu2f6qxk.cloudfront.net/nemoclaw-community/sandboxes"; /// Resolved source for the `--from` flag on `sandbox create`. enum ResolvedSource { From 06d7b33266278989b1e2fa4788811eab894f781a Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sat, 7 Mar 2026 19:40:29 -0800 Subject: [PATCH 2/2] fix(sandbox): run supervisor as root in BYOC/community image pods Custom and community sandbox images may set a non-root USER directive (e.g. USER sandbox), but the side-loaded supervisor binary needs root for network namespace creation, proxy setup, and Landlock/seccomp configuration. - Set runAsUser: 0 in the pod securityContext when supervisor bootstrap mode is active (apply_supervisor_bootstrap) - Auto-detect the image's intended sandbox user at supervisor startup by checking /etc/passwd when no run_as_user is configured in policy - Derive SSH session USER/HOME from the policy's run_as_user instead of hardcoding sandbox//sandbox --- crates/navigator-sandbox/src/lib.rs | 67 +++- crates/navigator-sandbox/src/ssh.rs | 33 +- crates/navigator-server/src/sandbox/mod.rs | 361 +++++++++++++++++++-- 3 files changed, 434 insertions(+), 27 deletions(-) diff --git a/crates/navigator-sandbox/src/lib.rs b/crates/navigator-sandbox/src/lib.rs index fd69d51ed3..736f0d8a06 100644 --- a/crates/navigator-sandbox/src/lib.rs +++ b/crates/navigator-sandbox/src/lib.rs @@ -137,7 +137,7 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let navigator_endpoint_for_proxy = navigator_endpoint.clone(); - let (policy, opa_engine) = load_policy( + let (mut policy, opa_engine) = load_policy( sandbox_id.clone(), sandbox, navigator_endpoint.clone(), @@ -146,6 +146,14 @@ pub async fn run_sandbox( ) .await?; + // When no run_as_user/group is configured (e.g. custom/community images + // where the CLI cleared identity) and the supervisor is running as root + // (forced via runAsUser: 0 in the pod spec), auto-detect the image's + // intended sandbox user. This prevents child processes from running as + // root unnecessarily. + #[cfg(unix)] + maybe_infer_sandbox_user(&mut policy); + // Fetch provider environment variables from the server. // This is done after loading the policy so the sandbox can still start // even if provider env fetch fails (graceful degradation). @@ -926,6 +934,63 @@ fn discover_policy_from_path(path: &std::path::Path) -> navigator_core::proto::S } } +/// When the policy has no `run_as_user`/`run_as_group` and the supervisor is +/// running as root, try to detect the image's intended non-root user for +/// child process privilege dropping. +/// +/// This handles the BYOC / community-image case where the CLI clears +/// `run_as_user` (to avoid failures if the default "sandbox" user doesn't +/// exist), but the K8s pod spec forces `runAsUser: 0` so the supervisor can +/// perform privileged setup. Without this, child processes would inherit +/// root — undermining the image author's security intent. +/// +/// Detection strategy: look for a `sandbox` user in `/etc/passwd`. This is +/// the conventional non-root user in NemoClaw sandbox images. +#[cfg(unix)] +fn maybe_infer_sandbox_user(policy: &mut SandboxPolicy) { + use nix::unistd::{Uid, User}; + + // Only infer when both fields are empty and we're running as root. + let has_user = policy + .process + .run_as_user + .as_ref() + .is_some_and(|u| !u.is_empty()); + let has_group = policy + .process + .run_as_group + .as_ref() + .is_some_and(|g| !g.is_empty()); + if has_user || has_group || !Uid::effective().is_root() { + return; + } + + // Check if a "sandbox" user exists in the image. + match User::from_name("sandbox") { + Ok(Some(_)) => { + info!( + "Policy has no run_as_user but image has 'sandbox' user; \ + inferring run_as_user=sandbox, run_as_group=sandbox" + ); + policy.process.run_as_user = Some("sandbox".to_string()); + policy.process.run_as_group = Some("sandbox".to_string()); + } + Ok(None) => { + warn!( + "Policy has no run_as_user and image has no 'sandbox' user; \ + child processes will run as root. Set process.run_as_user in \ + your sandbox policy to specify a non-root user." + ); + } + Err(e) => { + warn!( + error = %e, + "Failed to look up 'sandbox' user; child processes will run as root" + ); + } + } +} + /// Prepare filesystem for the sandboxed process. /// /// Creates `read_write` directories if they don't exist and sets ownership diff --git a/crates/navigator-sandbox/src/ssh.rs b/crates/navigator-sandbox/src/ssh.rs index ab75eea4b2..92e6b81a1c 100644 --- a/crates/navigator-sandbox/src/ssh.rs +++ b/crates/navigator-sandbox/src/ssh.rs @@ -596,6 +596,25 @@ impl Default for PtyRequest { } } +/// Derive the session USER and HOME from the policy's `run_as_user`. +/// +/// Falls back to `("sandbox", "/sandbox")` when the policy has no explicit user, +/// preserving backward compatibility with images that use the default layout. +fn session_user_and_home(policy: &SandboxPolicy) -> (String, String) { + match policy.process.run_as_user.as_deref() { + Some(user) if !user.is_empty() => { + // Look up the user's home directory from /etc/passwd. + let home = nix::unistd::User::from_name(user) + .ok() + .flatten() + .map(|u| u.dir.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("/home/{user}")); + (user.to_string(), home) + } + _ => ("sandbox".to_string(), "/sandbox".to_string()), + } +} + #[allow(clippy::too_many_arguments)] fn spawn_pty_shell( policy: &SandboxPolicy, @@ -651,13 +670,17 @@ fn spawn_pty_shell( // set in /sandbox/.bashrc by the Dockerfile and sourced via login shell. let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + // Derive USER and HOME from the policy's run_as_user when available, + // falling back to "sandbox" / "/sandbox" for backward compatibility. + let (session_user, session_home) = session_user_and_home(policy); + cmd.env_clear() .stdin(stdin) .stdout(stdout) .stderr(stderr) .env("NEMOCLAW_SANDBOX", "1") - .env("HOME", "/sandbox") - .env("USER", "sandbox") + .env("HOME", &session_home) + .env("USER", &session_user) .env("SHELL", "/bin/bash") .env("PATH", &path) .env("TERM", term); @@ -813,13 +836,15 @@ fn spawn_pipe_exec( let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + let (session_user, session_home) = session_user_and_home(policy); + cmd.env_clear() .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .env("NEMOCLAW_SANDBOX", "1") - .env("HOME", "/sandbox") - .env("USER", "sandbox") + .env("HOME", &session_home) + .env("USER", &session_user) .env("SHELL", "/bin/bash") .env("PATH", &path) .env("TERM", "dumb"); diff --git a/crates/navigator-server/src/sandbox/mod.rs b/crates/navigator-server/src/sandbox/mod.rs index 2700f625af..ac22b75b4f 100644 --- a/crates/navigator-server/src/sandbox/mod.rs +++ b/crates/navigator-server/src/sandbox/mod.rs @@ -17,8 +17,14 @@ use navigator_core::proto::{ use std::collections::BTreeMap; use std::net::IpAddr; use std::sync::Arc; +use std::time::Duration; use tracing::{debug, info, warn}; +/// Timeout for individual Kubernetes API calls (create, delete, get). +/// This prevents gRPC handlers from blocking indefinitely when the k8s +/// API server is unreachable or slow. +const KUBE_API_TIMEOUT: Duration = Duration::from_secs(30); + const SANDBOX_GROUP: &str = "agents.x-k8s.io"; const SANDBOX_VERSION: &str = "v1alpha1"; pub const SANDBOX_KIND: &str = "Sandbox"; @@ -59,7 +65,16 @@ impl SandboxClient { ssh_handshake_skew_secs: u64, client_tls_secret_name: String, ) -> Result { - let client = Client::try_default().await?; + let mut config = match kube::Config::incluster() { + Ok(c) => c, + Err(_) => kube::Config::infer() + .await + .map_err(kube::Error::InferConfig)?, + }; + config.connect_timeout = Some(Duration::from_secs(10)); + config.read_timeout = Some(Duration::from_secs(30)); + config.write_timeout = Some(Duration::from_secs(30)); + let client = Client::try_from(config)?; Ok(Self { client, namespace, @@ -144,8 +159,9 @@ impl SandboxClient { ); let api = self.api(); - match api.create(&PostParams::default(), &obj).await { - Ok(result) => { + match tokio::time::timeout(KUBE_API_TIMEOUT, api.create(&PostParams::default(), &obj)).await + { + Ok(Ok(result)) => { info!( sandbox_id = %sandbox.id, sandbox_name = %name, @@ -153,7 +169,7 @@ impl SandboxClient { ); Ok(result) } - Err(err) => { + Ok(Err(err)) => { warn!( sandbox_id = %sandbox.id, sandbox_name = %name, @@ -162,6 +178,23 @@ impl SandboxClient { ); Err(err) } + Err(_elapsed) => { + warn!( + sandbox_id = %sandbox.id, + sandbox_name = %name, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Timed out creating sandbox in Kubernetes" + ); + Err(KubeError::Api(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + ), + reason: "Timeout".to_string(), + code: 504, + })) + } } } @@ -173,16 +206,18 @@ impl SandboxClient { ); let api = self.api(); - match api.delete(name, &DeleteParams::default()).await { - Ok(_response) => { + match tokio::time::timeout(KUBE_API_TIMEOUT, api.delete(name, &DeleteParams::default())) + .await + { + Ok(Ok(_response)) => { info!(sandbox_name = %name, "Sandbox deleted from Kubernetes"); Ok(true) } - Err(KubeError::Api(err)) if err.code == 404 => { + Ok(Err(KubeError::Api(err))) if err.code == 404 => { debug!(sandbox_name = %name, "Sandbox not found in Kubernetes (already deleted)"); Ok(false) } - Err(err) => { + Ok(Err(err)) => { warn!( sandbox_name = %name, error = %err, @@ -190,6 +225,22 @@ impl SandboxClient { ); Err(err) } + Err(_elapsed) => { + warn!( + sandbox_name = %name, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Timed out deleting sandbox from Kubernetes" + ); + Err(KubeError::Api(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + ), + reason: "Timeout".to_string(), + code: 504, + })) + } } } } @@ -274,6 +325,126 @@ pub fn spawn_sandbox_watcher( }); } +/// Interval between store-vs-k8s reconciliation sweeps. +const RECONCILE_INTERVAL: Duration = Duration::from_secs(60); + +/// How long a sandbox can stay in `Provisioning` in the store without a +/// corresponding Kubernetes resource before it is considered orphaned and +/// removed. +const ORPHAN_GRACE_PERIOD: Duration = Duration::from_secs(120); + +/// Periodically reconcile the store against Kubernetes to clean up orphaned +/// sandbox records. A record is orphaned when it exists in the store but +/// has no corresponding Kubernetes `Sandbox` CR — typically because the +/// k8s create timed out or the gRPC handler was cancelled. +pub fn spawn_store_reconciler( + store: Arc, + client: SandboxClient, + index: crate::sandbox_index::SandboxIndex, + watch_bus: crate::sandbox_watch::SandboxWatchBus, + tracing_log_bus: crate::tracing_bus::TracingLogBus, +) { + tokio::spawn(async move { + // Wait for initial startup to settle before running the first sweep. + tokio::time::sleep(RECONCILE_INTERVAL).await; + + loop { + if let Err(e) = + reconcile_orphaned_sandboxes(&store, &client, &index, &watch_bus, &tracing_log_bus) + .await + { + warn!(error = %e, "Store reconciliation sweep failed"); + } + tokio::time::sleep(RECONCILE_INTERVAL).await; + } + }); +} + +/// Single reconciliation sweep: list all sandboxes in the store that are +/// still `Provisioning`, check if they have a corresponding k8s resource, +/// and remove any that have been orphaned beyond the grace period. +async fn reconcile_orphaned_sandboxes( + store: &Store, + client: &SandboxClient, + index: &crate::sandbox_index::SandboxIndex, + watch_bus: &crate::sandbox_watch::SandboxWatchBus, + tracing_log_bus: &crate::tracing_bus::TracingLogBus, +) -> Result<(), String> { + let records = store + .list(Sandbox::object_type(), 500, 0) + .await + .map_err(|e| e.to_string())?; + + let api = client.api(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + for record in records { + let sandbox: Sandbox = match prost::Message::decode(record.payload.as_slice()) { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "Failed to decode sandbox record during reconciliation"); + continue; + } + }; + + // Only check sandboxes that are still provisioning — these are the + // ones at risk of being orphaned. + if sandbox.phase != SandboxPhase::Provisioning as i32 { + continue; + } + + // Check how old this record is using the store's created_at_ms. + let age_ms = now_ms.saturating_sub(record.created_at_ms); + if age_ms < ORPHAN_GRACE_PERIOD.as_millis() as i64 { + continue; + } + + // Check if a corresponding k8s resource exists. + match tokio::time::timeout(KUBE_API_TIMEOUT, api.get(&sandbox.name)).await { + Ok(Ok(_)) => { + // k8s resource exists — not orphaned. + continue; + } + Ok(Err(KubeError::Api(err))) if err.code == 404 => { + // k8s resource does not exist — orphaned store entry. + info!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + age_secs = age_ms / 1000, + "Removing orphaned sandbox from store (no corresponding k8s resource)" + ); + if let Err(e) = store.delete(Sandbox::object_type(), &sandbox.id).await { + warn!(sandbox_id = %sandbox.id, error = %e, "Failed to remove orphaned sandbox"); + } + index.remove_sandbox(&sandbox.id); + watch_bus.notify(&sandbox.id); + tracing_log_bus.remove(&sandbox.id); + tracing_log_bus.platform_event_bus.remove(&sandbox.id); + watch_bus.remove(&sandbox.id); + } + Ok(Err(err)) => { + // k8s API error — skip this record and try again next cycle. + debug!( + sandbox_id = %sandbox.id, + error = %err, + "Skipping orphan check due to k8s API error" + ); + } + Err(_elapsed) => { + debug!( + sandbox_id = %sandbox.id, + "Skipping orphan check due to k8s API timeout" + ); + } + } + } + + Ok(()) +} + async fn handle_applied( store: &Store, client: &SandboxClient, @@ -293,19 +464,24 @@ async fn handle_applied( let status = status_from_object(&obj); let phase = derive_phase(&status, deletion_timestamp); - let mut sandbox = store + let existing = store .get_message::(&id) .await - .map_err(|e| e.to_string())? - .unwrap_or_else(|| Sandbox { - id: id.clone(), - name: name.clone(), - namespace, - spec: None, - status: None, - phase: SandboxPhase::Unknown as i32, - ..Default::default() - }); + .map_err(|e| e.to_string())?; + + // If the record doesn't exist yet, the `create_sandbox` handler may + // still be in-flight (it creates the k8s resource first, then writes + // to the store). Build a minimal placeholder but never overwrite an + // existing record's `spec` — only the `create_sandbox` handler sets it. + let mut sandbox = existing.unwrap_or_else(|| Sandbox { + id: id.clone(), + name: name.clone(), + namespace, + spec: None, + status: None, + phase: SandboxPhase::Unknown as i32, + ..Default::default() + }); // Log phase transitions let old_phase = SandboxPhase::try_from(sandbox.phase).unwrap_or(SandboxPhase::Unknown); @@ -465,9 +641,15 @@ fn supervisor_volume_mount() -> serde_json::Value { /// Apply supervisor bootstrap transforms to an already-built pod template JSON. /// -/// This injects the init container, shared volume, volume mount, and command -/// override into the pod template, targeting the `agent` container (or the -/// first container if no `agent` is found). +/// This injects the init container, shared volume, volume mount, command +/// override, and `runAsUser: 0` into the pod template, targeting the `agent` +/// container (or the first container if no `agent` is found). +/// +/// The `runAsUser: 0` override ensures the side-loaded supervisor binary runs +/// as root regardless of the image's `USER` directive. The supervisor needs +/// root for network namespace creation, proxy setup, and Landlock/seccomp +/// configuration. It drops to the appropriate non-root user for child +/// processes via the policy's `run_as_user`/`run_as_group`. fn apply_supervisor_bootstrap(pod_template: &mut serde_json::Value, default_image: &str) { let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else { return; @@ -512,6 +694,18 @@ fn apply_supervisor_bootstrap(pod_template: &mut serde_json::Value, default_imag serde_json::json!([format!("{}/navigator-sandbox", SUPERVISOR_MOUNT_PATH)]), ); + // Force the supervisor to run as root (UID 0). Custom/community images + // may set a non-root USER directive (e.g. `USER sandbox`), but the + // supervisor needs root to create network namespaces, set up the proxy, + // and configure Landlock/seccomp. The supervisor itself drops privileges + // for child processes via the policy's `run_as_user`/`run_as_group`. + let security_context = container + .entry("securityContext") + .or_insert_with(|| serde_json::json!({})); + if let Some(sc) = security_context.as_object_mut() { + sc.insert("runAsUser".to_string(), serde_json::json!(0)); + } + // Add volume mount let volume_mounts = container .entry("volumeMounts") @@ -1252,4 +1446,127 @@ mod tests { Some("my-secret-value") ); } + + #[test] + fn supervisor_bootstrap_injects_run_as_user_zero() { + let mut pod_template = serde_json::json!({ + "spec": { + "containers": [{ + "name": "agent", + "image": "custom-image:latest", + "securityContext": { + "capabilities": { + "add": ["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE"] + } + } + }] + } + }); + + apply_supervisor_bootstrap(&mut pod_template, "default-image:latest"); + + let sc = &pod_template["spec"]["containers"][0]["securityContext"]; + assert_eq!(sc["runAsUser"], 0, "runAsUser must be 0 for supervisor"); + // Capabilities should be preserved + assert!( + sc["capabilities"]["add"] + .as_array() + .unwrap() + .contains(&serde_json::json!("SYS_ADMIN")) + ); + } + + #[test] + fn supervisor_bootstrap_adds_security_context_when_missing() { + let mut pod_template = serde_json::json!({ + "spec": { + "containers": [{ + "name": "agent", + "image": "custom-image:latest" + }] + } + }); + + apply_supervisor_bootstrap(&mut pod_template, "default-image:latest"); + + let sc = &pod_template["spec"]["containers"][0]["securityContext"]; + assert_eq!( + sc["runAsUser"], 0, + "runAsUser must be 0 even when no prior securityContext" + ); + } + + #[test] + fn supervisor_bootstrap_injects_init_container_and_volume() { + let mut pod_template = serde_json::json!({ + "spec": { + "containers": [{ + "name": "agent", + "image": "custom-image:latest" + }] + } + }); + + apply_supervisor_bootstrap(&mut pod_template, "default-image:latest"); + + // Init container should be present + let init_containers = pod_template["spec"]["initContainers"] + .as_array() + .expect("initContainers should exist"); + assert_eq!(init_containers.len(), 1); + assert_eq!(init_containers[0]["name"], "copy-supervisor"); + assert_eq!(init_containers[0]["image"], "default-image:latest"); + + // Volume should be present + let volumes = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist"); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0]["name"], SUPERVISOR_VOLUME_NAME); + + // Agent container command should be overridden + let command = pod_template["spec"]["containers"][0]["command"] + .as_array() + .expect("command should be set"); + assert_eq!( + command[0].as_str().unwrap(), + format!("{}/navigator-sandbox", SUPERVISOR_MOUNT_PATH) + ); + } + + #[test] + fn needs_supervisor_bootstrap_true_for_custom_image() { + let template = SandboxTemplate { + image: "custom-image:latest".to_string(), + ..SandboxTemplate::default() + }; + assert!(needs_supervisor_bootstrap( + &template, + "default-image:latest" + )); + } + + #[test] + fn needs_supervisor_bootstrap_false_for_default_image() { + let template = SandboxTemplate { + image: "default-image:latest".to_string(), + ..SandboxTemplate::default() + }; + assert!(!needs_supervisor_bootstrap( + &template, + "default-image:latest" + )); + } + + #[test] + fn needs_supervisor_bootstrap_false_for_empty_image() { + let template = SandboxTemplate { + image: String::new(), + ..SandboxTemplate::default() + }; + assert!(!needs_supervisor_bootstrap( + &template, + "default-image:latest" + )); + } }