diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 40281f056..c902d9fc3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -286,7 +286,7 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} - docker: + docker-amd64: needs: [setup, build] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-22.04 @@ -303,15 +303,83 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image + - name: Build and push amd64 image uses: docker/build-push-action@v5 with: context: . + # Build native per-arch images, then fuse them in docker-manifest. platforms: linux/amd64 push: true - tags: | - ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }} - ${{ needs.setup.outputs.is_beta != 'true' && format('ghcr.io/{0}:latest', github.repository) || '' }} + tags: ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}-amd64 + + docker-arm64: + needs: [setup, build] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push arm64 image + uses: docker/build-push-action@v5 + with: + context: . + # Native arm64 runner avoids slow full Rust builds under QEMU. + platforms: linux/arm64 + push: true + tags: ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}-arm64 + + docker-manifest: + needs: [setup, docker-amd64, docker-arm64] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-22.04 + steps: + - name: Set up QEMU for cross-platform smoke tests + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create versioned multi-arch manifest + run: | + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }} \ + ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}-arm64 + + - name: Create latest multi-arch manifest + if: needs.setup.outputs.is_beta != 'true' + run: | + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:latest \ + ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}-arm64 + + - name: Smoke test multi-arch image + run: | + set -euo pipefail + IMAGE="ghcr.io/${{ github.repository }}:v${{ needs.setup.outputs.version }}" + for platform in linux/amd64 linux/arm64; do + output=$(docker run --rm --platform="$platform" "$IMAGE" --version) + echo "$platform: $output" + echo "$output" | grep -F "${{ needs.setup.outputs.version }}" + done update-release-notes: needs: release diff --git a/cli/src/commands/autopilot/mod.rs b/cli/src/commands/autopilot/mod.rs index e6fa4a2c8..975879238 100644 --- a/cli/src/commands/autopilot/mod.rs +++ b/cli/src/commands/autopilot/mod.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeSet, HashSet}; +use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -669,6 +670,7 @@ struct SandboxStatusJson { consecutive_failures: Option, last_ok: Option, last_error: Option, + startup_error: Option, } #[derive(Debug, Serialize)] @@ -793,6 +795,58 @@ async fn run_startup_preflight(config: &AppConfig, bind_addr: &str) -> Result<() Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum StartupReadinessPhase { + Ready, + Waiting(String), +} + +fn startup_readiness_phase( + expects_sandbox: bool, + body: &serde_json::Value, +) -> StartupReadinessPhase { + if !expects_sandbox { + return StartupReadinessPhase::Ready; + } + + if let Some(sandbox) = body.get("sandbox") + && (sandbox.get("healthy").and_then(|v| v.as_bool()) == Some(true) + || sandbox + .get("startup_error") + .and_then(|v| v.as_str()) + .is_some()) + { + return StartupReadinessPhase::Ready; + } + + StartupReadinessPhase::Waiting("Starting sandbox container...".to_string()) +} + +async fn attach_persistent_sandbox_if_configured( + app_state: stakpak_server::AppState, + sandbox_mode: &stakpak_server::SandboxMode, + sandbox_config: &stakpak_server::SandboxConfig, + spawn: F, +) -> stakpak_server::AppState +where + F: FnOnce(stakpak_server::SandboxConfig) -> Fut, + Fut: Future>, +{ + if *sandbox_mode != stakpak_server::SandboxMode::Persistent { + return app_state; + } + + tracing::info!("Persistent sandbox mode: spawning sandbox at startup"); + match spawn(sandbox_config.clone()).await { + Ok(persistent) => app_state.with_persistent_sandbox(persistent), + Err(error) => { + tracing::error!(error = %error, "Failed to spawn persistent sandbox at startup"); + tracing::warn!("continuing without persistent sandbox"); + app_state.with_persistent_sandbox_startup_error(error) + } + } +} + async fn start_autopilot(config: &mut AppConfig, options: StartOptions) -> Result<(), String> { let autopilot_config_path = AutopilotConfigFile::path(); let needs_setup = !autopilot_config_path.exists() || options.force; @@ -942,7 +996,13 @@ async fn start_autopilot(config: &mut AppConfig, options: StartOptions) -> Resul // user just sees a frozen "waiting" message. By pulling here we inherit // stdout/stderr so Docker's progress bars are visible. This applies to both // persistent and ephemeral modes since both need the image. - ensure_sandbox_image_available()?; + if let Err(error) = ensure_sandbox_image_available() { + tracing::warn!(error = %error, "Failed to pre-pull sandbox image; continuing startup"); + eprintln!(" ⚠ Failed to pre-pull sandbox image; continuing startup"); + eprintln!( + " Persistent sandbox startup will report the failure if Docker cannot pull it in the service." + ); + } let expects_sandbox = effective_server.sandbox_mode == stakpak_server::SandboxMode::Persistent; @@ -971,24 +1031,14 @@ async fn start_autopilot(config: &mut AppConfig, options: StartOptions) -> Resul // Determine current phase for display let phase = match reqwest::get(&health_url).await { Ok(resp) => match resp.json::().await { - Ok(body) => { - if expects_sandbox { - if let Some(sandbox) = body.get("sandbox") - && sandbox.get("healthy").and_then(|v| v.as_bool()) == Some(true) - { - // Clear the spinner line and break - print!("\r\x1b[2K"); - let _ = std::io::Write::flush(&mut std::io::stdout()); - break true; - } - "Starting sandbox container...".to_string() - } else { - // Server is up and no sandbox needed — done + Ok(body) => match startup_readiness_phase(expects_sandbox, &body) { + StartupReadinessPhase::Ready => { print!("\r\x1b[2K"); let _ = std::io::Write::flush(&mut std::io::stdout()); break true; } - } + StartupReadinessPhase::Waiting(phase) => phase, + }, Err(_) => "Starting server...".to_string(), }, Err(_) => "Starting server...".to_string(), @@ -1323,17 +1373,14 @@ async fn start_foreground_runtime( tracing::info!(image = %stakpak_image, mode = %sandbox_mode, warden = %sandbox_config.warden_path, "Sandbox config initialized"); let app_state = app_state.with_sandbox(sandbox_config.clone()); - // If persistent mode, spawn the sandbox now so sessions get near-zero startup overhead. - // This is a hard requirement — if the sandbox fails to start, the server cannot operate. - let app_state = if *sandbox_mode == stakpak_server::SandboxMode::Persistent { - tracing::info!("Persistent sandbox mode: spawning sandbox at startup"); - let persistent = stakpak_server::PersistentSandbox::spawn(&sandbox_config) - .await - .map_err(|e| format!("Failed to spawn persistent sandbox: {e}. The server requires a healthy sandbox to operate. Check Docker is running and the image is available."))?; - app_state.with_persistent_sandbox(persistent) - } else { - app_state - }; + // Non-fatal: keep gateway/scheduler/API up even if the sandbox can't spawn. + let app_state = attach_persistent_sandbox_if_configured( + app_state, + sandbox_mode, + &sandbox_config, + |config| async move { stakpak_server::PersistentSandbox::spawn(&config).await }, + ) + .await; // --- 2. Loopback connection for schedule + gateway runtimes --- let loopback_url = loopback_server_url(listener_addr); @@ -2639,6 +2686,22 @@ fn gateway_channel_count(config_path: &Path) -> Result { Ok(config.enabled_channels().len()) } +fn format_sandbox_status(sandbox: &SandboxStatusJson, server_reachable: bool) -> String { + if let Some(error) = sandbox.startup_error.as_deref() { + return format!("✗ {} (startup failed: {error})", sandbox.mode); + } + + match (sandbox.healthy, sandbox.mode.as_str()) { + (Some(true), mode) => format!("✓ healthy ({mode})"), + (Some(false), mode) => { + let err = sandbox.last_error.as_deref().unwrap_or("unknown error"); + format!("✗ unhealthy ({mode}) — {err}") + } + (None, mode) if server_reachable => format!("- {mode} (no health data)"), + (None, mode) => format!("- {mode} (server unreachable)"), + } +} + async fn status_autopilot( config: &AppConfig, json: bool, @@ -2689,6 +2752,10 @@ async fn status_autopilot( .get("last_error") .and_then(|v| v.as_str()) .map(String::from), + startup_error: s + .get("startup_error") + .and_then(|v| v.as_str()) + .map(String::from), }) }); (true, sandbox) @@ -2711,6 +2778,7 @@ async fn status_autopilot( } else { Some("Server unreachable — cannot determine sandbox health".to_string()) }, + startup_error: None, }); let server = EndpointStatusJson { @@ -2789,15 +2857,7 @@ async fn status_autopilot( describe_tool_policy(&resolved_tool_policy) ); // Sandbox status - let sandbox_display = match (sandbox.healthy, sandbox.mode.as_str()) { - (Some(true), mode) => format!("✓ healthy ({mode})"), - (Some(false), mode) => { - let err = sandbox.last_error.as_deref().unwrap_or("unknown error"); - format!("✗ unhealthy ({mode}) — {err}") - } - (None, mode) if server_reachable => format!("- {mode} (no health data)"), - (None, mode) => format!("- {mode} (server unreachable)"), - }; + let sandbox_display = format_sandbox_status(&sandbox, server_reachable); println!(" Sandbox {sandbox_display}"); // Scheduler status @@ -3362,7 +3422,7 @@ fn ensure_sandbox_image_available() -> Result<(), String> { format!( "{e}\n\n\ Troubleshoot:\n \ - docker pull --platform linux/amd64 {image} Pull manually\n \ + docker pull {image} Pull manually\n \ STAKPAK_AGENT_IMAGE= Override image" ) })?; @@ -4731,6 +4791,108 @@ max_turns = 12 assert!(gateway_cfg.gateway.approval_allowlist.is_empty()); } + #[tokio::test] + async fn soft_fail_persistent_sandbox_spawn_records_error_without_handle() { + let storage_backend = stakpak_api::LocalStorage::new(":memory:") + .await + .expect("local storage should initialize"); + let storage: Arc = Arc::new(storage_backend); + let model = stakai::Model::custom("test-model", "openai"); + let state = stakpak_server::AppState::new( + storage, + Arc::new(stakpak_server::EventLog::new(16)), + Arc::new(stakpak_server::IdempotencyStore::new( + std::time::Duration::from_secs(60), + )), + Arc::new(stakai::Inference::new()), + vec![model.clone()], + Some(model), + stakpak_server::ToolApprovalPolicy::with_defaults(), + ); + let sandbox_config = stakpak_server::SandboxConfig { + warden_path: "warden".to_string(), + image: "ghcr.io/stakpak/agent:does-not-exist-xyz".to_string(), + volumes: Vec::new(), + mode: stakpak_server::SandboxMode::Persistent, + user_mapping: stakpak_server::SandboxUserMapping::ImageDefault, + }; + + let result = attach_persistent_sandbox_if_configured( + state.with_sandbox(sandbox_config.clone()), + &stakpak_server::SandboxMode::Persistent, + &sandbox_config, + |_config| async { Err("image not found".to_string()) }, + ) + .await; + + assert!(result.persistent_sandbox.is_none()); + assert_eq!( + result.persistent_sandbox_startup_error.as_deref(), + Some("image not found") + ); + } + + #[test] + fn startup_waiter_treats_missing_sandbox_expectation_as_ready() { + let body = serde_json::json!({ "status": "ok" }); + + assert_eq!( + startup_readiness_phase(false, &body), + StartupReadinessPhase::Ready + ); + } + + #[test] + fn startup_waiter_treats_healthy_persistent_sandbox_as_ready() { + let body = serde_json::json!({ + "status": "ok", + "sandbox": { + "mode": "persistent", + "healthy": true + } + }); + + assert_eq!( + startup_readiness_phase(true, &body), + StartupReadinessPhase::Ready + ); + } + + #[test] + fn startup_waiter_treats_persistent_sandbox_startup_failure_as_ready() { + let body = serde_json::json!({ + "status": "ok", + "sandbox": { + "mode": "persistent", + "healthy": false, + "startup_error": "image not found" + } + }); + + assert_eq!( + startup_readiness_phase(true, &body), + StartupReadinessPhase::Ready + ); + } + + #[test] + fn sandbox_status_display_distinguishes_startup_failure() { + let sandbox = SandboxStatusJson { + mode: "persistent".to_string(), + healthy: Some(false), + consecutive_ok: None, + consecutive_failures: None, + last_ok: None, + last_error: Some("image not found".to_string()), + startup_error: Some("image not found".to_string()), + }; + + assert_eq!( + format_sandbox_status(&sandbox, true), + "✗ persistent (startup failed: image not found)" + ); + } + #[test] fn status_json_schema_contains_core_fields() { let payload = AutopilotStatusJson { @@ -4762,6 +4924,7 @@ max_turns = 12 consecutive_failures: Some(0), last_ok: Some("2026-01-01T00:00:00Z".to_string()), last_error: None, + startup_error: None, }, scheduler: SchedulerStatusJson { expected_enabled: true, diff --git a/cli/src/prompts/system_prompt.v1.md b/cli/src/prompts/system_prompt.v1.md index bf93a56f3..862a0fdda 100644 --- a/cli/src/prompts/system_prompt.v1.md +++ b/cli/src/prompts/system_prompt.v1.md @@ -475,7 +475,7 @@ Configure sandbox lifecycle in `~/.stakpak/autopilot.toml`: sandbox_mode = "persistent" # or "ephemeral" ``` -- **`persistent`** (default): Single container at startup, reused across sessions. Fast, shared filesystem. +- **`persistent`** (default): Single container at startup, reused across sessions. Fast, shared filesystem. If the container fails to spawn at `stakpak up` time, autopilot still starts and `stakpak autopilot status` reports `startup failed` for the sandbox; sandboxed sessions fail until the environment is fixed and autopilot is restarted. - **`ephemeral`**: New container per session, destroyed on end. Maximum isolation, ~5-10s overhead. Per-schedule: `stakpak autopilot schedule add ... --sandbox` diff --git a/libs/server/src/routes.rs b/libs/server/src/routes.rs index 329c2b7fe..e87eaba7d 100644 --- a/libs/server/src/routes.rs +++ b/libs/server/src/routes.rs @@ -44,6 +44,8 @@ struct SandboxStatusResponse { consecutive_failures: u64, last_ok: Option, last_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + startup_error: Option, } #[derive(Debug, Serialize)] @@ -303,18 +305,45 @@ pub fn protected_router(auth: AuthConfig) -> Router { .route_layer(middleware::from_fn_with_state(auth, require_bearer)) } +fn short_sandbox_startup_reason(error: &str) -> String { + let first_line = error + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error"); + + first_line.chars().take(160).collect() +} + async fn health_handler(State(state): State) -> Json { - let sandbox = state.persistent_sandbox.as_ref().map(|ps| { + let sandbox = if let Some(ps) = state.persistent_sandbox.as_ref() { let h = ps.health(); - SandboxStatusResponse { + Some(SandboxStatusResponse { mode: ps.mode().to_string(), healthy: h.healthy, consecutive_ok: h.consecutive_ok, consecutive_failures: h.consecutive_failures, last_ok: h.last_ok, last_error: h.last_error, - } - }); + startup_error: None, + }) + } else { + state + .persistent_sandbox_startup_error + .as_ref() + .map(|error| { + let reason = short_sandbox_startup_reason(error); + SandboxStatusResponse { + mode: state.sandbox_mode().to_string(), + healthy: false, + consecutive_ok: 0, + consecutive_failures: 1, + last_ok: None, + last_error: Some(reason.clone()), + startup_error: Some(reason), + } + }) + }; Json(HealthResponse { status: "ok", @@ -1427,6 +1456,79 @@ mod tests { ); } + #[test] + fn short_sandbox_startup_reason_sanitizes_verbose_errors() { + let raw = "Timed out waiting for container\n\nContainer stderr:\nFailed to read /Users/alice/.stakpak/auth.toml: Permission denied"; + + assert_eq!( + short_sandbox_startup_reason(raw), + "Timed out waiting for container" + ); + } + + async fn fetch_health_with_startup_error(error: String) -> serde_json::Value { + let state = match test_state().await { + Ok(state) => state + .with_sandbox(crate::SandboxConfig { + warden_path: "warden".to_string(), + image: "ghcr.io/stakpak/agent:does-not-exist-xyz".to_string(), + volumes: Vec::new(), + mode: crate::SandboxMode::Persistent, + user_mapping: crate::SandboxUserMapping::ImageDefault, + }) + .with_persistent_sandbox_startup_error(error), + Err(error) => panic!("failed to create app state: {error}"), + }; + let app = router(state, AuthConfig::token("secret")); + + let request = match Request::builder().uri("/v1/health").body(Body::empty()) { + Ok(request) => request, + Err(error) => panic!("failed to build request: {error}"), + }; + + let response = match app.oneshot(request).await { + Ok(response) => response, + Err(error) => panic!("request should succeed: {error}"), + }; + assert_eq!(response.status(), StatusCode::OK); + + let body = match to_bytes(response.into_body(), 1024 * 1024).await { + Ok(body) => body, + Err(error) => panic!("failed to read body: {error}"), + }; + match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => panic!("invalid json: {error}"), + } + } + + #[tokio::test] + async fn health_endpoint_reports_persistent_sandbox_startup_error() { + let body = fetch_health_with_startup_error("image not found".to_string()).await; + + assert_eq!( + body.get("sandbox") + .and_then(|sandbox| sandbox.get("startup_error")) + .and_then(|value| value.as_str()), + Some("image not found") + ); + } + + #[tokio::test] + async fn health_endpoint_sanitizes_persistent_sandbox_startup_error() { + let body = fetch_health_with_startup_error( + "Timed out waiting for container\n\nContainer stderr:\nsecret path".to_string(), + ) + .await; + + assert_eq!( + body.get("sandbox") + .and_then(|sandbox| sandbox.get("startup_error")) + .and_then(|value| value.as_str()), + Some("Timed out waiting for container") + ); + } + #[tokio::test] async fn health_endpoint_is_public() { let app = match test_state().await { diff --git a/libs/server/src/sandbox.rs b/libs/server/src/sandbox.rs index f182422d5..278aef8a8 100644 --- a/libs/server/src/sandbox.rs +++ b/libs/server/src/sandbox.rs @@ -431,7 +431,9 @@ impl SandboxedMcpServer { // 6. Wait for the MCP server inside the container to be ready let server_url = format!("https://127.0.0.1:{container_host_port}/mcp"); tracing::info!(url = %server_url, "Waiting for sandbox MCP server to be ready"); - wait_for_server_ready(&server_url, &container_client_config).await?; + if let Err(error) = wait_for_server_ready(&server_url, &container_client_config).await { + return Err(sandbox_runtime_error(&mut container_process, error).await); + } tracing::info!("Sandbox MCP server is ready"); // 7. Start a per-session proxy connecting to the sandboxed server @@ -721,6 +723,11 @@ async fn sandbox_bootstrap_error(process: &mut Child, base_message: &str) -> Str format_bootstrap_error(base_message, exit_status, stderr_excerpt.as_deref()) } +async fn sandbox_runtime_error(process: &mut Child, error: String) -> String { + let _ = ensure_process_exited(process).await; + error +} + async fn ensure_process_exited(process: &mut Child) -> Option { if let Ok(Some(status)) = process.try_wait() { return Some(status); @@ -1178,6 +1185,55 @@ MIIB0zCCAXmgAwIBAgIUFAKE= assert!(!argv.iter().any(|a| a.starts_with("STAKPAK_TARGET_GID"))); } + #[tokio::test] + async fn sandbox_runtime_error_terminates_spawned_process() { + let mut process = match tokio::process::Command::new("sh") + .arg("-c") + .arg("sleep 30") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(process) => process, + Err(error) => panic!("failed to spawn sleep process: {error}"), + }; + + let message = + super::sandbox_runtime_error(&mut process, "readiness failed".to_string()).await; + + assert_eq!(message, "readiness failed"); + match process.try_wait() { + Ok(Some(_)) => {} + Ok(None) => panic!("process should be terminated"), + Err(error) => panic!("failed to inspect process: {error}"), + } + } + + #[tokio::test] + async fn persistent_sandbox_spawn_returns_error_when_warden_is_missing() { + let config = super::SandboxConfig { + warden_path: "/definitely/missing/stakpak-warden".to_string(), + image: "ghcr.io/stakpak/agent:does-not-exist-xyz".to_string(), + volumes: vec![], + mode: super::SandboxMode::Persistent, + user_mapping: super::SandboxUserMapping::ImageDefault, + }; + + let result = super::PersistentSandbox::spawn(&config).await; + + let error = match result { + Ok(_) => panic!("missing warden should fail spawn"), + Err(error) => error, + }; + assert!( + error.contains("Failed to start sandbox MCP server") + || error.contains("No such file") + || error.contains("os error"), + "unexpected error: {error}" + ); + } + #[test] fn sandbox_health_default_is_healthy() { let h = super::SandboxHealth::default(); diff --git a/libs/server/src/state.rs b/libs/server/src/state.rs index 80f2cb3eb..96496780d 100644 --- a/libs/server/src/state.rs +++ b/libs/server/src/state.rs @@ -42,6 +42,9 @@ pub struct AppState { /// Pre-spawned persistent sandbox (when `SandboxMode::Persistent` is configured). /// Shared across all sessions — avoids per-session container startup overhead. pub persistent_sandbox: Option>, + /// Startup failure recorded when persistent sandbox mode is configured but spawn failed. + /// Mutually exclusive with `persistent_sandbox` — at most one is `Some`. + pub persistent_sandbox_startup_error: Option, pub base_system_prompt: Option, pub context_budget: ContextBudget, /// Base directory for project context discovery (AGENTS.md, APPS.md). @@ -82,6 +85,7 @@ impl AppState { mcp_proxy_shutdown_tx: None, sandbox_config: None, persistent_sandbox: None, + persistent_sandbox_startup_error: None, base_system_prompt: None, context_budget: ContextBudget::default(), project_dir: None, @@ -113,6 +117,13 @@ impl AppState { /// Sessions will reuse this sandbox instead of spawning ephemeral ones. pub fn with_persistent_sandbox(mut self, sandbox: PersistentSandbox) -> Self { self.persistent_sandbox = Some(Arc::new(sandbox)); + self.persistent_sandbox_startup_error = None; + self + } + + pub fn with_persistent_sandbox_startup_error(mut self, error: String) -> Self { + self.persistent_sandbox = None; + self.persistent_sandbox_startup_error = Some(error); self }