diff --git a/.github/workflows/provider-integration.yml b/.github/workflows/provider-integration.yml index 5f63bfc1e6..a179f93a69 100644 --- a/.github/workflows/provider-integration.yml +++ b/.github/workflows/provider-integration.yml @@ -249,6 +249,87 @@ jobs: - name: Run provider E2E scenarios run: ./scripts/run-provider-e2e-daily.sh + cloud-sandbox-live-e2e: + name: Cloud Sandbox Live E2E + runs-on: ubuntu-latest + permissions: + contents: read + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Check cloud sandbox secrets + run: | + usable_backend=false + if [ -n "$VERCEL_TOKEN" ] && [ -z "$VERCEL_PROJECT_ID" ]; then + echo "::warning::VERCEL_TOKEN is configured but VERCEL_PROJECT_ID is missing, skipping live Vercel sandbox E2E" + echo "VERCEL_TOKEN=" >> "$GITHUB_ENV" + echo "VERCEL_TEAM_ID=" >> "$GITHUB_ENV" + if [ -z "$DAYTONA_API_KEY" ]; then + echo "SKIP=true" >> "$GITHUB_ENV" + fi + elif [ -n "$VERCEL_TOKEN" ]; then + usable_backend=true + fi + if [ -n "$DAYTONA_API_KEY" ]; then + usable_backend=true + fi + if [ "$usable_backend" != "true" ]; then + echo "::warning::No usable cloud sandbox secrets are configured, skipping live cloud sandbox E2E" + echo "SKIP=true" >> "$GITHUB_ENV" + fi + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: env.SKIP != 'true' + with: + node-version: "22" + cache: npm + cache-dependency-path: crates/web/ui/package-lock.json + + - name: Install npm dependencies + if: env.SKIP != 'true' + working-directory: crates/web/ui + run: npm ci + + - name: Cache Playwright browsers + if: env.SKIP != 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('crates/web/ui/package-lock.json') }} + + - name: Install Playwright browsers + if: env.SKIP != 'true' + timeout-minutes: 2 + working-directory: crates/web/ui + run: npx playwright install chromium + + - name: Run cloud sandbox live E2E + if: env.SKIP != 'true' + working-directory: crates/web/ui + env: + CI: "true" + MOLTIS_E2E_ONLY_PROJECT: remote-sandbox-live + MOLTIS_E2E_SKIP_DEFAULT_PROJECTS: "1" + run: npx playwright test --project=remote-sandbox-live e2e/specs/remote-sandbox-live.spec.js + + - name: Upload cloud sandbox live E2E results + if: ${{ !cancelled() && env.SKIP != 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: cloud-sandbox-live-e2e-${{ github.run_id }}-${{ github.run_attempt }} + path: | + crates/web/ui/playwright-report/ + crates/web/ui/test-results/ + if-no-files-found: ignore + retention-days: 14 + openai-live-e2e: name: OpenAI Live E2E runs-on: ubuntu-latest @@ -428,6 +509,7 @@ jobs: needs: - provider-tests - provider-e2e-scenarios + - cloud-sandbox-live-e2e - openai-live-e2e - ollama-qwen-live-e2e if: always() diff --git a/.gitignore b/.gitignore index 143f2a048d..359726538a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ apps/ios/local.xcconfig crates/web/ui/node_modules/ crates/web/ui/playwright-report/ crates/web/ui/test-results/ +test-results/ # Generated web assets (run `just build-web-assets` to regenerate) crates/web/src/assets/dist/ diff --git a/Cargo.lock b/Cargo.lock index d1b4e98426..b250f3569e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8341,6 +8341,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", + "flate2", "futures", "globset", "grep-matcher", @@ -8384,6 +8385,7 @@ dependencies = [ "uuid", "wasmtime", "wasmtime-wasi", + "which 8.0.2", ] [[package]] @@ -8490,6 +8492,7 @@ dependencies = [ "moltis-skills", "moltis-tools", "portable-pty", + "secrecy 0.8.0", "serde", "serde_json", "sysinfo", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 09f25777e4..d56a9c59fc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -170,6 +170,7 @@ full = [ "tls", "trusted-network", "vault", + "vercel-sandbox", "voice", "wasm", "web-ui", @@ -232,6 +233,7 @@ tls = ["moltis-httpd/tls"] trusted-network = ["moltis-httpd/trusted-network"] vault = ["moltis-httpd/vault", "moltis-web?/vault"] voice = ["moltis-gateway/voice", "moltis-web?/voice"] +vercel-sandbox = ["moltis-tools/vercel-sandbox"] wasm = ["moltis-tools/wasm"] web-ui = ["dep:moltis-web", "moltis-httpd/web-ui"] whatsapp = ["moltis-gateway/whatsapp"] diff --git a/crates/config/src/loader/config_io.rs b/crates/config/src/loader/config_io.rs index 9b933bb308..444229345b 100644 --- a/crates/config/src/loader/config_io.rs +++ b/crates/config/src/loader/config_io.rs @@ -29,6 +29,13 @@ fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> { /// Uses a two-pass approach so that `[env]` section values are available /// for `${VAR}` substitution in other sections of the same config file. pub fn load_config(path: &Path) -> crate::Result { + load_config_with_aliases(path, true) +} + +fn load_config_with_aliases( + path: &Path, + apply_third_party_aliases: bool, +) -> crate::Result { let raw = std::fs::read_to_string(path).map_err(|source| { crate::Error::external(format!("failed to read {}", path.display()), source) })?; @@ -46,7 +53,11 @@ pub fn load_config(path: &Path) -> crate::Result { parse_config(&second_pass, path)? }; - Ok(apply_env_overrides(config)) + Ok(apply_env_overrides_with_options( + config, + std::env::vars(), + apply_third_party_aliases, + )) } /// Load and parse the config file with env substitution and includes. @@ -165,9 +176,13 @@ pub fn discover_and_load() -> MoltisConfig { /// /// Identical to [`discover_and_load`]. Retained for backward compatibility. pub fn discover_and_load_readonly() -> MoltisConfig { + discover_and_load_readonly_with_aliases(true) +} + +fn discover_and_load_readonly_with_aliases(apply_third_party_aliases: bool) -> MoltisConfig { let mut cfg = if let Some(path) = find_config_file() { debug!(path = %path.display(), "loading config (read-only)"); - match load_layered_config(&path) { + match load_layered_config(&path, apply_third_party_aliases) { Ok(mut cfg) => { if cfg.server.port == 0 { cfg.server.port = generate_random_port(); @@ -176,11 +191,19 @@ pub fn discover_and_load_readonly() -> MoltisConfig { }, Err(e) => { warn!(path = %path.display(), error = %e, "failed to load config, using defaults"); - apply_env_overrides(MoltisConfig::default()) + apply_env_overrides_with_options( + MoltisConfig::default(), + std::env::vars(), + apply_third_party_aliases, + ) }, } } else { - apply_env_overrides(MoltisConfig::default()) + apply_env_overrides_with_options( + MoltisConfig::default(), + std::env::vars(), + apply_third_party_aliases, + ) }; // Merge markdown agent definitions (TOML presets take precedence). @@ -198,22 +221,28 @@ pub fn discover_and_load_readonly() -> MoltisConfig { /// that user overrides are additive (only keys present in the user file /// override the corresponding defaults). For YAML/JSON user files, falls /// back to a struct-level load (since `defaults.toml` is always TOML). -fn load_layered_config(user_path: &Path) -> crate::Result { +fn load_layered_config( + user_path: &Path, + apply_third_party_aliases: bool, +) -> crate::Result { let is_toml = user_path .extension() .and_then(|e| e.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("toml")); if is_toml { - load_layered_config_toml(user_path) + load_layered_config_toml(user_path, apply_third_party_aliases) } else { // YAML/JSON: simple struct-level load (no TOML-level merge). - load_config(user_path) + load_config_with_aliases(user_path, apply_third_party_aliases) } } /// TOML-specific layered loading with deep document merge. -fn load_layered_config_toml(user_path: &Path) -> crate::Result { +fn load_layered_config_toml( + user_path: &Path, + apply_third_party_aliases: bool, +) -> crate::Result { let user_raw = std::fs::read_to_string(user_path).map_err(|source| { crate::Error::external(format!("failed to read {}", user_path.display()), source) })?; @@ -242,7 +271,11 @@ fn load_layered_config_toml(user_path: &Path) -> crate::Result { )? }; - Ok(apply_env_overrides(config)) + Ok(apply_env_overrides_with_options( + config, + std::env::vars(), + apply_third_party_aliases, + )) } /// Find the first config file in standard locations. @@ -314,7 +347,7 @@ pub fn update_config(f: impl FnOnce(&mut MoltisConfig)) -> crate::Result crate: /// `MOLTIS_WEBAUTHN_RP_ID`, and `MOLTIS_WEBAUTHN_ORIGIN` are excluded /// (they are handled separately). pub fn apply_env_overrides(config: MoltisConfig) -> MoltisConfig { - apply_env_overrides_with(config, std::env::vars()) + apply_env_overrides_with_options(config, std::env::vars(), true) } /// Apply env overrides from an arbitrary iterator of (key, value) pairs. /// Exposed for testing without mutating the process environment. +#[cfg(test)] pub(super) fn apply_env_overrides_with( config: MoltisConfig, vars: impl Iterator, +) -> MoltisConfig { + apply_env_overrides_with_options(config, vars, true) +} + +#[cfg(test)] +pub(super) fn apply_env_overrides_without_aliases( + config: MoltisConfig, + vars: impl Iterator, +) -> MoltisConfig { + apply_env_overrides_with_options(config, vars, false) +} + +fn apply_env_overrides_with_options( + config: MoltisConfig, + vars: impl Iterator, + apply_third_party_aliases: bool, ) -> MoltisConfig { use serde_json::Value; @@ -762,7 +812,78 @@ pub(super) fn apply_env_overrides_with( }, }; + // Third-party env var aliases: standard env vars that map to config paths. + // These are only applied if the config field is empty/unset, so explicit + // config always takes precedence. + const ENV_ALIASES: &[(&str, &[&str])] = &[ + ("VERCEL_TOKEN", &[ + "tools", + "exec", + "sandbox", + "vercel_token", + ]), + ("VERCEL_OIDC_TOKEN", &[ + "tools", + "exec", + "sandbox", + "vercel_token", + ]), + ("VERCEL_PROJECT_ID", &[ + "tools", + "exec", + "sandbox", + "vercel_project_id", + ]), + ("VERCEL_TEAM_ID", &[ + "tools", + "exec", + "sandbox", + "vercel_team_id", + ]), + ("DAYTONA_API_KEY", &[ + "tools", + "exec", + "sandbox", + "daytona_api_key", + ]), + ("DAYTONA_API_URL", &[ + "tools", + "exec", + "sandbox", + "daytona_api_url", + ]), + ("DAYTONA_TARGET", &[ + "tools", + "exec", + "sandbox", + "daytona_target", + ]), + ]; + for (key, val) in vars { + // Check third-party aliases first (before the MOLTIS_ prefix check). + let mut matched_alias = false; + if apply_third_party_aliases { + for &(alias_key, path) in ENV_ALIASES { + if key == alias_key { + let path_parts: Vec = path.iter().map(|s| s.to_string()).collect(); + // Only apply if the field is currently null/empty. + let current = get_nested(&root, &path_parts); + if current.is_none() + || current == Some(&Value::Null) + || current.and_then(|v| v.as_str()).unwrap_or("x").is_empty() + { + set_nested(&mut root, &path_parts, parse_env_value(&val)); + } + matched_alias = true; + break; + } + } + } + if matched_alias { + continue; + } + if !key.starts_with("MOLTIS_") { continue; } @@ -869,6 +990,15 @@ pub(super) fn parse_env_value(val: &str) -> serde_json::Value { } /// Set a value at a nested JSON path, creating intermediate objects as needed. +/// Read a nested value from a JSON tree by path. +fn get_nested<'a>(root: &'a serde_json::Value, path: &[String]) -> Option<&'a serde_json::Value> { + let mut current = root; + for key in path { + current = current.get(key.as_str())?; + } + Some(current) +} + pub(super) fn set_nested(root: &mut serde_json::Value, path: &[String], val: serde_json::Value) { if path.is_empty() { return; diff --git a/crates/config/src/loader/tests.rs b/crates/config/src/loader/tests.rs index 1bcfce651a..2aa5c21852 100644 --- a/crates/config/src/loader/tests.rs +++ b/crates/config/src/loader/tests.rs @@ -7,8 +7,9 @@ use std::{path::PathBuf, sync::Mutex}; use super::{ config_io::{ - apply_env_overrides_with, parse_config, parse_env_value, resubstitute_config, - save_user_config_to_path, set_nested, strip_default_values, + apply_env_overrides_with, apply_env_overrides_without_aliases, parse_config, + parse_env_value, resubstitute_config, save_user_config_to_path, set_nested, + strip_default_values, }, *, }; diff --git a/crates/config/src/loader/tests/core.rs b/crates/config/src/loader/tests/core.rs index 625e1de657..41cd6fd5e1 100644 --- a/crates/config/src/loader/tests/core.rs +++ b/crates/config/src/loader/tests/core.rs @@ -1,3 +1,5 @@ +use secrecy::{ExposeSecret, Secret}; + use crate::{AgentIdentity, UserProfile, schema::MoltisConfig}; use super::*; @@ -1056,3 +1058,72 @@ fn load_guidelines_md_for_agent_falls_back_to_root() { clear_data_dir(); } + +#[test] +fn apply_env_overrides_vercel_token_alias() { + let vars = vec![("VERCEL_TOKEN".into(), "ver_test_123".into())]; + let config = apply_env_overrides_with(MoltisConfig::default(), vars.into_iter()); + assert_eq!( + config + .tools + .exec + .sandbox + .vercel_token + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("ver_test_123"), + "VERCEL_TOKEN env var should map to tools.exec.sandbox.vercel_token" + ); +} + +#[test] +fn apply_env_overrides_daytona_api_key_alias() { + let vars = vec![("DAYTONA_API_KEY".into(), "dyt_test_456".into())]; + let config = apply_env_overrides_with(MoltisConfig::default(), vars.into_iter()); + assert_eq!( + config + .tools + .exec + .sandbox + .daytona_api_key + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("dyt_test_456"), + "DAYTONA_API_KEY env var should map to tools.exec.sandbox.daytona_api_key" + ); +} + +#[test] +fn apply_env_overrides_alias_does_not_overwrite_explicit() { + let vars = vec![("VERCEL_TOKEN".into(), "from_env".into())]; + let mut config = MoltisConfig::default(); + config.tools.exec.sandbox.vercel_token = Some(Secret::new("from_config".into())); + let config = apply_env_overrides_with(config, vars.into_iter()); + assert_eq!( + config + .tools + .exec + .sandbox + .vercel_token + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("from_config"), + "explicit config should take precedence over env alias" + ); +} + +#[test] +fn apply_env_overrides_without_aliases_keeps_third_party_env_out() { + let vars = vec![ + ("VERCEL_TOKEN".into(), "ver_secret".into()), + ("DAYTONA_API_KEY".into(), "dyt_secret".into()), + ("MOLTIS_AUTH__DISABLED".into(), "true".into()), + ]; + let config = apply_env_overrides_without_aliases(MoltisConfig::default(), vars.into_iter()); + assert!(config.auth.disabled); + assert!(config.tools.exec.sandbox.vercel_token.is_none()); + assert!(config.tools.exec.sandbox.daytona_api_key.is_none()); +} diff --git a/crates/config/src/schema/tests.rs b/crates/config/src/schema/tests.rs index 1818e1bef7..3cd2ba47a4 100644 --- a/crates/config/src/schema/tests.rs +++ b/crates/config/src/schema/tests.rs @@ -1,4 +1,4 @@ -use secrecy::ExposeSecret; +use secrecy::{ExposeSecret, Secret}; use super::*; @@ -567,6 +567,50 @@ fn sandbox_defaults_include_go_runtime() { assert!(sandbox.wasm_tool_limits.is_none()); } +#[test] +fn sandbox_config_debug_redacts_remote_backend_credentials() { + let sandbox = SandboxConfig { + vercel_token: Some(Secret::new("vercel-secret-value".into())), + daytona_api_key: Some(Secret::new("daytona-secret-value".into())), + ..SandboxConfig::default() + }; + + let debug = format!("{sandbox:?}"); + + assert!(!debug.contains("vercel-secret-value")); + assert!(!debug.contains("daytona-secret-value")); + assert!(debug.contains("vercel_token")); + assert!(debug.contains("daytona_api_key")); +} + +#[test] +fn sandbox_config_deserializes_remote_backend_credentials_as_secrets() { + let sandbox: SandboxConfig = toml::from_str( + r#" +vercel_token = "vercel-secret-value" +daytona_api_key = "daytona-secret-value" +"#, + ) + .unwrap(); + + assert_eq!( + sandbox + .vercel_token + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("vercel-secret-value") + ); + assert_eq!( + sandbox + .daytona_api_key + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("daytona-secret-value") + ); +} + #[test] fn wasm_tool_limits_config_defaults() { let limits = WasmToolLimitsConfig::default(); diff --git a/crates/config/src/schema/tools.rs b/crates/config/src/schema/tools.rs index 7410949f37..d311b05544 100644 --- a/crates/config/src/schema/tools.rs +++ b/crates/config/src/schema/tools.rs @@ -711,6 +711,72 @@ pub struct SandboxConfig { /// Acts as layer 6 in the policy resolution chain. #[serde(default, skip_serializing_if = "Option::is_none")] pub tools_policy: Option, + + // ── Remote sandbox backends ───────────────────────────────────────── + /// Vercel API token for the Vercel Sandbox backend. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_option_secret", + deserialize_with = "deserialize_option_secret" + )] + pub vercel_token: Option>, + /// Vercel project ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vercel_project_id: Option, + /// Vercel team ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vercel_team_id: Option, + /// Vercel sandbox runtime (e.g. "node24", "python3.13"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vercel_runtime: Option, + /// Vercel sandbox timeout in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vercel_timeout_ms: Option, + /// Vercel sandbox vCPU count. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vercel_vcpus: Option, + + /// Daytona API key. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_option_secret", + deserialize_with = "deserialize_option_secret" + )] + pub daytona_api_key: Option>, + /// Daytona API URL (default: https://app.daytona.io/api). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona_api_url: Option, + /// Daytona target region/environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona_target: Option, + /// Custom image for Daytona sandbox creation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona_image: Option, + + /// Vercel snapshot ID for fast cold starts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vercel_snapshot_id: Option, + + /// Path to the `firecracker` binary (Linux only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firecracker_bin: Option, + /// Path to the uncompressed Linux kernel (`vmlinux`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firecracker_kernel: Option, + /// Path to the base ext4 rootfs image. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firecracker_rootfs: Option, + /// Path to the SSH private key for VM access. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firecracker_ssh_key: Option, + /// Number of vCPUs per Firecracker VM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firecracker_vcpus: Option, + /// Memory in MiB per Firecracker VM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub firecracker_memory_mb: Option, } /// Default packages installed in sandbox containers. @@ -911,6 +977,23 @@ impl Default for SandboxConfig { wasm_epoch_interval_ms: None, wasm_tool_limits: None, tools_policy: None, + vercel_token: None, + vercel_project_id: None, + vercel_team_id: None, + vercel_runtime: None, + vercel_timeout_ms: None, + vercel_vcpus: None, + daytona_api_key: None, + daytona_api_url: None, + daytona_target: None, + daytona_image: None, + vercel_snapshot_id: None, + firecracker_bin: None, + firecracker_kernel: None, + firecracker_rootfs: None, + firecracker_ssh_key: None, + firecracker_vcpus: None, + firecracker_memory_mb: None, } } } diff --git a/crates/discord/src/commands.rs b/crates/discord/src/commands.rs index 3e4c48155d..92da9774a7 100644 --- a/crates/discord/src/commands.rs +++ b/crates/discord/src/commands.rs @@ -231,7 +231,7 @@ async fn respond_ephemeral(ctx: &Context, command: &CommandInteraction, text: &s } #[cfg(test)] -#[allow(clippy::expect_used)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/gateway/src/approval.rs b/crates/gateway/src/approval.rs index 7e511fb374..4081bcd86d 100644 --- a/crates/gateway/src/approval.rs +++ b/crates/gateway/src/approval.rs @@ -262,6 +262,7 @@ mod tests { worktree_branch: None, sandbox_enabled: None, sandbox_image: None, + sandbox_backend: None, channel_binding: Some( serde_json::json!({ "channel_type": "telegram", @@ -302,6 +303,7 @@ mod tests { worktree_branch: None, sandbox_enabled: None, sandbox_image: None, + sandbox_backend: None, channel_binding: Some("{not-json".into()), parent_session_key: None, fork_point: None, diff --git a/crates/gateway/src/channel_events/commands/control_handlers.rs b/crates/gateway/src/channel_events/commands/control_handlers.rs index 71791e45cd..bbb6005539 100644 --- a/crates/gateway/src/channel_events/commands/control_handlers.rs +++ b/crates/gateway/src/channel_events/commands/control_handlers.rs @@ -495,7 +495,10 @@ pub(in crate::channel_events) async fn handle_sandbox( }; // List available images. - let builder = moltis_tools::image_cache::DockerImageBuilder::new(); + let cfg = moltis_config::discover_and_load(); + let builder = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &cfg.tools.exec.sandbox.backend, + ); let cached = builder.list_cached().await.unwrap_or_default(); let default_img = moltis_tools::sandbox::DEFAULT_SANDBOX_IMAGE.to_string(); @@ -563,7 +566,10 @@ pub(in crate::channel_events) async fn handle_sandbox( .map_err(|_| ChannelError::invalid_input("usage: /sandbox image [number]"))?; let default_img = moltis_tools::sandbox::DEFAULT_SANDBOX_IMAGE.to_string(); - let builder = moltis_tools::image_cache::DockerImageBuilder::new(); + let cfg = moltis_config::discover_and_load(); + let builder = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &cfg.tools.exec.sandbox.backend, + ); let cached = builder.list_cached().await.unwrap_or_default(); let mut images: Vec = vec![default_img]; for img in &cached { diff --git a/crates/gateway/src/channel_events/tests.rs b/crates/gateway/src/channel_events/tests.rs index 2772164448..5257cf389a 100644 --- a/crates/gateway/src/channel_events/tests.rs +++ b/crates/gateway/src/channel_events/tests.rs @@ -175,6 +175,7 @@ fn attachable_session_filter_skips_archived_and_cron_sessions() { worktree_branch: None, sandbox_enabled: None, sandbox_image: None, + sandbox_backend: None, channel_binding: None, parent_session_key: None, fork_point: None, @@ -218,6 +219,7 @@ fn format_attachable_sessions_shows_session_keys_when_labels_are_present() { worktree_branch: None, sandbox_enabled: None, sandbox_image: None, + sandbox_backend: None, channel_binding: None, parent_session_key: None, fork_point: None, @@ -242,6 +244,7 @@ fn format_attachable_sessions_shows_session_keys_when_labels_are_present() { worktree_branch: None, sandbox_enabled: None, sandbox_image: None, + sandbox_backend: None, channel_binding: None, parent_session_key: None, fork_point: None, diff --git a/crates/gateway/src/server/prepare_core.rs b/crates/gateway/src/server/prepare_core.rs index 3678876e12..68e9d13472 100644 --- a/crates/gateway/src/server/prepare_core.rs +++ b/crates/gateway/src/server/prepare_core.rs @@ -31,14 +31,12 @@ use { store::SessionStore, }, secrecy::{ExposeSecret, Secret}, - std::{ - path::PathBuf, - sync::{Arc, atomic::Ordering}, - }, + std::{path::PathBuf, sync::Arc}, tracing::{debug, info, warn}, }; mod log_persistence; mod post_state; +mod sandbox; /// Prepare the core gateway: load config, run migrations, wire services, /// spawn background tasks, and return the core state without any HTTP layer. /// This is the transport-agnostic initialisation. Non-HTTP consumers (TUI, @@ -880,15 +878,11 @@ pub async fn prepare_gateway_core( services = services.with_webhooks(live_webhooks); // Build sandbox router from config. - let mut sandbox_config = moltis_tools::sandbox::SandboxConfig::from(&config.tools.exec.sandbox); - sandbox_config.container_prefix = Some(sandbox_container_prefix); - sandbox_config.timezone = config - .user - .timezone - .as_ref() - .map(|tz| tz.name().to_string()); - let sandbox_router = Arc::new(moltis_tools::sandbox::SandboxRouter::new( - sandbox_config.clone(), + let sandbox_config = moltis_tools::sandbox::SandboxConfig::from(&config.tools.exec.sandbox); + let sandbox_router = Arc::new(sandbox::build_sandbox_router( + &sandbox_config, + &sandbox_container_prefix, + config.user.timezone.as_ref().map(|tz| tz.name()), )); // ── Upstream proxy (user-configured) ───────────────────────────────── @@ -971,191 +965,8 @@ pub async fn prepare_gateway_core( moltis_tools::init_shared_http_client(upstream_proxy); } - // Spawn background image pre-build. - { - let router = Arc::clone(&sandbox_router); - let backend = Arc::clone(router.backend()); - let packages = router.config().packages.clone(); - let base_image = router - .config() - .image - .clone() - .unwrap_or_else(|| moltis_tools::sandbox::DEFAULT_SANDBOX_IMAGE.to_string()); - - if super::helpers::should_prebuild_sandbox_image(router.mode(), &packages) { - let deferred_for_build = Arc::clone(&deferred_state); - sandbox_router.building_flag.store(true, Ordering::Relaxed); - let build_router = Arc::clone(&sandbox_router); - tokio::spawn(async move { - if let Some(state) = deferred_for_build.get() { - broadcast( - state, - "sandbox.image.build", - serde_json::json!({ - "phase": "start", - "package_count": packages.len(), - }), - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) - .await; - } - - match backend.build_image(&base_image, &packages).await { - Ok(Some(result)) => { - info!( - tag = %result.tag, - built = result.built, - "sandbox image pre-build complete" - ); - router.set_global_image(Some(result.tag.clone())).await; - build_router.building_flag.store(false, Ordering::Relaxed); - build_router.build_complete.notify_waiters(); - - if let Some(state) = deferred_for_build.get() { - broadcast( - state, - "sandbox.image.build", - serde_json::json!({ - "phase": "done", - "tag": result.tag, - "built": result.built, - }), - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) - .await; - } - }, - Ok(None) => { - debug!( - "sandbox image pre-build: no-op (no packages or unsupported backend)" - ); - build_router.building_flag.store(false, Ordering::Relaxed); - build_router.build_complete.notify_waiters(); - }, - Err(e) => { - tracing::warn!("sandbox image pre-build failed: {e}"); - build_router.building_flag.store(false, Ordering::Relaxed); - build_router.build_complete.notify_waiters(); - if let Some(state) = deferred_for_build.get() { - broadcast( - state, - "sandbox.image.build", - serde_json::json!({ - "phase": "error", - "error": e.to_string(), - }), - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) - .await; - } - }, - } - }); - } - } - - // Host package provisioning when no container runtime is available. - { - let packages = sandbox_router.config().packages.clone(); - if sandbox_router.backend_name() == "none" - && !packages.is_empty() - && moltis_tools::sandbox::is_debian_host() - { - let deferred_for_host = Arc::clone(&deferred_state); - let pkg_count = packages.len(); - tokio::spawn(async move { - if let Some(state) = deferred_for_host.get() { - broadcast( - state, - "sandbox.host.provision", - serde_json::json!({ - "phase": "start", - "count": pkg_count, - }), - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) - .await; - } - - match moltis_tools::sandbox::provision_host_packages(&packages).await { - Ok(Some(result)) => { - info!( - installed = result.installed.len(), - skipped = result.skipped.len(), - sudo = result.used_sudo, - "host package provisioning complete" - ); - if let Some(state) = deferred_for_host.get() { - broadcast( - state, - "sandbox.host.provision", - serde_json::json!({ - "phase": "done", - "installed": result.installed.len(), - "skipped": result.skipped.len(), - }), - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) - .await; - } - }, - Ok(None) => { - debug!("host package provisioning: no-op (not debian or empty packages)"); - }, - Err(e) => { - warn!("host package provisioning failed: {e}"); - if let Some(state) = deferred_for_host.get() { - broadcast( - state, - "sandbox.host.provision", - serde_json::json!({ - "phase": "error", - "error": e.to_string(), - }), - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) - .await; - } - }, - } - }); - } - } - - // Startup GC: remove orphaned session containers. - if sandbox_router.backend_name() != "none" { - let prefix = sandbox_router.config().container_prefix.clone(); - tokio::spawn(async move { - if let Some(prefix) = prefix { - match moltis_tools::sandbox::clean_all_containers(&prefix).await { - Ok(0) => {}, - Ok(n) => info!( - removed = n, - "startup GC: cleaned orphaned session containers" - ), - Err(e) => debug!("startup GC: container cleanup skipped: {e}"), - } - } - }); - } + // Spawn background sandbox tasks (image pre-build, host provisioning, container GC). + sandbox::spawn_sandbox_background_tasks(&sandbox_router, &deferred_state); // Periodic cron session retention pruning. if let Some(retention_days) = config.cron.session_retention_days @@ -1287,19 +1098,11 @@ pub async fn prepare_gateway_core( }); } - // Load persisted sandbox overrides from session metadata. - { - for entry in session_metadata.list().await { - if let Some(enabled) = entry.sandbox_enabled { - sandbox_router.set_override(&entry.key, enabled).await; - } - if let Some(ref image) = entry.sandbox_image { - sandbox_router - .set_image_override(&entry.key, image.clone()) - .await; - } - } - } + LiveSessionService::restore_sandbox_router_overrides_from_metadata( + &session_metadata, + &sandbox_router, + ) + .await; // ── Channel initialization ─────────────────────────────────────────── let channel_result = init_channels::init_channels( diff --git a/crates/gateway/src/server/prepare_core/sandbox.rs b/crates/gateway/src/server/prepare_core/sandbox.rs new file mode 100644 index 0000000000..1bddda6f21 --- /dev/null +++ b/crates/gateway/src/server/prepare_core/sandbox.rs @@ -0,0 +1,313 @@ +//! Sandbox initialization helpers: router construction, background image build, +//! host provisioning, and startup container garbage collection. + +use std::sync::{Arc, atomic::Ordering}; + +use { + moltis_tools::sandbox::SandboxConfig, + secrecy::{ExposeSecret, Secret}, + tracing::{debug, info, warn}, +}; + +use crate::{ + broadcast::{BroadcastOpts, broadcast}, + server::helpers::should_prebuild_sandbox_image, + state::GatewayState, +}; + +/// Type alias for the deferred state used in prepare_core. +type DeferredState = tokio::sync::OnceCell>; + +fn has_secret(secret: &Option>) -> bool { + secret + .as_ref() + .is_some_and(|secret| !secret.expose_secret().is_empty()) +} + +/// Build the sandbox router with all configured backends registered. +pub(super) fn build_sandbox_router( + sandbox_config: &SandboxConfig, + container_prefix: &str, + timezone: Option<&str>, +) -> moltis_tools::sandbox::SandboxRouter { + let mut config = sandbox_config.clone(); + config.container_prefix = Some(container_prefix.to_string()); + config.timezone = timezone.map(ToOwned::to_owned); + + let mut router = moltis_tools::sandbox::SandboxRouter::new(config.clone()); + + // Register additional remote backends that have credentials configured. + // Env vars (VERCEL_TOKEN, DAYTONA_API_KEY) are resolved by the config crate + // into the config fields. + for (name, has_creds) in [ + ("vercel", has_secret(&config.vercel_token)), + ("daytona", has_secret(&config.daytona_api_key)), + ] { + if has_creds && router.backend_name() != name { + let backend = moltis_tools::sandbox::router::select_backend_by_name(name, &config); + if backend.backend_name() == name { + router.register_backend(backend); + } + } + } + + #[cfg(target_os = "linux")] + { + let name = "firecracker"; + let has_creds = + moltis_tools::sandbox::firecracker_bin_available(config.firecracker_bin.as_deref()); + if has_creds && router.backend_name() != name { + let backend = moltis_tools::sandbox::router::select_backend_by_name(name, &config); + if backend.backend_name() == name { + router.register_backend(backend); + } + } + } + + router +} + +/// Spawn background sandbox tasks: image pre-build, host provisioning, and +/// startup container GC. +pub(super) fn spawn_sandbox_background_tasks( + sandbox_router: &Arc, + deferred_state: &Arc, +) { + // Background image pre-build. + { + let router = Arc::clone(sandbox_router); + let backends = router.available_backend_instances(); + let default_backend_name = router.backend_name().to_string(); + let packages = router.config().packages.clone(); + let base_image = router + .config() + .image + .clone() + .unwrap_or_else(|| moltis_tools::sandbox::DEFAULT_SANDBOX_IMAGE.to_string()); + + if should_prebuild_sandbox_image(router.mode(), &packages) { + let deferred_for_build = Arc::clone(deferred_state); + sandbox_router.building_flag.store(true, Ordering::Relaxed); + let build_router = Arc::clone(sandbox_router); + tokio::spawn(async move { + if let Some(state) = deferred_for_build.get() { + broadcast( + state, + "sandbox.image.build", + serde_json::json!({ + "phase": "start", + "package_count": packages.len(), + }), + BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }, + ) + .await; + } + + let mut built_any = false; + let mut images = Vec::new(); + let mut errors = Vec::new(); + let mut default_result = None; + + for backend in backends { + let backend_name = backend.backend_name(); + match backend.build_image(&base_image, &packages).await { + Ok(Some(result)) => { + info!( + backend = backend_name, + tag = %result.tag, + built = result.built, + "sandbox image pre-build complete" + ); + built_any |= result.built; + if let Err(error) = router + .set_backend_image(backend_name, result.tag.clone()) + .await + { + warn!( + backend = backend_name, + %error, + "sandbox image pre-build result could not be registered" + ); + errors.push(serde_json::json!({ + "backend": backend_name, + "error": error.to_string(), + })); + continue; + } + if backend_name == default_backend_name { + router.set_global_image(Some(result.tag.clone())).await; + default_result = Some(result.clone()); + } + images.push(serde_json::json!({ + "backend": backend_name, + "tag": result.tag, + "built": result.built, + })); + }, + Ok(None) => { + debug!( + backend = backend_name, + "sandbox image pre-build: no-op (no packages or unsupported backend)" + ); + }, + Err(error) => { + warn!( + backend = backend_name, + "sandbox image pre-build failed: {error}" + ); + errors.push(serde_json::json!({ + "backend": backend_name, + "error": error.to_string(), + })); + }, + } + } + + build_router.building_flag.store(false, Ordering::Relaxed); + build_router.build_complete.notify_waiters(); + + if images.is_empty() && errors.is_empty() { + debug!("sandbox image pre-build: no-op (no packages or unsupported backends)"); + } + + if let Some(state) = deferred_for_build.get() { + if !images.is_empty() { + let mut payload = serde_json::json!({ + "phase": "done", + "built": built_any, + "images": images, + "errors": errors, + }); + if let Some(result) = default_result + && let Some(payload) = payload.as_object_mut() + { + payload.insert("tag".to_string(), serde_json::json!(result.tag)); + } + + broadcast(state, "sandbox.image.build", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) + .await; + } else if errors.is_empty() { + debug!( + "sandbox image pre-build: no-op (no packages or unsupported backend)" + ); + } else { + broadcast( + state, + "sandbox.image.build", + serde_json::json!({ + "phase": "error", + "error": "sandbox image pre-build failed", + "errors": errors, + }), + BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }, + ) + .await; + } + } + }); + } + } + + // Host package provisioning when no container runtime is available. + { + let packages = sandbox_router.config().packages.clone(); + if sandbox_router.backend_name() == "none" + && !packages.is_empty() + && moltis_tools::sandbox::is_debian_host() + { + let deferred_for_host = Arc::clone(deferred_state); + let pkg_count = packages.len(); + tokio::spawn(async move { + if let Some(state) = deferred_for_host.get() { + broadcast( + state, + "sandbox.host.provision", + serde_json::json!({ + "phase": "start", + "count": pkg_count, + }), + BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }, + ) + .await; + } + + match moltis_tools::sandbox::provision_host_packages(&packages).await { + Ok(Some(result)) => { + info!( + installed = result.installed.len(), + skipped = result.skipped.len(), + sudo = result.used_sudo, + "host package provisioning complete" + ); + if let Some(state) = deferred_for_host.get() { + broadcast( + state, + "sandbox.host.provision", + serde_json::json!({ + "phase": "done", + "installed": result.installed.len(), + "skipped": result.skipped.len(), + }), + BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }, + ) + .await; + } + }, + Ok(None) => { + debug!("host package provisioning: no-op (not debian or empty packages)"); + }, + Err(e) => { + warn!("host package provisioning failed: {e}"); + if let Some(state) = deferred_for_host.get() { + broadcast( + state, + "sandbox.host.provision", + serde_json::json!({ + "phase": "error", + "error": e.to_string(), + }), + BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }, + ) + .await; + } + }, + } + }); + } + } + + // Startup GC: remove orphaned session containers. + if sandbox_router.backend_name() != "none" { + let prefix = sandbox_router.config().container_prefix.clone(); + tokio::spawn(async move { + if let Some(prefix) = prefix { + match moltis_tools::sandbox::clean_all_containers(&prefix).await { + Ok(0) => {}, + Ok(n) => info!( + removed = n, + "startup GC: cleaned orphaned session containers" + ), + Err(e) => debug!("startup GC: container cleanup skipped: {e}"), + } + } + }); + } +} diff --git a/crates/gateway/src/session/service.rs b/crates/gateway/src/session/service.rs index 2c89b34edd..6483412d29 100644 --- a/crates/gateway/src/session/service.rs +++ b/crates/gateway/src/session/service.rs @@ -85,6 +85,38 @@ impl LiveSessionService { self } + pub async fn restore_sandbox_router_overrides(&self) { + let Some(router) = self.sandbox_router.as_ref() else { + return; + }; + + Self::restore_sandbox_router_overrides_from_metadata(&self.metadata, router).await; + } + + pub async fn restore_sandbox_router_overrides_from_metadata( + metadata: &SqliteSessionMetadata, + router: &SandboxRouter, + ) { + for entry in metadata.list().await { + if let Some(enabled) = entry.sandbox_enabled { + router.set_override(&entry.key, enabled).await; + } + if let Some(ref image) = entry.sandbox_image { + router.set_image_override(&entry.key, image.clone()).await; + } + if let Some(ref backend) = entry.sandbox_backend + && let Err(e) = router.set_backend_override(&entry.key, backend).await + { + tracing::debug!( + session = entry.key, + backend = backend.as_str(), + error = %e, + "skipping persisted sandbox backend override (backend not available)" + ); + } + } + } + pub fn with_agent_persona_store(mut self, store: Arc) -> Self { self.agent_persona_store = Some(store); self @@ -559,6 +591,21 @@ impl SessionService for LiveSessionService { } } } + if let Some(sandbox_backend_opt) = p.sandbox_backend { + let sandbox_backend = sandbox_backend_opt.filter(|s| !s.is_empty()); + self.metadata + .set_sandbox_backend(key, sandbox_backend.clone()) + .await; + if let Some(ref router) = self.sandbox_router { + if let Some(ref name) = sandbox_backend { + if let Err(e) = router.set_backend_override(key, name).await { + warn!(session = key, error = %e, "failed to set sandbox backend override"); + } + } else { + router.remove_backend_override(key).await; + } + } + } let entry = self .metadata @@ -573,6 +620,7 @@ impl SessionService for LiveSessionService { "archived": entry.archived, "sandbox_enabled": entry.sandbox_enabled, "sandbox_image": entry.sandbox_image, + "sandbox_backend": entry.sandbox_backend, "worktree_branch": entry.worktree_branch, "mcpDisabled": entry.mcp_disabled, "agent_id": entry.agent_id, diff --git a/crates/gateway/src/session/tests.rs b/crates/gateway/src/session/tests.rs index 593db50117..e7e7693710 100644 --- a/crates/gateway/src/session/tests.rs +++ b/crates/gateway/src/session/tests.rs @@ -860,6 +860,50 @@ mod tests { pool } + #[tokio::test] + async fn restore_sandbox_router_overrides_rehydrates_persisted_backend() { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(SessionStore::new(dir.path().to_path_buf())); + let pool = sqlite_pool().await; + let metadata = Arc::new(SqliteSessionMetadata::new(pool)); + metadata + .upsert("session:abc", Some("Cloud session".to_string())) + .await + .unwrap(); + metadata + .set_sandbox_backend("session:abc", Some("restricted-host".to_string())) + .await; + metadata + .set_sandbox_image("session:abc", Some("custom:image".to_string())) + .await; + metadata + .set_sandbox_enabled("session:abc", Some(true)) + .await; + + let mut router = SandboxRouter::new(moltis_tools::sandbox::SandboxConfig { + backend: "docker".to_string(), + ..Default::default() + }); + router.register_backend(Arc::new(moltis_tools::sandbox::RestrictedHostSandbox::new( + moltis_tools::sandbox::SandboxConfig::default(), + ))); + let router = Arc::new(router); + let service = LiveSessionService::new(store, Arc::clone(&metadata)) + .with_sandbox_router(Arc::clone(&router)); + + service.restore_sandbox_router_overrides().await; + + assert_eq!( + router.resolve_backend("session:abc").await.backend_name(), + "restricted-host" + ); + assert!(router.is_sandboxed("session:abc").await); + assert_eq!( + router.resolve_image("session:abc", None).await, + "custom:image" + ); + } + #[tokio::test] async fn resolve_dispatches_session_start_with_channel_binding() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/gateway/src/session_types.rs b/crates/gateway/src/session_types.rs index 959c35393a..434160eb17 100644 --- a/crates/gateway/src/session_types.rs +++ b/crates/gateway/src/session_types.rs @@ -38,6 +38,8 @@ pub struct PatchParams { pub mcp_disabled: Option>, #[serde(default, deserialize_with = "double_option", alias = "sandbox_enabled")] pub sandbox_enabled: Option>, + #[serde(default, deserialize_with = "double_option", alias = "sandbox_backend")] + pub sandbox_backend: Option>, } /// Deserialize a field as `Some(inner)` when present (even if null), diff --git a/crates/portable/src/lib.rs b/crates/portable/src/lib.rs index 3286264d2e..d0b9939914 100644 --- a/crates/portable/src/lib.rs +++ b/crates/portable/src/lib.rs @@ -14,7 +14,7 @@ pub use { }; #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod integration_tests { use {super::*, std::io::Cursor}; diff --git a/crates/sessions/migrations/20260430120000_session_sandbox_backend.sql b/crates/sessions/migrations/20260430120000_session_sandbox_backend.sql new file mode 100644 index 0000000000..1762721b8a --- /dev/null +++ b/crates/sessions/migrations/20260430120000_session_sandbox_backend.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN sandbox_backend TEXT; diff --git a/crates/sessions/src/metadata.rs b/crates/sessions/src/metadata.rs index ff1c19adc4..9652ec9202 100644 --- a/crates/sessions/src/metadata.rs +++ b/crates/sessions/src/metadata.rs @@ -34,6 +34,8 @@ pub struct SessionEntry { #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_image: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_backend: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub channel_binding: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_session_key: Option, @@ -132,6 +134,7 @@ impl SessionMetadata { worktree_branch: None, sandbox_enabled: None, sandbox_image: None, + sandbox_backend: None, channel_binding: None, parent_session_key: None, fork_point: None, @@ -207,6 +210,15 @@ impl SessionMetadata { } } + /// Set the sandbox_backend override for a session. + pub fn set_sandbox_backend(&mut self, key: &str, backend: Option) { + if let Some(entry) = self.entries.get_mut(key) { + entry.sandbox_backend = backend; + entry.updated_at = now_ms(); + entry.version += 1; + } + } + /// Set the mcp_disabled override for a session. pub fn set_mcp_disabled(&mut self, key: &str, disabled: Option) { if let Some(entry) = self.entries.get_mut(key) { @@ -317,6 +329,7 @@ struct SessionRow { worktree_branch: Option, sandbox_enabled: Option, sandbox_image: Option, + sandbox_backend: Option, channel_binding: Option, parent_session_key: Option, fork_point: Option, @@ -344,6 +357,7 @@ impl From for SessionEntry { worktree_branch: r.worktree_branch, sandbox_enabled: r.sandbox_enabled.map(|v| v != 0), sandbox_image: r.sandbox_image, + sandbox_backend: r.sandbox_backend, channel_binding: r.channel_binding, parent_session_key: r.parent_session_key, fork_point: r.fork_point.map(|v| v as u32), @@ -409,6 +423,7 @@ impl SqliteSessionMetadata { worktree_branch TEXT, sandbox_enabled INTEGER, sandbox_image TEXT, + sandbox_backend TEXT, channel_binding TEXT, parent_session_key TEXT, fork_point INTEGER, @@ -650,6 +665,22 @@ impl SqliteSessionMetadata { }); } + pub async fn set_sandbox_backend(&self, key: &str, backend: Option) { + let now = now_ms() as i64; + sqlx::query( + "UPDATE sessions SET sandbox_backend = ?, updated_at = ?, version = version + 1 WHERE key = ?", + ) + .bind(&backend) + .bind(now) + .bind(key) + .execute(&self.pool) + .await + .ok(); + self.emit(crate::session_events::SessionEvent::Patched { + session_key: key.to_string(), + }); + } + pub async fn set_worktree_branch(&self, key: &str, branch: Option) { let now = now_ms() as i64; sqlx::query( diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 2094af0412..2ef9fa724e 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -5,9 +5,10 @@ version.workspace = true [features] bundled-skills = ["moltis-skills/bundled-skills"] -default = ["firecrawl", "fs-tools", "metrics", "wasm"] -embedded-wasm = [] -firecrawl = [] +default = ["firecrawl", "fs-tools", "metrics", "vercel-sandbox", "wasm"] +embedded-wasm = [] +firecrawl = [] +vercel-sandbox = [] # Native filesystem tools (Read, Write, Edit, MultiEdit, Glob, Grep). # See moltis-org/moltis#657. Default-on; build with # --no-default-features to omit them from minimal builds. @@ -20,13 +21,14 @@ fs-tools = [ "dep:pdf-extract", ] metrics = ["dep:moltis-metrics"] -wasm = ["dep:shell-words", "dep:wasmtime", "dep:wasmtime-wasi", "wasmtime/component-model"] +wasm = ["dep:wasmtime", "dep:wasmtime-wasi", "wasmtime/component-model"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } +flate2 = { workspace = true } futures = { workspace = true } globset = { optional = true, workspace = true } grep-matcher = { optional = true, workspace = true } @@ -55,7 +57,7 @@ secrecy = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } -shell-words = { optional = true, workspace = true } +shell-words = { workspace = true } sqlx = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } @@ -67,6 +69,7 @@ url = { workspace = true } uuid = { workspace = true } wasmtime = { optional = true, workspace = true } wasmtime-wasi = { optional = true, workspace = true } +which = { workspace = true } [dev-dependencies] mockito = { workspace = true } diff --git a/crates/tools/src/exec.rs b/crates/tools/src/exec.rs index 486784405d..691c9c9d5d 100644 --- a/crates/tools/src/exec.rs +++ b/crates/tools/src/exec.rs @@ -457,7 +457,8 @@ impl AgentTool for ExecTool { // fail with ENOENT on the host, so we must fall back to the host // data directory. let has_container_backend = if let Some(ref router) = self.sandbox_router { - router.backend().provides_fs_isolation() + let sk = session_key.unwrap_or("main"); + router.resolve_backend(sk).await.provides_fs_isolation() } else { self.sandbox.provides_fs_isolation() }; @@ -497,15 +498,9 @@ impl AgentTool for ExecTool { Some(ref dir) if !dir.is_absolute() => { Some(PathBuf::from("/home/sandbox").join(dir)) }, - // Absolute paths are only allowed inside the sandbox home. - Some(ref dir) if dir.starts_with("/home/sandbox") => explicit_working_dir, - Some(ref dir) => { - debug!( - path = %dir.display(), - "explicit working_dir is outside /home/sandbox while sandboxed, using default" - ); - None - }, + // Absolute paths are passed through (the backend's exec() + // will map them to its own workspace if needed). + Some(_) => explicit_working_dir, None => None, } }; @@ -513,6 +508,8 @@ impl AgentTool for ExecTool { let using_default_working_dir = validated_explicit.is_none(); let mut working_dir = validated_explicit.or_else(|| { if !runs_on_host { + // Use the generic sandbox home as default. Each backend's exec() + // method uses its own workspace_dir() if working_dir doesn't exist. Some(PathBuf::from("/home/sandbox")) } else { Some(host_default_dir()) @@ -602,8 +599,10 @@ impl AgentTool for ExecTool { let sk = session_key.unwrap_or("main"); if is_sandboxed { let id = router.sandbox_id_for(sk); - let image = router.resolve_image_nowait(sk, None).await; - let backend = router.backend(); + let backend = router.resolve_backend(sk).await; + let image = router + .resolve_image_for_backend_nowait(sk, None, backend.backend_name()) + .await; info!(session = sk, sandbox_id = %id, backend = backend.backend_name(), image, "sandbox ensure_ready"); let announce_prepare = router.mark_preparing_once(sk).await; if announce_prepare { @@ -617,6 +616,9 @@ impl AgentTool for ExecTool { if let Err(error) = backend.ensure_ready(&id, Some(&image)).await { if announce_prepare { router.clear_prepared_session(sk).await; + if backend.is_isolated() { + router.mark_sync_failed(sk, error.to_string()).await; + } router.emit_event(crate::sandbox::SandboxEvent::PrepareFailed { session_key: sk.to_string(), backend: backend.backend_name().to_string(), @@ -633,6 +635,82 @@ impl AgentTool for ExecTool { backend: backend.backend_name().to_string(), image: image.clone(), }); + + // Sync workspace and provision packages for isolated backends on first run. + if backend.is_isolated() { + let sync_ok = if let Some(host_workspace) = + crate::sandbox::sync::resolve_sync_workspace(router.config(), &id) + { + let sandbox_workspace = backend.workspace_dir_for(&id).await; + match crate::sandbox::sync::sync_in( + &*backend, + &id, + &host_workspace, + &sandbox_workspace, + ) + .await + { + Ok(()) => true, + Err(e) => { + let error = e.to_string(); + warn!( + session = sk, + sandbox_id = %id, + error = %error, + "workspace sync-in failed" + ); + router.clear_prepared_session(sk).await; + router.mark_sync_failed(sk, error.clone()).await; + return Err(Error::message(format!( + "workspace sync-in failed: {error}" + )) + .into()); + }, + } + } else { + true + }; + + // Provision packages only if sync succeeded (no point + // provisioning if we couldn't even connect to the sandbox) + // and no pre-built image was used. + if sync_ok { + let has_prebuilt = image + != crate::sandbox::types::DEFAULT_SANDBOX_IMAGE + && !image.is_empty(); + let packages = &router.config().packages; + if !has_prebuilt + && !packages.is_empty() + && let Err(e) = backend.provision_packages(&id, packages).await + { + warn!( + session = sk, + sandbox_id = %id, + error = %e, + "package provisioning failed (non-fatal)" + ); + } + } + + // Always mark synced to unblock concurrent waiters. + // The sandbox is ready for exec regardless of sync outcome. + router.mark_synced(sk).await; + } + } else if backend.is_isolated() && !router.is_synced(sk).await { + // Another caller is performing sync_in; wait for it. + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + while !router.is_synced(sk).await { + if tokio::time::Instant::now() >= deadline { + warn!(session = sk, "timed out waiting for workspace sync-in"); + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + if let Some(error) = router.sync_failure(sk).await { + return Err( + Error::message(format!("sandbox preparation failed: {error}")).into(), + ); } debug!(session = sk, sandbox_id = %id, command, "sandbox running command"); let mut sandbox_result = backend.exec(&id, command, &opts).await?; diff --git a/crates/tools/src/exec/tests.rs b/crates/tools/src/exec/tests.rs index d0e7e53dd1..b9af2dfd1d 100644 --- a/crates/tools/src/exec/tests.rs +++ b/crates/tools/src/exec/tests.rs @@ -330,6 +330,83 @@ impl Sandbox for NonWaitingSandbox { } } +struct FailingIsolatedSandbox; + +#[async_trait] +impl Sandbox for FailingIsolatedSandbox { + fn backend_name(&self) -> &'static str { + "docker" + } + + fn provides_fs_isolation(&self) -> bool { + true + } + + fn is_isolated(&self) -> bool { + true + } + + async fn ensure_ready(&self, _id: &SandboxId, _image_override: Option<&str>) -> Result<()> { + Err(Error::message("ensure_ready failed")) + } + + async fn exec(&self, _id: &SandboxId, _command: &str, _opts: &ExecOpts) -> Result { + Err(Error::message("no active sandbox")) + } + + async fn cleanup(&self, _id: &SandboxId) -> Result<()> { + Ok(()) + } +} + +#[derive(Default)] +struct SyncUploadFailingSandbox { + ensure_ready_calls: AtomicUsize, + write_file_calls: AtomicUsize, +} + +#[async_trait] +impl Sandbox for SyncUploadFailingSandbox { + fn backend_name(&self) -> &'static str { + "docker" + } + + fn provides_fs_isolation(&self) -> bool { + true + } + + fn is_isolated(&self) -> bool { + true + } + + async fn ensure_ready(&self, _id: &SandboxId, _image_override: Option<&str>) -> Result<()> { + self.ensure_ready_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn exec(&self, _id: &SandboxId, _command: &str, _opts: &ExecOpts) -> Result { + Ok(ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + }) + } + + async fn write_file( + &self, + _id: &SandboxId, + _file_path: &str, + _content: &[u8], + ) -> Result> { + self.write_file_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::message("upload failed")) + } + + async fn cleanup(&self, _id: &SandboxId) -> Result<()> { + Ok(()) + } +} + #[tokio::test] async fn test_exec_tool_retries_container_not_running_with_cleanup() { use crate::sandbox::SandboxScope; @@ -625,6 +702,73 @@ async fn test_exec_tool_with_sandbox_router_does_not_wait_for_background_image_b ); } +#[tokio::test] +async fn test_exec_tool_marks_synced_when_isolated_ensure_ready_fails() { + use crate::sandbox::{SandboxConfig, SandboxRouter}; + + let router = Arc::new(SandboxRouter::with_backend( + SandboxConfig::default(), + Arc::new(FailingIsolatedSandbox), + )); + let session_key = "session:ensure-ready-fails"; + + let result = ExecTool::default() + .with_sandbox_router(Arc::clone(&router)) + .execute(serde_json::json!({ + "command": "printf ok", + "_session_key": session_key + })) + .await; + + assert!(result.is_err()); + assert!(router.is_synced(session_key).await); + assert_eq!( + router.sync_failure(session_key).await.as_deref(), + Some("ensure_ready failed") + ); + assert!(router.mark_preparing_once(session_key).await); + assert!(!router.is_synced(session_key).await); + assert!(router.sync_failure(session_key).await.is_none()); +} + +#[tokio::test] +async fn test_exec_tool_clears_prepared_session_when_sync_in_fails() { + use crate::sandbox::{SandboxConfig, SandboxRouter}; + + let host_workspace = tempfile::tempdir().unwrap(); + std::fs::write(host_workspace.path().join("input.txt"), "needs upload").unwrap(); + + let sandbox = Arc::new(SyncUploadFailingSandbox::default()); + let router = Arc::new(SandboxRouter::with_backend( + SandboxConfig { + shared_home_dir: Some(host_workspace.path().to_path_buf()), + ..Default::default() + }, + Arc::clone(&sandbox) as Arc, + )); + let session_key = "session:sync-in-fails"; + + let result = ExecTool::default() + .with_sandbox_router(Arc::clone(&router)) + .execute(serde_json::json!({ + "command": "printf ok", + "_session_key": session_key + })) + .await; + + assert!(result.is_err()); + assert_eq!(sandbox.ensure_ready_calls.load(Ordering::SeqCst), 1); + assert_eq!(sandbox.write_file_calls.load(Ordering::SeqCst), 1); + assert!(router.is_synced(session_key).await); + assert_eq!( + router.sync_failure(session_key).await.as_deref(), + Some("upload failed") + ); + assert!(router.mark_preparing_once(session_key).await); + assert!(!router.is_synced(session_key).await); + assert!(router.sync_failure(session_key).await.is_none()); +} + /// Regression test: when SandboxMode=All (the default) but the backend is /// NoSandbox (no container runtime), the exec tool must NOT use /// /home/sandbox as the working directory. It should fall back to the host @@ -674,7 +818,9 @@ async fn test_exec_tool_sandbox_rewrites_host_absolute_working_dir() { .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); - assert_eq!(captured, Some(PathBuf::from("/home/sandbox"))); + // Absolute paths outside the sandbox are passed through — the backend + // handles remapping to its own workspace if needed. + assert_eq!(captured, Some(PathBuf::from("/Users/fabien"))); } #[tokio::test] diff --git a/crates/tools/src/image_cache.rs b/crates/tools/src/image_cache.rs index 683b290ceb..39f47e6eb5 100644 --- a/crates/tools/src/image_cache.rs +++ b/crates/tools/src/image_cache.rs @@ -12,7 +12,7 @@ use std::path::Path; use { async_trait::async_trait, serde::{Deserialize, Serialize}, - tracing::{debug, info}, + tracing::{debug, info, warn}, }; use crate::{ @@ -69,6 +69,32 @@ impl DockerImageBuilder { } } + /// Create with an explicit CLI binary name. + pub fn with_cli(cli: &'static str) -> Self { + Self { cli } + } + + /// Create for a specific sandbox backend configuration value. + /// + /// Maps backend names to the correct build CLI: + /// - `apple-container` → `docker` (Apple Container delegates builds to Docker) + /// - `docker` → `docker` + /// - `podman` → `podman` + /// - `auto` / others → auto-detected via `container_cli()` + pub fn for_backend(backend: &str) -> Self { + let cli = match backend { + "apple-container" | "docker" => "docker", + "podman" => "podman", + _ => crate::sandbox::container_cli(), + }; + Self { cli } + } + + /// Return the container CLI name (e.g. "docker" or "podman"). + pub fn cli_name(&self) -> &'static str { + self.cli + } + /// Compute the image tag for a skill's Dockerfile. /// Format: `moltis-cache/:` pub fn image_tag(skill_name: &str, dockerfile_contents: &[u8]) -> String { @@ -97,6 +123,42 @@ impl DockerImageBuilder { .await .is_ok_and(|s| s.success()) } + + /// Run a build with the configured CLI. + async fn try_build( + &self, + tag: &str, + dockerfile: &Path, + context: &Path, + ) -> Result { + Self::run_build(self.cli, tag, dockerfile, context).await + } + + /// Run a ` build` command and return the output. + async fn run_build( + cli: &str, + tag: &str, + dockerfile: &Path, + context: &Path, + ) -> Result { + debug!(cli, tag, context = %context.display(), "spawning image build"); + tokio::process::Command::new(cli) + .args([ + "build", + "-t", + tag, + "-f", + &dockerfile.display().to_string(), + &context.display().to_string(), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await + .with_context(|| { + format!("failed to run `{cli} build` — is {cli} installed and in PATH?") + }) + } } impl Default for DockerImageBuilder { @@ -126,23 +188,57 @@ impl ImageBuilder for DockerImageBuilder { info!(tag, dockerfile = %dockerfile.display(), "building tool image"); - let output = tokio::process::Command::new(self.cli) - .args([ - "build", - "-t", - &tag, - "-f", - &dockerfile.display().to_string(), - &context.display().to_string(), - ]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .await - .with_context(|| format!("failed to run {} build", self.cli))?; - + // Try the configured CLI first. If it fails with a daemon connection + // error, try the alternative CLI (docker ↔ podman). + let output = self.try_build(&tag, dockerfile, context).await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); + let is_daemon_error = stderr.contains("Cannot connect") + || stderr.contains("connect to the Docker daemon") + || stderr.contains("unable to connect") + || stderr.contains("connection refused"); + + if is_daemon_error { + let alt_cli = if self.cli == "podman" { + "docker" + } else { + "podman" + }; + if crate::sandbox::containers::is_cli_available(alt_cli) { + info!( + primary = self.cli, + fallback = alt_cli, + "primary CLI daemon not available, trying fallback" + ); + let alt_output = Self::run_build(alt_cli, &tag, dockerfile, context).await?; + if alt_output.status.success() { + info!( + tag, + cli = alt_cli, + "tool image built successfully (via fallback)" + ); + return Ok(tag); + } + let alt_stderr = String::from_utf8_lossy(&alt_output.stderr); + warn!( + cli = alt_cli, + tag, + exit_code = alt_output.status.code().unwrap_or(-1), + stderr = %alt_stderr.trim(), + "fallback image build also failed" + ); + } + } + + let stdout = String::from_utf8_lossy(&output.stdout); + warn!( + cli = self.cli, + tag, + exit_code = output.status.code().unwrap_or(-1), + stderr = %stderr.trim(), + stdout = %stdout.chars().take(200).collect::(), + "image build failed" + ); return Err(Error::message(format!( "{} build failed for {tag}: {}", self.cli, diff --git a/crates/tools/src/process.rs b/crates/tools/src/process.rs index 37cf8603ee..b2be48cfb8 100644 --- a/crates/tools/src/process.rs +++ b/crates/tools/src/process.rs @@ -132,8 +132,10 @@ impl ProcessTool { let is_sandboxed = router.is_sandboxed(session_key).await; if is_sandboxed { let id = router.sandbox_id_for(session_key); - let image = router.resolve_image_nowait(session_key, None).await; - let backend = router.backend(); + let backend = router.resolve_backend(session_key).await; + let image = router + .resolve_image_for_backend_nowait(session_key, None, backend.backend_name()) + .await; backend.ensure_ready(&id, Some(&image)).await?; return Ok(backend.exec(&id, &command, &opts).await?); } diff --git a/crates/tools/src/sandbox/containers.rs b/crates/tools/src/sandbox/containers.rs index 3b5647620f..63d411c4ff 100644 --- a/crates/tools/src/sandbox/containers.rs +++ b/crates/tools/src/sandbox/containers.rs @@ -983,7 +983,7 @@ pub(crate) fn is_docker_daemon_available() -> bool { } /// Check whether a CLI tool is available on PATH. -pub(crate) fn is_cli_available(name: &str) -> bool { +pub fn is_cli_available(name: &str) -> bool { std::process::Command::new(name) .arg("--version") .stdout(std::process::Stdio::null()) diff --git a/crates/tools/src/sandbox/daytona.rs b/crates/tools/src/sandbox/daytona.rs new file mode 100644 index 0000000000..ea451688a5 --- /dev/null +++ b/crates/tools/src/sandbox/daytona.rs @@ -0,0 +1,621 @@ +//! Daytona Sandbox backend — cloud sandboxes via the Daytona API. +//! +//! Each session gets an ephemeral Daytona sandbox. Commands run via the +//! toolbox REST API, files transfer via multipart upload and raw download. +//! The sandbox is deleted on cleanup. +//! +//! Requires `DAYTONA_API_KEY` and optionally `DAYTONA_API_URL`. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use { + async_trait::async_trait, + secrecy::{ExposeSecret, Secret}, + tokio::sync::{RwLock, Semaphore}, + tracing::{debug, info, warn}, +}; + +use crate::{ + error::{Error, Result}, + exec::{ExecOpts, ExecResult}, + sandbox::{ + file_system::SandboxReadResult, + types::{Sandbox, SandboxConfig, SandboxId}, + }, +}; + +/// Default Daytona API URL. +const DEFAULT_API_URL: &str = "https://app.daytona.io/api"; + +/// Default workspace directory inside Daytona sandboxes. +const DAYTONA_WORKSPACE: &str = "/home/daytona"; + +/// Generic workspace path used by the shared sandbox tool contract. +const GENERIC_WORKSPACE: &str = "/home/sandbox"; +const GENERIC_WORKSPACE_PREFIX: &str = "/home/sandbox/"; + +/// State of a live Daytona sandbox session. +struct DaytonaSession { + sandbox_id: String, + workspace_dir: String, +} + +/// Daytona Sandbox backend configuration. +#[derive(Debug, Clone)] +pub struct DaytonaSandboxConfig { + pub api_key: Secret, + pub api_url: String, + pub target: Option, + pub image: Option, + pub language: Option, +} + +impl Default for DaytonaSandboxConfig { + fn default() -> Self { + Self { + api_key: Secret::new(String::new()), + api_url: DEFAULT_API_URL.into(), + target: None, + image: None, + language: None, + } + } +} + +/// Daytona Sandbox backend. +pub struct DaytonaSandbox { + #[allow(dead_code)] + config: SandboxConfig, + daytona: DaytonaSandboxConfig, + client: reqwest::Client, + active: RwLock>, + creation_permits: RwLock>>, +} + +impl DaytonaSandbox { + pub fn new(config: SandboxConfig, daytona: DaytonaSandboxConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .unwrap_or_default(); + Self { + config, + daytona, + client, + active: RwLock::new(HashMap::new()), + creation_permits: RwLock::new(HashMap::new()), + } + } + + async fn creation_permit(&self, id: &SandboxId) -> Arc { + if let Some(permit) = self.creation_permits.read().await.get(&id.key).cloned() { + return permit; + } + let mut permits = self.creation_permits.write().await; + permits + .entry(id.key.clone()) + .or_insert_with(|| Arc::new(Semaphore::new(1))) + .clone() + } + + async fn existing_creation_permit(&self, id: &SandboxId) -> Option> { + self.creation_permits.read().await.get(&id.key).cloned() + } + + fn translate_working_dir(working_dir: Option<&str>, workspace_dir: &str) -> String { + match working_dir { + Some(path) if path == GENERIC_WORKSPACE => workspace_dir.to_string(), + Some(path) if path.starts_with(GENERIC_WORKSPACE_PREFIX) => { + format!("{workspace_dir}{}", &path[GENERIC_WORKSPACE.len()..]) + }, + Some(path) => path.to_string(), + None => workspace_dir.to_string(), + } + } + + fn env_object(env: &[(String, String)]) -> serde_json::Map { + env.iter() + .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone()))) + .collect() + } + + fn wrapped_command(command: &str, stderr_file: &str) -> String { + use base64::Engine; + + let encoded = base64::engine::general_purpose::STANDARD.encode(command.as_bytes()); + let encoded = shell_words::quote(&encoded); + let stderr_file = shell_words::quote(stderr_file); + + format!( + "decoded=$(mktemp /tmp/moltis-cmd.XXXXXX) || exit 125; \ + printf %s {encoded} | base64 -d > \"$decoded\"; status=$?; \ + if [ \"$status\" -ne 0 ]; then rm -f \"$decoded\"; exit \"$status\"; fi; \ + sh \"$decoded\" 2>{stderr_file}; status=$?; \ + rm -f \"$decoded\"; exit \"$status\"" + ) + } + + /// Build an authenticated request. + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let url = format!("{}{path}", self.daytona.api_url); + self.client + .request(method, &url) + .bearer_auth(self.daytona.api_key.expose_secret()) + .header("X-Daytona-Source", "moltis") + } + + /// Create a Daytona sandbox, returning (sandbox_id, workspace_dir). + async fn create_sandbox(&self) -> Result<(String, String)> { + let mut body = serde_json::json!({}); + + if let Some(ref image) = self.daytona.image { + body["image"] = serde_json::Value::String(image.clone()); + } + if let Some(ref target) = self.daytona.target { + body["target"] = serde_json::Value::String(target.clone()); + } + if let Some(ref lang) = self.daytona.language { + body["labels"] = serde_json::json!({ + "code-toolbox-language": lang, + }); + } + + let resp = self + .request(reqwest::Method::POST, "/sandbox") + .timeout(Duration::from_secs(120)) + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("daytona: failed to create sandbox: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "daytona: create sandbox failed (HTTP {status}): {text}" + ))); + } + + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| Error::message(format!("daytona: invalid create response: {e}")))?; + + let sandbox_id = data["id"] + .as_str() + .map(String::from) + .ok_or_else(|| Error::message("daytona: missing id in create response"))?; + + // Try to get the workspace directory from the response. + let workspace_dir = data["info"]["projectDir"] + .as_str() + .or_else(|| data["info"]["homeDir"].as_str()) + .map(String::from) + .unwrap_or_else(|| DAYTONA_WORKSPACE.to_string()); + + Ok((sandbox_id, workspace_dir)) + } + + /// Run a command via the toolbox API. + async fn run_command( + &self, + sandbox_id: &str, + command: &str, + cwd: &str, + opts: &ExecOpts, + ) -> Result { + // The Daytona toolbox API combines stdout and stderr in the `result` + // field. To separate them, wrap the command to redirect stderr to a + // temp file, then read it back in a second call. + let stderr_file = format!("/tmp/moltis-stderr-{}", uuid::Uuid::new_v4()); + // Base64-encode the command to avoid any shell metacharacter issues + // (braces, parentheses, quotes, etc.) in the wrapper. + let wrapped = Self::wrapped_command(command, &stderr_file); + let timeout_secs = opts.timeout.as_secs().max(1); + let mut body = serde_json::json!({ + "command": wrapped, + "cwd": cwd, + "timeout": timeout_secs, + }); + if !opts.env.is_empty() { + body["env"] = serde_json::Value::Object(Self::env_object(&opts.env)); + } + + let resp = self + .request( + reqwest::Method::POST, + &format!("/toolbox/{sandbox_id}/toolbox/process/execute"), + ) + .timeout(opts.timeout + Duration::from_secs(10)) + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("daytona: command request failed: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "daytona: command failed (HTTP {status}): {text}" + ))); + } + + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| Error::message(format!("daytona: invalid command response: {e}")))?; + + let exit_code = data["exitCode"].as_i64().unwrap_or(-1) as i32; + let mut stdout = data["result"].as_str().unwrap_or("").to_string(); + + // Retrieve stderr from the temp file. + let stderr_body = serde_json::json!({ + "command": format!("cat {stderr_file} 2>/dev/null; rm -f {stderr_file}"), + "cwd": "/", + "timeout": 5, + }); + let mut stderr = String::new(); + if let Ok(resp) = self + .request( + reqwest::Method::POST, + &format!("/toolbox/{sandbox_id}/toolbox/process/execute"), + ) + .timeout(Duration::from_secs(10)) + .json(&stderr_body) + .send() + .await + && let Ok(data) = resp.json::().await + { + stderr = data["result"].as_str().unwrap_or("").to_string(); + } + + stdout.truncate(stdout.floor_char_boundary(opts.max_output_bytes)); + stderr.truncate(stderr.floor_char_boundary(opts.max_output_bytes)); + + Ok(ExecResult { + stdout, + stderr, + exit_code, + }) + } + + /// Upload a file to the sandbox via the toolbox API. + async fn upload_file(&self, sandbox_id: &str, path: &str, content: &[u8]) -> Result<()> { + let file_name: String = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let part = reqwest::multipart::Part::bytes(content.to_vec()) + .file_name(file_name) + .mime_str("application/octet-stream") + .map_err(|e| Error::message(format!("daytona: mime error: {e}")))?; + + let form = reqwest::multipart::Form::new().part("file", part); + + let resp = self + .request( + reqwest::Method::POST, + &format!("/toolbox/{sandbox_id}/toolbox/files/upload"), + ) + .query(&[("path", path)]) + .multipart(form) + .send() + .await + .map_err(|e| Error::message(format!("daytona: file upload failed: {e}")))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "daytona: file upload failed: {text}" + ))); + } + + Ok(()) + } + + /// Download a file from the sandbox via the toolbox API. + async fn download_file(&self, sandbox_id: &str, path: &str) -> Result>> { + let resp = self + .request( + reqwest::Method::GET, + &format!("/toolbox/{sandbox_id}/toolbox/files/download"), + ) + .query(&[("path", path)]) + .send() + .await + .map_err(|e| Error::message(format!("daytona: file download failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "daytona: file download failed: {text}" + ))); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| Error::message(format!("daytona: failed to read file bytes: {e}")))?; + + Ok(Some(bytes.to_vec())) + } + + /// Delete a sandbox. + async fn delete_sandbox(&self, sandbox_id: &str) -> Result<()> { + let resp = self + .request(reqwest::Method::DELETE, &format!("/sandbox/{sandbox_id}")) + .send() + .await + .map_err(|e| Error::message(format!("daytona: delete request failed: {e}")))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + if !text.contains("not found") { + return Err(Error::message(format!( + "daytona: delete sandbox failed: {text}" + ))); + } + } + + Ok(()) + } + + /// Get the session state for a sandbox, or None. + async fn session_state(&self, id: &SandboxId) -> Option<(String, String)> { + self.active + .read() + .await + .get(&id.key) + .map(|s| (s.sandbox_id.clone(), s.workspace_dir.clone())) + } +} + +#[async_trait] +impl Sandbox for DaytonaSandbox { + fn backend_name(&self) -> &'static str { + "daytona" + } + + fn is_real(&self) -> bool { + true + } + + fn provides_fs_isolation(&self) -> bool { + true + } + + fn is_isolated(&self) -> bool { + true + } + + fn workspace_dir(&self) -> &str { + DAYTONA_WORKSPACE + } + + async fn workspace_dir_for(&self, id: &SandboxId) -> String { + self.session_state(id) + .await + .map(|(_, workspace_dir)| workspace_dir) + .unwrap_or_else(|| DAYTONA_WORKSPACE.to_string()) + } + + async fn ensure_ready(&self, id: &SandboxId, _image_override: Option<&str>) -> Result<()> { + if self.session_state(id).await.is_some() { + return Ok(()); + } + let permit = self.creation_permit(id).await; + let _permit = permit + .acquire_owned() + .await + .map_err(|e| Error::message(format!("daytona: sandbox creation permit closed: {e}")))?; + if self.session_state(id).await.is_some() { + return Ok(()); + } + + info!(%id, "daytona: creating sandbox"); + + let (sandbox_id, workspace_dir) = self.create_sandbox().await?; + + info!(%id, daytona_id = sandbox_id, workspace = workspace_dir, "daytona: sandbox ready"); + + self.active + .write() + .await + .insert(id.key.clone(), DaytonaSession { + sandbox_id, + workspace_dir, + }); + + Ok(()) + } + + async fn exec(&self, id: &SandboxId, command: &str, opts: &ExecOpts) -> Result { + let (sandbox_id, workspace_dir) = self + .session_state(id) + .await + .ok_or_else(|| Error::message(format!("daytona: no active sandbox for {id}")))?; + + // Map the generic /home/sandbox to the actual Daytona workspace dir. + let cwd = Self::translate_working_dir( + opts.working_dir.as_ref().and_then(|p| p.to_str()), + &workspace_dir, + ); + + self.run_command(&sandbox_id, command, &cwd, opts).await + } + + async fn read_file( + &self, + id: &SandboxId, + file_path: &str, + max_bytes: u64, + ) -> Result { + let (sandbox_id, _) = self + .session_state(id) + .await + .ok_or_else(|| Error::message(format!("daytona: no active sandbox for {id}")))?; + + match self.download_file(&sandbox_id, file_path).await? { + None => Ok(SandboxReadResult::NotFound), + Some(bytes) => { + if bytes.len() as u64 > max_bytes { + Ok(SandboxReadResult::TooLarge(bytes.len() as u64)) + } else { + Ok(SandboxReadResult::Ok(bytes)) + } + }, + } + } + + async fn write_file( + &self, + id: &SandboxId, + file_path: &str, + content: &[u8], + ) -> Result> { + let (sandbox_id, _) = self + .session_state(id) + .await + .ok_or_else(|| Error::message(format!("daytona: no active sandbox for {id}")))?; + + // Ensure parent directory exists. + if let Some(parent) = std::path::Path::new(file_path).parent() + && let Some(parent_str) = parent.to_str() + { + let mkdir_opts = ExecOpts { + timeout: Duration::from_secs(10), + ..Default::default() + }; + let _ = self + .run_command( + &sandbox_id, + &format!("mkdir -p '{}'", parent_str.replace('\'', "'\\''")), + "/", + &mkdir_opts, + ) + .await; + } + + self.upload_file(&sandbox_id, file_path, content).await?; + + Ok(None) + } + + async fn cleanup(&self, id: &SandboxId) -> Result<()> { + let permit = self.existing_creation_permit(id).await; + let _permit = match permit { + Some(permit) => Some(permit.acquire_owned().await.map_err(|e| { + Error::message(format!("daytona: sandbox creation permit closed: {e}")) + })?), + None => None, + }; + let session = self.active.write().await.remove(&id.key); + self.creation_permits.write().await.remove(&id.key); + if let Some(session) = session { + debug!(%id, daytona_id = session.sandbox_id, "daytona: deleting sandbox"); + if let Err(e) = self.delete_sandbox(&session.sandbox_id).await { + warn!(%id, error = %e, "daytona: sandbox deletion failed during cleanup"); + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_daytona_sandbox_backend_name() { + let sandbox = + DaytonaSandbox::new(SandboxConfig::default(), DaytonaSandboxConfig::default()); + assert_eq!(sandbox.backend_name(), "daytona"); + assert!(sandbox.is_real()); + assert!(sandbox.provides_fs_isolation()); + assert!(sandbox.is_isolated()); + } + + #[test] + fn test_daytona_config_defaults() { + let config = DaytonaSandboxConfig::default(); + assert_eq!(config.api_url, "https://app.daytona.io/api"); + assert!(config.target.is_none()); + assert!(config.image.is_none()); + assert!(config.language.is_none()); + } + + #[test] + fn test_translate_working_dir_preserves_workspace_subdirectory() { + assert_eq!( + DaytonaSandbox::translate_working_dir( + Some("/home/sandbox/myproject/src"), + "/workspace/custom", + ), + "/workspace/custom/myproject/src" + ); + assert_eq!( + DaytonaSandbox::translate_working_dir(Some("/home/sandbox"), "/workspace/custom"), + "/workspace/custom" + ); + assert_eq!( + DaytonaSandbox::translate_working_dir(Some("/tmp/build"), "/workspace/custom"), + "/tmp/build" + ); + assert_eq!( + DaytonaSandbox::translate_working_dir(None, "/workspace/custom"), + "/workspace/custom" + ); + } + + #[test] + fn test_env_object_includes_exec_env() { + let env = DaytonaSandbox::env_object(&[ + ("API_TOKEN".to_string(), "secret-value".to_string()), + ("SESSION_ID".to_string(), "abc123".to_string()), + ]); + + assert_eq!( + env.get("API_TOKEN").and_then(serde_json::Value::as_str), + Some("secret-value") + ); + assert_eq!( + env.get("SESSION_ID").and_then(serde_json::Value::as_str), + Some("abc123") + ); + } + + #[test] + fn test_wrapped_command_guards_base64_decode_failure() { + let wrapped = DaytonaSandbox::wrapped_command("echo '; } weird'", "/tmp/stderr.log"); + + assert!(wrapped.contains("base64 -d > \"$decoded\"; status=$?;")); + assert!(wrapped.contains("if [ \"$status\" -ne 0 ]; then")); + assert!(wrapped.contains("sh \"$decoded\" 2>/tmp/stderr.log")); + assert!(!wrapped.contains("eval")); + } + + #[tokio::test] + async fn test_no_active_sandbox_returns_error() { + let sandbox = + DaytonaSandbox::new(SandboxConfig::default(), DaytonaSandboxConfig::default()); + let id = SandboxId { + scope: crate::sandbox::types::SandboxScope::Session, + key: "test".into(), + }; + let opts = ExecOpts::default(); + let result = sandbox.exec(&id, "echo hello", &opts).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no active sandbox") + ); + } +} diff --git a/crates/tools/src/sandbox/file_system.rs b/crates/tools/src/sandbox/file_system.rs index 9c41bbc08e..32a5571297 100644 --- a/crates/tools/src/sandbox/file_system.rs +++ b/crates/tools/src/sandbox/file_system.rs @@ -188,8 +188,10 @@ pub async fn sandbox_file_system_for_session( session_key: &str, ) -> Result> { let id = router.sandbox_id_for(session_key); - let image = router.resolve_image_nowait(session_key, None).await; - let backend = Arc::clone(router.backend()); + let backend = router.resolve_backend(session_key).await; + let image = router + .resolve_image_for_backend_nowait(session_key, None, backend.backend_name()) + .await; backend.ensure_ready(&id, Some(&image)).await?; Ok(Arc::new(CommandSandboxFileSystem::new(backend, id))) } diff --git a/crates/tools/src/sandbox/firecracker.rs b/crates/tools/src/sandbox/firecracker.rs new file mode 100644 index 0000000000..8bba122250 --- /dev/null +++ b/crates/tools/src/sandbox/firecracker.rs @@ -0,0 +1,1151 @@ +//! Local Firecracker sandbox backend — microVM-based isolation without Docker. +//! +//! Boots ephemeral Firecracker microVMs for sandboxed command execution. +//! Each session gets its own VM with a copy-on-write rootfs, dedicated +//! TAP device, and SSH access for command execution. +//! +//! **Requirements:** +//! - Linux only (Firecracker is Linux-exclusive) +//! - `firecracker` binary installed +//! - Uncompressed Linux kernel (`vmlinux`) +//! - ext4 rootfs image with SSH server and `sandbox` user +//! - Root or `CAP_NET_ADMIN` for TAP device creation + +use std::{ + collections::HashMap, + io::Write as _, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use { + async_trait::async_trait, + serde_json::json, + tokio::{ + io::AsyncReadExt, + sync::{RwLock, Semaphore}, + }, + tracing::{debug, info, warn}, +}; + +use crate::{ + error::{Error, Result}, + exec::{ExecOpts, ExecResult}, + sandbox::{ + file_system::SandboxReadResult, + types::{Sandbox, SandboxConfig, SandboxId}, + }, +}; + +const GUEST_USER: &str = "sandbox"; +const SUBNET_BASE: &str = "172.16"; +const FC_WORKSPACE: &str = "/home/sandbox"; +const EXIT_SYMLINK: i32 = 14; +const EXIT_PARENT_MISSING: i32 = 20; + +struct FirecrackerVm { + process: tokio::process::Child, + api_socket: PathBuf, + tap_device: String, + guest_ip: String, + rootfs_copy: PathBuf, +} + +/// Firecracker backend configuration. +#[derive(Debug, Clone)] +pub struct FirecrackerSandboxConfig { + pub firecracker_bin: PathBuf, + pub kernel_path: PathBuf, + pub rootfs_path: PathBuf, + pub ssh_key_path: PathBuf, + pub vcpus: u32, + pub memory_mb: u32, +} + +impl Default for FirecrackerSandboxConfig { + fn default() -> Self { + Self { + firecracker_bin: PathBuf::from("firecracker"), + kernel_path: PathBuf::from("/opt/moltis/vmlinux"), + rootfs_path: PathBuf::from("/opt/moltis/rootfs.ext4"), + ssh_key_path: PathBuf::from("/opt/moltis/ssh_key"), + vcpus: 2, + memory_mb: 512, + } + } +} + +pub fn resolve_firecracker_bin(configured: Option<&Path>) -> PathBuf { + configured.map_or_else( + || which::which("firecracker").unwrap_or_else(|_| PathBuf::from("firecracker")), + Path::to_path_buf, + ) +} + +pub fn firecracker_bin_available(configured: Option<&Path>) -> bool { + configured.map_or_else( + || which::which("firecracker").is_ok(), + |path| { + if path.components().count() == 1 { + which::which(path).is_ok() + } else { + path.exists() + } + }, + ) +} + +/// Firecracker sandbox backend. +pub struct FirecrackerSandbox { + #[allow(dead_code)] + config: SandboxConfig, + fc: FirecrackerSandboxConfig, + active: RwLock>, + creation_permits: RwLock>>, + subnet_counter: std::sync::atomic::AtomicU16, +} + +impl FirecrackerSandbox { + /// Maximum number of concurrent /30 subnets (256 * 64 = 16384). + const MAX_SUBNETS: u16 = 256 * 64; + + pub fn new(config: SandboxConfig, fc: FirecrackerSandboxConfig) -> Self { + Self { + config, + fc, + active: RwLock::new(HashMap::new()), + creation_permits: RwLock::new(HashMap::new()), + subnet_counter: std::sync::atomic::AtomicU16::new(1), + } + } + + async fn creation_permit(&self, id: &SandboxId) -> Arc { + if let Some(permit) = self.creation_permits.read().await.get(&id.key).cloned() { + return permit; + } + let mut permits = self.creation_permits.write().await; + permits + .entry(id.key.clone()) + .or_insert_with(|| Arc::new(Semaphore::new(1))) + .clone() + } + + async fn existing_creation_permit(&self, id: &SandboxId) -> Option> { + self.creation_permits.read().await.get(&id.key).cloned() + } + + fn allocate_subnet(&self) -> Result<(String, String, u16)> { + let idx = self + .subnet_counter + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if idx >= Self::MAX_SUBNETS { + return Err(Error::message(format!( + "firecracker: subnet pool exhausted ({} VMs created without cleanup)", + Self::MAX_SUBNETS + ))); + } + let third = idx / 64; + let fourth_base = (idx % 64) * 4; + let host_ip = format!("{SUBNET_BASE}.{third}.{}", fourth_base + 1); + let guest_ip = format!("{SUBNET_BASE}.{third}.{}", fourth_base + 2); + Ok((host_ip, guest_ip, idx)) + } + + async fn create_tap(tap_name: &str, host_ip: &str) -> Result<()> { + let status = tokio::process::Command::new("ip") + .args(["tuntap", "add", "dev", tap_name, "mode", "tap"]) + .status() + .await + .map_err(|e| Error::message(format!("firecracker: failed to create TAP: {e}")))?; + if !status.success() { + return Err(Error::message( + "firecracker: ip tuntap add failed (requires root or CAP_NET_ADMIN)", + )); + } + + let cidr = format!("{host_ip}/30"); + let _ = tokio::process::Command::new("ip") + .args(["addr", "add", &cidr, "dev", tap_name]) + .status() + .await; + let _ = tokio::process::Command::new("ip") + .args(["link", "set", tap_name, "up"]) + .status() + .await; + + Ok(()) + } + + async fn remove_tap(tap_name: &str) { + let _ = tokio::process::Command::new("ip") + .args(["link", "del", tap_name]) + .status() + .await; + } + + async fn copy_rootfs(base: &Path, dest: &Path) -> Result<()> { + let status = tokio::process::Command::new("cp") + .args(["--reflink=auto", "--sparse=auto"]) + .arg(base) + .arg(dest) + .status() + .await + .map_err(|e| Error::message(format!("firecracker: rootfs copy failed: {e}")))?; + if !status.success() { + return Err(Error::message("firecracker: rootfs copy failed")); + } + Ok(()) + } + + /// Make an API call to the Firecracker process via curl over Unix socket. + async fn fc_api_call( + api_socket: &Path, + method: &str, + path: &str, + body: &serde_json::Value, + ) -> Result<()> { + let body_str = serde_json::to_string(body) + .map_err(|e| Error::message(format!("firecracker: JSON serialize failed: {e}")))?; + + let output = tokio::process::Command::new("curl") + .args([ + "--unix-socket", + &api_socket.display().to_string(), + "-s", + "-X", + method, + &format!("http://localhost{path}"), + "-H", + "Content-Type: application/json", + "-d", + &body_str, + ]) + .output() + .await + .map_err(|e| Error::message(format!("firecracker: curl failed: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::message(format!( + "firecracker: API call {method} {path} failed: {stderr}" + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if let Ok(resp) = serde_json::from_str::(&stdout) { + if resp.get("fault_message").is_some() { + return Err(Error::message(format!( + "firecracker: API error on {method} {path}: {stdout}" + ))); + } + } + + Ok(()) + } + + async fn boot_vm( + &self, + api_socket: &Path, + rootfs_path: &Path, + tap_name: &str, + guest_ip: &str, + host_ip: &str, + ) -> Result { + let child = tokio::process::Command::new(&self.fc.firecracker_bin) + .arg("--api-sock") + .arg(api_socket) + .arg("--level") + .arg("Warning") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| Error::message(format!("firecracker: failed to spawn: {e}")))?; + + // Wait for API socket. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !api_socket.exists() { + if tokio::time::Instant::now() >= deadline { + let mut child = child; + let _ = child.kill().await; + let _ = child.wait().await; + return Err(Error::message( + "firecracker: API socket did not appear within 5s", + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Configure and start the VM. On any error, kill the process to + // avoid leaving an orphaned Firecracker instance. + let configure_result = async { + // Linux kernel ip= format: ip=:::::: + let boot_args = format!( + "console=ttyS0 reboot=k panic=1 pci=off ip={guest_ip}::{host_ip}:255.255.255.252::eth0:off" + ); + Self::fc_api_call( + api_socket, + "PUT", + "/boot-source", + &serde_json::json!({ + "kernel_image_path": self.fc.kernel_path.display().to_string(), + "boot_args": boot_args, + }), + ) + .await?; + + Self::fc_api_call( + api_socket, + "PUT", + "/drives/rootfs", + &serde_json::json!({ + "drive_id": "rootfs", + "path_on_host": rootfs_path.display().to_string(), + "is_root_device": true, + "is_read_only": false, + }), + ) + .await?; + + Self::fc_api_call( + api_socket, + "PUT", + "/machine-config", + &serde_json::json!({ + "vcpu_count": self.fc.vcpus, + "mem_size_mib": self.fc.memory_mb, + }), + ) + .await?; + + Self::fc_api_call( + api_socket, + "PUT", + "/network-interfaces/eth0", + &serde_json::json!({ + "iface_id": "eth0", + "host_dev_name": tap_name, + }), + ) + .await?; + + Self::fc_api_call( + api_socket, + "PUT", + "/actions", + &serde_json::json!({ "action_type": "InstanceStart" }), + ) + .await + } + .await; + + match configure_result { + Ok(()) => Ok(child), + Err(e) => { + let mut child = child; + let _ = child.kill().await; + let _ = child.wait().await; + Err(e) + }, + } + } + + async fn stop_build_vm(process: &mut tokio::process::Child, api_socket: &Path, tap_name: &str) { + let _ = Self::fc_api_call( + api_socket, + "PUT", + "/actions", + &serde_json::json!({ "action_type": "SendCtrlAltDel" }), + ) + .await; + tokio::time::sleep(Duration::from_secs(3)).await; + let _ = process.kill().await; + let _ = process.wait().await; + Self::remove_tap(tap_name).await; + let _ = std::fs::remove_file(api_socket); + } + + async fn cleanup_failed_build_vm( + process: &mut tokio::process::Child, + api_socket: &Path, + tap_name: &str, + temp_rootfs: &Path, + ) { + Self::stop_build_vm(process, api_socket, tap_name).await; + let _ = std::fs::remove_file(temp_rootfs); + } + + async fn wait_for_ssh(guest_ip: &str, ssh_key: &Path) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let result = tokio::process::Command::new("ssh") + .args([ + "-i", + &ssh_key.display().to_string(), + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=2", + "-o", + "BatchMode=yes", + &format!("{GUEST_USER}@{guest_ip}"), + "echo", + "ready", + ]) + .output() + .await; + + if let Ok(output) = result { + if output.status.success() { + return Ok(()); + } + } + + if tokio::time::Instant::now() >= deadline { + return Err(Error::message( + "firecracker: SSH did not become available within 30s", + )); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + fn validate_env_key(key: &str) -> Result<()> { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return Err(Error::message( + "firecracker: empty environment variable name", + )); + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return Err(Error::message(format!( + "firecracker: invalid environment variable name '{key}'" + ))); + } + if chars.any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) { + return Err(Error::message(format!( + "firecracker: invalid environment variable name '{key}'" + ))); + } + Ok(()) + } + + fn remote_shell_command(cwd: &str, command: &str, env: &[(String, String)]) -> Result { + let inner = format!("cd {} && {command}", shell_words::quote(cwd)); + let mut words = Vec::with_capacity(env.len() + 3); + words.push("env".to_string()); + for (key, value) in env { + Self::validate_env_key(key)?; + words.push(format!("{key}={value}")); + } + words.push("sh".to_string()); + words.push("-lc".to_string()); + words.push(inner); + Ok(shell_words::join(words)) + } + + async fn collect_ssh_pipe( + name: &str, + task: tokio::task::JoinHandle>>, + ) -> Result> { + task.await + .map_err(|e| Error::message(format!("firecracker: SSH {name} reader failed: {e}")))? + .map_err(|e| Error::message(format!("firecracker: SSH {name} read failed: {e}"))) + } + + async fn ssh_run( + guest_ip: &str, + ssh_key: &Path, + command: &str, + opts: &ExecOpts, + ) -> Result { + let cwd = opts + .working_dir + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or(FC_WORKSPACE); + + let remote_command = Self::remote_shell_command(cwd, command, &opts.env)?; + let ssh_key = ssh_key.display().to_string(); + let destination = format!("{GUEST_USER}@{guest_ip}"); + + let mut child = tokio::process::Command::new("ssh") + .args([ + "-i", + &ssh_key, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + &destination, + &remote_command, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| Error::message(format!("firecracker: SSH run failed: {e}")))?; + + let Some(mut stdout_pipe) = child.stdout.take() else { + let _ = child.start_kill(); + let _ = child.wait().await; + return Err(Error::message("firecracker: SSH stdout pipe unavailable")); + }; + let Some(mut stderr_pipe) = child.stderr.take() else { + let _ = child.start_kill(); + let _ = child.wait().await; + return Err(Error::message("firecracker: SSH stderr pipe unavailable")); + }; + + let stdout_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stdout_pipe.read_to_end(&mut bytes).await.map(|_| bytes) + }); + let stderr_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stderr_pipe.read_to_end(&mut bytes).await.map(|_| bytes) + }); + + let status = match tokio::time::timeout(opts.timeout, child.wait()).await { + Ok(Ok(status)) => status, + Ok(Err(e)) => { + let _ = child.start_kill(); + let _ = child.wait().await; + stdout_task.abort(); + stderr_task.abort(); + return Err(Error::message(format!("firecracker: SSH wait failed: {e}"))); + }, + Err(_) => { + let _ = child.start_kill(); + let _ = child.wait().await; + stdout_task.abort(); + stderr_task.abort(); + return Err(Error::message(format!( + "firecracker: SSH command timed out after {}s", + opts.timeout.as_secs() + ))); + }, + }; + + let stdout_bytes = Self::collect_ssh_pipe("stdout", stdout_task).await?; + let stderr_bytes = Self::collect_ssh_pipe("stderr", stderr_task).await?; + let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string(); + let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string(); + stdout.truncate(stdout.floor_char_boundary(opts.max_output_bytes)); + stderr.truncate(stderr.floor_char_boundary(opts.max_output_bytes)); + + Ok(ExecResult { + stdout, + stderr, + exit_code: status.code().unwrap_or(-1), + }) + } + + async fn session_vm(&self, id: &SandboxId) -> Option<(String, PathBuf)> { + self.active + .read() + .await + .get(&id.key) + .map(|vm| (vm.guest_ip.clone(), self.fc.ssh_key_path.clone())) + } + + fn scp_target(guest_ip: &str, file_path: &str) -> String { + format!("{GUEST_USER}@{guest_ip}:{}", shell_words::quote(file_path)) + } + + async fn scp_upload( + guest_ip: &str, + ssh_key: &Path, + local_path: &Path, + remote_path: &str, + ) -> Result<()> { + let mut child = tokio::process::Command::new("scp") + .args([ + "-i", + &ssh_key.display().to_string(), + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + "-o", + "BatchMode=yes", + "-q", + ]) + .arg(local_path) + .arg(Self::scp_target(guest_ip, remote_path)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| Error::message(format!("firecracker: scp upload failed: {e}")))?; + + let Some(mut stdout_pipe) = child.stdout.take() else { + let _ = child.start_kill(); + return Err(Error::message("firecracker: scp stdout pipe unavailable")); + }; + let Some(mut stderr_pipe) = child.stderr.take() else { + let _ = child.start_kill(); + return Err(Error::message("firecracker: scp stderr pipe unavailable")); + }; + + let stdout_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stdout_pipe.read_to_end(&mut bytes).await.map(|_| bytes) + }); + let stderr_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stderr_pipe.read_to_end(&mut bytes).await.map(|_| bytes) + }); + + let status = match tokio::time::timeout(Duration::from_secs(300), child.wait()).await { + Ok(Ok(status)) => status, + Ok(Err(e)) => { + let _ = child.start_kill(); + let _ = child.wait().await; + stdout_task.abort(); + stderr_task.abort(); + return Err(Error::message(format!("firecracker: scp wait failed: {e}"))); + }, + Err(_) => { + let _ = child.start_kill(); + let _ = child.wait().await; + stdout_task.abort(); + stderr_task.abort(); + return Err(Error::message("firecracker: scp upload timed out")); + }, + }; + + let _ = Self::collect_ssh_pipe("scp stdout", stdout_task).await?; + let stderr_bytes = Self::collect_ssh_pipe("scp stderr", stderr_task).await?; + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr_bytes); + return Err(Error::message(format!( + "firecracker: scp upload failed: {}", + stderr.trim() + ))); + } + + Ok(()) + } + + fn write_file_denied(file_path: &str) -> serde_json::Value { + json!({ + "kind": "path_denied", + "file_path": file_path, + "error": "target is a symbolic link; refusing to follow", + "detail": "firecracker Write rejects symlinks", + }) + } +} + +#[async_trait] +impl Sandbox for FirecrackerSandbox { + fn backend_name(&self) -> &'static str { + "firecracker" + } + + fn is_real(&self) -> bool { + true + } + + fn provides_fs_isolation(&self) -> bool { + true + } + + fn is_isolated(&self) -> bool { + true + } + + /// Build a pre-provisioned rootfs with packages baked in. + /// + /// Boots a temporary VM from the base rootfs, installs packages via + /// apt-get, shuts down, and saves the rootfs as the "built image". + /// Future `ensure_ready()` calls copy from this pre-built rootfs + /// instead of the bare one, avoiding per-session package installation. + async fn build_image( + &self, + _base: &str, + packages: &[String], + ) -> Result> { + use sha2::{Digest, Sha256}; + + if packages.is_empty() { + return Ok(None); + } + + // Deterministic tag from package list (same as Docker image builder). + let mut hasher = Sha256::new(); + for pkg in packages { + hasher.update(pkg.as_bytes()); + hasher.update(b"\n"); + } + let hash = format!("{:x}", hasher.finalize()); + let tag = format!("moltis-fc-{}", &hash[..12]); + + let data_dir = moltis_config::data_dir(); + let images_dir = data_dir.join("sandbox").join("firecracker").join("images"); + let image_path = images_dir.join(format!("{tag}.ext4")); + + // Check if image already exists (cache hit). + if image_path.exists() { + info!( + tag, + "firecracker: pre-built rootfs already exists (cache hit)" + ); + return Ok(Some(super::types::BuildImageResult { + tag: image_path.display().to_string(), + built: false, + })); + } + + info!( + tag, + packages = packages.len(), + "firecracker: building pre-provisioned rootfs" + ); + + std::fs::create_dir_all(&images_dir).map_err(|e| { + Error::message(format!("firecracker: failed to create images dir: {e}")) + })?; + + // Boot a temporary VM, install packages, shut down, keep the rootfs. + let build_id = SandboxId { + scope: super::types::SandboxScope::Session, + key: format!("build-{tag}"), + }; + let temp_rootfs = images_dir.join(format!("{tag}.building.ext4")); + Self::copy_rootfs(&self.fc.rootfs_path, &temp_rootfs).await?; + + let (host_ip, guest_ip, subnet_idx) = self.allocate_subnet()?; + let tap_name = format!("moltis-fc{subnet_idx}"); + let api_socket = images_dir.join(format!("{tag}.sock")); + let _ = std::fs::remove_file(&api_socket); + + if let Err(e) = Self::create_tap(&tap_name, &host_ip).await { + let _ = std::fs::remove_file(&temp_rootfs); + return Err(e); + } + + let mut process = match self + .boot_vm(&api_socket, &temp_rootfs, &tap_name, &guest_ip, &host_ip) + .await + { + Ok(p) => p, + Err(e) => { + Self::remove_tap(&tap_name).await; + let _ = std::fs::remove_file(&temp_rootfs); + return Err(e); + }, + }; + + if let Err(e) = Self::wait_for_ssh(&guest_ip, &self.fc.ssh_key_path).await { + Self::cleanup_failed_build_vm(&mut process, &api_socket, &tap_name, &temp_rootfs).await; + return Err(e); + } + + // Install packages. + let pkg_list = packages.join(" "); + let install_cmd = format!( + "apt-get update -qq && apt-get install -y -qq --no-install-recommends {pkg_list}" + ); + let opts = ExecOpts { + timeout: Duration::from_secs(600), + ..Default::default() + }; + let result = match Self::ssh_run(&guest_ip, &self.fc.ssh_key_path, &install_cmd, &opts) + .await + { + Ok(result) => result, + Err(e) => { + Self::cleanup_failed_build_vm(&mut process, &api_socket, &tap_name, &temp_rootfs) + .await; + return Err(e); + }, + }; + if result.exit_code != 0 { + warn!( + tag, + exit_code = result.exit_code, + "firecracker: package install during image build failed (continuing)" + ); + } + + Self::stop_build_vm(&mut process, &api_socket, &tap_name).await; + + // Rename to final path (atomic on same filesystem). + std::fs::rename(&temp_rootfs, &image_path) + .map_err(|e| Error::message(format!("firecracker: failed to finalize image: {e}")))?; + + info!(tag, path = %image_path.display(), "firecracker: pre-built rootfs ready"); + + Ok(Some(super::types::BuildImageResult { + tag: image_path.display().to_string(), + built: true, + })) + } + + async fn ensure_ready(&self, id: &SandboxId, image_override: Option<&str>) -> Result<()> { + if self.session_vm(id).await.is_some() { + return Ok(()); + } + let permit = self.creation_permit(id).await; + let _permit = permit.acquire_owned().await.map_err(|e| { + Error::message(format!("firecracker: sandbox creation permit closed: {e}")) + })?; + if self.session_vm(id).await.is_some() { + return Ok(()); + } + + if !firecracker_bin_available(Some(&self.fc.firecracker_bin)) { + return Err(Error::message(format!( + "firecracker: binary not found at {}", + self.fc.firecracker_bin.display() + ))); + } + // curl is required for Firecracker API calls over Unix socket. + if !super::containers::is_cli_available("curl") { + return Err(Error::message( + "firecracker: curl is required for API calls over Unix socket (install curl)", + )); + } + if !self.fc.kernel_path.exists() { + return Err(Error::message(format!( + "firecracker: kernel not found at {}", + self.fc.kernel_path.display() + ))); + } + if !self.fc.rootfs_path.exists() { + return Err(Error::message(format!( + "firecracker: rootfs not found at {}", + self.fc.rootfs_path.display() + ))); + } + + let (host_ip, guest_ip, subnet_idx) = self.allocate_subnet()?; + let tap_name = format!("moltis-fc{subnet_idx}"); + + let data_dir = moltis_config::data_dir(); + let vm_dir = data_dir.join("sandbox").join("firecracker").join(&id.key); + std::fs::create_dir_all(&vm_dir) + .map_err(|e| Error::message(format!("firecracker: failed to create VM dir: {e}")))?; + let rootfs_copy = vm_dir.join("rootfs.ext4"); + let api_socket = vm_dir.join("api.sock"); + let _ = std::fs::remove_file(&api_socket); + + // Use pre-built rootfs if available (from build_image()), otherwise base. + let source_rootfs = image_override + .map(std::path::Path::new) + .filter(|p| p.exists()) + .unwrap_or(&self.fc.rootfs_path); + + info!(%id, tap = tap_name, guest_ip, source = %source_rootfs.display(), "firecracker: booting VM"); + + Self::copy_rootfs(source_rootfs, &rootfs_copy).await?; + if let Err(e) = Self::create_tap(&tap_name, &host_ip).await { + let _ = std::fs::remove_dir_all(&vm_dir); + return Err(e); + } + + let process = match self + .boot_vm(&api_socket, &rootfs_copy, &tap_name, &guest_ip, &host_ip) + .await + { + Ok(p) => p, + Err(e) => { + Self::remove_tap(&tap_name).await; + let _ = std::fs::remove_dir_all(&vm_dir); + return Err(e); + }, + }; + + if let Err(e) = Self::wait_for_ssh(&guest_ip, &self.fc.ssh_key_path).await { + Self::remove_tap(&tap_name).await; + let mut process = process; + let _ = process.kill().await; + let _ = process.wait().await; + let _ = std::fs::remove_dir_all(&vm_dir); + return Err(e); + } + + info!(%id, guest_ip, "firecracker: VM ready"); + + self.active + .write() + .await + .insert(id.key.clone(), FirecrackerVm { + process, + api_socket, + tap_device: tap_name, + guest_ip, + rootfs_copy, + }); + + Ok(()) + } + + async fn exec(&self, id: &SandboxId, command: &str, opts: &ExecOpts) -> Result { + let (guest_ip, ssh_key) = self + .session_vm(id) + .await + .ok_or_else(|| Error::message(format!("firecracker: no active VM for {id}")))?; + + Self::ssh_run(&guest_ip, &ssh_key, command, opts).await + } + + async fn write_file( + &self, + id: &SandboxId, + file_path: &str, + content: &[u8], + ) -> Result> { + let (guest_ip, ssh_key) = self + .session_vm(id) + .await + .ok_or_else(|| Error::message(format!("firecracker: no active VM for {id}")))?; + + let quoted_path = shell_words::quote(file_path); + let remote_tmp = format!("{file_path}.moltis-{}", uuid::Uuid::new_v4()); + let quoted_tmp = shell_words::quote(&remote_tmp); + let preflight = format!( + "path={quoted_path}; parent=$(dirname \"$path\"); \ + if [ ! -d \"$parent\" ]; then exit {EXIT_PARENT_MISSING}; fi; \ + if [ -L \"$path\" ]; then exit {EXIT_SYMLINK}; fi" + ); + let opts = ExecOpts { + timeout: Duration::from_secs(30), + ..Default::default() + }; + let result = Self::ssh_run(&guest_ip, &ssh_key, &preflight, &opts).await?; + match result.exit_code { + 0 => {}, + EXIT_PARENT_MISSING => { + return Err(Error::message(format!( + "cannot resolve parent of '{file_path}': directory does not exist in sandbox" + ))); + }, + EXIT_SYMLINK => return Ok(Some(Self::write_file_denied(file_path))), + other => { + let detail = if result.stderr.trim().is_empty() { + format!("firecracker write preflight exited with code {other}") + } else { + result.stderr.trim().to_string() + }; + return Err(Error::message(format!( + "firecracker write of '{file_path}' failed: {detail}" + ))); + }, + } + + let mut local_temp = tempfile::NamedTempFile::new() + .map_err(|e| Error::message(format!("firecracker: temp file create failed: {e}")))?; + local_temp + .write_all(content) + .map_err(|e| Error::message(format!("firecracker: temp file write failed: {e}")))?; + local_temp + .flush() + .map_err(|e| Error::message(format!("firecracker: temp file flush failed: {e}")))?; + + if let Err(e) = Self::scp_upload(&guest_ip, &ssh_key, local_temp.path(), &remote_tmp).await + { + let cleanup = format!("rm -f -- {quoted_tmp}"); + let _ = Self::ssh_run(&guest_ip, &ssh_key, &cleanup, &opts).await; + return Err(e); + } + + let finalize = format!( + "path={quoted_path}; tmp={quoted_tmp}; \ + if [ -L \"$path\" ]; then rm -f \"$tmp\"; exit {EXIT_SYMLINK}; fi; \ + sync \"$tmp\" 2>/dev/null || sync; \ + if ! mv \"$tmp\" \"$path\"; then status=$?; rm -f \"$tmp\"; exit \"$status\"; fi" + ); + let result = match Self::ssh_run(&guest_ip, &ssh_key, &finalize, &opts).await { + Ok(result) => result, + Err(e) => { + let cleanup = format!("rm -f -- {quoted_tmp}"); + let _ = Self::ssh_run(&guest_ip, &ssh_key, &cleanup, &opts).await; + return Err(e); + }, + }; + match result.exit_code { + 0 => Ok(None), + EXIT_SYMLINK => Ok(Some(Self::write_file_denied(file_path))), + other => { + let detail = if result.stderr.trim().is_empty() { + format!("firecracker write finalize exited with code {other}") + } else { + result.stderr.trim().to_string() + }; + Err(Error::message(format!( + "firecracker write of '{file_path}' failed: {detail}" + ))) + }, + } + } + + async fn cleanup(&self, id: &SandboxId) -> Result<()> { + let permit = self.existing_creation_permit(id).await; + let _permit = match permit { + Some(permit) => Some(permit.acquire_owned().await.map_err(|e| { + Error::message(format!("firecracker: sandbox creation permit closed: {e}")) + })?), + None => None, + }; + + // Take ownership and drop the lock immediately so concurrent + // exec()/ensure_ready() calls for other sessions are not blocked + // during the async teardown below. + let vm = self.active.write().await.remove(&id.key); + self.creation_permits.write().await.remove(&id.key); + if let Some(mut vm) = vm { + debug!(%id, guest_ip = vm.guest_ip, "firecracker: stopping VM"); + + let _ = Self::fc_api_call( + &vm.api_socket, + "PUT", + "/actions", + &serde_json::json!({ "action_type": "SendCtrlAltDel" }), + ) + .await; + + tokio::time::sleep(Duration::from_secs(2)).await; + + let _ = vm.process.kill().await; + let _ = vm.process.wait().await; + + Self::remove_tap(&vm.tap_device).await; + + if let Some(parent) = vm.rootfs_copy.parent() { + let _ = std::fs::remove_dir_all(parent); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_firecracker_sandbox_backend_name() { + let sandbox = FirecrackerSandbox::new( + SandboxConfig::default(), + FirecrackerSandboxConfig::default(), + ); + assert_eq!(sandbox.backend_name(), "firecracker"); + assert!(sandbox.is_real()); + assert!(sandbox.provides_fs_isolation()); + assert!(sandbox.is_isolated()); + } + + #[test] + fn test_firecracker_config_defaults() { + let config = FirecrackerSandboxConfig::default(); + assert_eq!(config.vcpus, 2); + assert_eq!(config.memory_mb, 512); + assert_eq!(config.firecracker_bin, PathBuf::from("firecracker")); + } + + #[test] + fn test_firecracker_bin_available_checks_configured_path() { + let tempdir = tempfile::tempdir().unwrap(); + let bin = tempdir.path().join("firecracker"); + std::fs::write(&bin, b"#!/bin/sh\n").unwrap(); + + assert!(firecracker_bin_available(Some(&bin))); + assert!(!firecracker_bin_available(Some( + &tempdir.path().join("missing-firecracker") + ))); + } + + #[test] + fn test_resolve_firecracker_bin_prefers_configured_path() { + let configured = PathBuf::from("/custom/firecracker"); + assert_eq!(resolve_firecracker_bin(Some(&configured)), configured); + } + + #[test] + fn test_scp_target_quotes_remote_path() { + assert_eq!( + FirecrackerSandbox::scp_target("172.16.1.2", "/home/sandbox/a b.txt"), + "sandbox@172.16.1.2:'/home/sandbox/a b.txt'" + ); + } + + #[test] + fn test_subnet_allocation() { + let sandbox = FirecrackerSandbox::new( + SandboxConfig::default(), + FirecrackerSandboxConfig::default(), + ); + let (host1, guest1, idx1) = sandbox.allocate_subnet().unwrap(); + let (host2, guest2, idx2) = sandbox.allocate_subnet().unwrap(); + + assert_eq!(idx1, 1); + assert_eq!(idx2, 2); + assert_ne!(host1, host2); + assert_ne!(guest1, guest2); + assert!(host1.starts_with("172.16.")); + assert!(guest1.starts_with("172.16.")); + } + + #[test] + fn test_remote_shell_command_quotes_exec_env() { + let command = FirecrackerSandbox::remote_shell_command( + "/home/sandbox/project dir", + "printf '%s' \"$API_TOKEN\"", + &[ + ("API_TOKEN".to_string(), "secret'value".to_string()), + ("SESSION_ID".to_string(), "abc 123".to_string()), + ], + ) + .unwrap(); + + assert_eq!( + command, + "env 'API_TOKEN=secret'\\''value' 'SESSION_ID=abc 123' sh -lc 'cd '\\''/home/sandbox/project dir'\\'' && printf '\\''%s'\\'' \"$API_TOKEN\"'" + ); + } + + #[test] + fn test_remote_shell_command_without_env() { + let command = + FirecrackerSandbox::remote_shell_command("/home/sandbox", "pwd", &[]).unwrap(); + + assert_eq!(command, "env sh -lc 'cd /home/sandbox && pwd'"); + } + + #[test] + fn test_remote_shell_command_rejects_invalid_keys() { + let result = FirecrackerSandbox::remote_shell_command("/home/sandbox", "env", &[( + "BAD-KEY".to_string(), + "value".to_string(), + )]); + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_no_active_vm_returns_error() { + let sandbox = FirecrackerSandbox::new( + SandboxConfig::default(), + FirecrackerSandboxConfig::default(), + ); + let id = SandboxId { + scope: crate::sandbox::types::SandboxScope::Session, + key: "test".into(), + }; + let opts = ExecOpts::default(); + let result = sandbox.exec(&id, "echo hello", &opts).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no active VM")); + } +} diff --git a/crates/tools/src/sandbox/mod.rs b/crates/tools/src/sandbox/mod.rs index 90f47bf5b1..db66c37e6b 100644 --- a/crates/tools/src/sandbox/mod.rs +++ b/crates/tools/src/sandbox/mod.rs @@ -5,13 +5,19 @@ #[cfg(target_os = "macos")] pub(crate) mod apple; pub(crate) mod containers; +pub(crate) mod daytona; pub(crate) mod docker; pub(crate) mod file_system; +#[cfg(target_os = "linux")] +pub(crate) mod firecracker; pub(crate) mod host; pub(crate) mod paths; pub(crate) mod platform; -pub(crate) mod router; +pub mod router; +pub(crate) mod sync; pub(crate) mod types; +#[cfg(feature = "vercel-sandbox")] +pub(crate) mod vercel; pub(crate) mod wasm; #[cfg(test)] @@ -22,16 +28,24 @@ mod tests; #[cfg(target_os = "macos")] pub use apple::{AppleContainerSandbox, ensure_apple_container_service}; #[cfg(target_os = "linux")] +pub use firecracker::{ + FirecrackerSandbox, FirecrackerSandboxConfig, firecracker_bin_available, + resolve_firecracker_bin, +}; +#[cfg(target_os = "linux")] pub use platform::CgroupSandbox; +#[cfg(feature = "vercel-sandbox")] +pub use vercel::{VercelSandbox, VercelSandboxConfig}; #[cfg(feature = "wasm")] pub use wasm::WasmSandbox; pub use { containers::{ ContainerBackend, ContainerDiskUsage, ContainerRunState, RunningContainer, SandboxImage, clean_all_containers, clean_sandbox_images, container_cli, container_disk_usage, - list_running_containers, list_sandbox_images, remove_container, remove_sandbox_image, - restart_container_daemon, sandbox_image_tag, stop_container, + is_cli_available, list_running_containers, list_sandbox_images, remove_container, + remove_sandbox_image, restart_container_daemon, sandbox_image_tag, stop_container, }, + daytona::{DaytonaSandbox, DaytonaSandboxConfig}, docker::{DockerSandbox, NoSandbox}, host::{HostProvisionResult, is_debian_host, provision_host_packages}, paths::shared_home_dir_path, @@ -39,6 +53,7 @@ pub use { router::{FailoverSandbox, SandboxEvent, SandboxRouter, auto_detect_backend, create_sandbox}, types::{ BuildImageResult, DEFAULT_SANDBOX_IMAGE, HomePersistence, NetworkPolicy, ResourceLimits, - Sandbox, SandboxConfig, SandboxId, SandboxMode, SandboxScope, WorkspaceMount, + Sandbox, SandboxBackendId, SandboxConfig, SandboxId, SandboxMode, SandboxScope, + WorkspaceMount, }, }; diff --git a/crates/tools/src/sandbox/router.rs b/crates/tools/src/sandbox/router.rs index 0a42395a3e..e90a03618f 100644 --- a/crates/tools/src/sandbox/router.rs +++ b/crates/tools/src/sandbox/router.rs @@ -7,6 +7,7 @@ use std::{ use { async_trait::async_trait, + secrecy::ExposeSecret, tokio::sync::RwLock, tracing::{debug, info, warn}, }; @@ -121,6 +122,40 @@ impl Sandbox for FailoverSandbox { } } + fn workspace_dir(&self) -> &str { + if self + .use_fallback + .try_read() + .map(|guard| *guard) + .unwrap_or(true) + { + self.fallback.workspace_dir() + } else { + self.primary.workspace_dir() + } + } + + async fn workspace_dir_for(&self, id: &SandboxId) -> String { + if self.fallback_enabled().await { + self.fallback.workspace_dir_for(id).await + } else { + self.primary.workspace_dir_for(id).await + } + } + + fn is_isolated(&self) -> bool { + if self + .use_fallback + .try_read() + .map(|guard| *guard) + .unwrap_or(true) + { + self.fallback.is_isolated() + } else { + self.primary.is_isolated() + } + } + async fn ensure_ready(&self, id: &SandboxId, image_override: Option<&str>) -> Result<()> { if self.fallback_enabled().await { return self.fallback.ensure_ready(id, image_override).await; @@ -280,6 +315,18 @@ pub(crate) fn create_sandbox_backend(config: SandboxConfig) -> Arc select_backend(config) } +/// Create a backend by explicit name, using the given config. +/// +/// Used by the gateway to register additional backends into the multi-backend +/// router at startup. If the backend cannot be created (missing credentials, +/// wrong platform), falls back to `RestrictedHostSandbox`. +pub fn select_backend_by_name(name: &str, config: &SandboxConfig) -> Arc { + select_backend(SandboxConfig { + backend: name.to_string(), + ..config.clone() + }) +} + /// Select the sandbox backend based on config and platform availability. /// /// When `backend` is `"auto"` (the default): @@ -308,6 +355,11 @@ pub(crate) fn select_backend(config: SandboxConfig) -> Arc { Arc::new(RestrictedHostSandbox::new(config)) }, "wasm" | "wasmtime" => create_wasm_backend(config), + #[cfg(feature = "vercel-sandbox")] + "vercel" => create_vercel_backend(config), + "daytona" => create_daytona_backend(config), + #[cfg(target_os = "linux")] + "firecracker" => create_firecracker_backend(config), _ => auto_detect_backend(config), } } @@ -335,6 +387,121 @@ fn create_wasm_backend(config: SandboxConfig) -> Arc { } } +/// Create a Vercel sandbox backend, falling back to `RestrictedHostSandbox` if +/// the feature is disabled or the token is not configured. +#[cfg(feature = "vercel-sandbox")] +fn create_vercel_backend(config: SandboxConfig) -> Arc { + use super::vercel::{VercelSandbox, VercelSandboxConfig}; + + let Some(token) = config + .vercel_token + .clone() + .filter(|token| !token.expose_secret().is_empty()) + else { + tracing::warn!( + "vercel sandbox requested but no token configured (set VERCEL_TOKEN); \ + using restricted-host" + ); + return Arc::new(RestrictedHostSandbox::new(config)); + }; + + let vercel_config = VercelSandboxConfig { + token, + project_id: config.vercel_project_id.clone(), + team_id: config.vercel_team_id.clone(), + runtime: config + .vercel_runtime + .clone() + .unwrap_or_else(|| "node24".into()), + timeout_ms: config.vercel_timeout_ms.unwrap_or(300_000), + vcpus: config.vercel_vcpus.unwrap_or(2), + snapshot_id: config.vercel_snapshot_id.clone(), + }; + + tracing::info!( + runtime = vercel_config.runtime, + vcpus = vercel_config.vcpus, + "sandbox backend: vercel (Firecracker microVM)" + ); + Arc::new(VercelSandbox::new(config, vercel_config)) +} + +/// Create a Daytona sandbox backend, falling back to `RestrictedHostSandbox` +/// if the API key is not configured. +fn create_daytona_backend(config: SandboxConfig) -> Arc { + use super::daytona::{DaytonaSandbox, DaytonaSandboxConfig}; + + let Some(api_key) = config + .daytona_api_key + .clone() + .filter(|api_key| !api_key.expose_secret().is_empty()) + else { + tracing::warn!( + "daytona sandbox requested but no API key configured (set DAYTONA_API_KEY); \ + using restricted-host" + ); + return Arc::new(RestrictedHostSandbox::new(config)); + }; + + let daytona_config = DaytonaSandboxConfig { + api_key, + api_url: config + .daytona_api_url + .clone() + .unwrap_or_else(|| "https://app.daytona.io/api".into()), + target: config.daytona_target.clone(), + image: config.daytona_image.clone(), + language: None, + }; + + tracing::info!( + api_url = daytona_config.api_url, + "sandbox backend: daytona (cloud sandbox)" + ); + Arc::new(DaytonaSandbox::new(config, daytona_config)) +} + +fn has_secret(secret: &Option>) -> bool { + secret + .as_ref() + .is_some_and(|secret| !secret.expose_secret().is_empty()) +} + +/// Create a Firecracker sandbox backend. +/// Linux-only: requires firecracker binary, kernel, rootfs, and root/CAP_NET_ADMIN. +#[cfg(target_os = "linux")] +fn create_firecracker_backend(config: SandboxConfig) -> Arc { + use super::firecracker::{FirecrackerSandbox, FirecrackerSandboxConfig}; + + let fc_config = FirecrackerSandboxConfig { + firecracker_bin: super::firecracker::resolve_firecracker_bin( + config.firecracker_bin.as_deref(), + ), + kernel_path: config + .firecracker_kernel + .clone() + .unwrap_or_else(|| std::path::PathBuf::from("/opt/moltis/vmlinux")), + rootfs_path: config + .firecracker_rootfs + .clone() + .unwrap_or_else(|| std::path::PathBuf::from("/opt/moltis/rootfs.ext4")), + ssh_key_path: config + .firecracker_ssh_key + .clone() + .unwrap_or_else(|| std::path::PathBuf::from("/opt/moltis/ssh_key")), + vcpus: config.firecracker_vcpus.unwrap_or(2), + memory_mb: config.firecracker_memory_mb.unwrap_or(512), + }; + + tracing::info!( + firecracker = %fc_config.firecracker_bin.display(), + vcpus = fc_config.vcpus, + memory_mb = fc_config.memory_mb, + "sandbox backend: firecracker (local microVM)" + ); + Arc::new(FirecrackerSandbox::new(config, fc_config)) +} + /// Wrap a primary sandbox backend with a failover chain. /// /// Tries Podman, then Docker as fallback, then restricted-host, returning the @@ -438,7 +605,20 @@ pub fn auto_detect_backend(config: SandboxConfig) -> Arc { ); } - // Use restricted-host sandbox before falling back to NoSandbox. + // No local container runtime available — try remote backends. + // Env vars are resolved into config fields by the config crate. + #[cfg(feature = "vercel-sandbox")] + if has_secret(&config.vercel_token) { + tracing::info!("no local container runtime; using vercel sandbox"); + return create_vercel_backend(config); + } + + if has_secret(&config.daytona_api_key) { + tracing::info!("no local container runtime; using daytona sandbox"); + return create_daytona_backend(config); + } + + // Use restricted-host sandbox as last resort. tracing::info!( "sandbox backend: restricted-host (env clearing, rlimits; no container runtime available)" ); @@ -479,13 +659,25 @@ pub enum SandboxEvent { } /// Routes sandbox decisions per-session, with per-session overrides on top of global config. +/// +/// Supports multiple named backends simultaneously. Each session can be routed +/// to a different backend via per-session overrides, while the default backend +/// serves sessions without an explicit override. pub struct SandboxRouter { config: SandboxConfig, - backend: Arc, + /// Default backend, stored separately for lock-free access. + default_backend: Arc, + /// All available backends, keyed by backend name. + /// The default backend is also present in this map. + backends: HashMap>, /// Per-session overrides: true = sandboxed, false = direct execution. overrides: RwLock>, + /// Per-session backend override: session_key -> backend_name. + backend_overrides: RwLock>, /// Per-session image overrides. image_overrides: RwLock>, + /// Runtime image overrides scoped to a backend. + backend_image_overrides: RwLock>, /// Runtime override for the global default image (set via API, persisted externally). global_image_override: RwLock>, /// Event channel for sandbox lifecycle events (prepare/provision/build feedback). @@ -493,6 +685,12 @@ pub struct SandboxRouter { /// Session keys that have already completed sandbox initialization. /// Used to avoid repeating first-run preparation banners on every command. prepared_sessions: RwLock>, + /// Session keys where workspace sync-in has completed. + /// Subsequent exec calls wait until sync_in finishes before proceeding. + synced_sessions: RwLock>, + /// Per-session first-run failures that should unblock waiters without + /// allowing them to run against an incomplete sandbox workspace. + sync_failures: RwLock>, /// Whether a sandbox image pre-build is currently in progress. /// Used by the gateway to show a banner in the UI. pub building_flag: std::sync::atomic::AtomicBool, @@ -506,16 +704,26 @@ impl SandboxRouter { pub fn new(config: SandboxConfig) -> Self { // Always create a real sandbox backend, even when global mode is Off, // because per-session overrides can enable sandboxing dynamically. - let backend = create_sandbox_backend(config.clone()); + let default_backend = create_sandbox_backend(config.clone()); + let mut backends = HashMap::new(); + backends.insert( + default_backend.backend_name().to_string(), + Arc::clone(&default_backend), + ); let (event_tx, _) = tokio::sync::broadcast::channel(32); Self { config, - backend, + default_backend, + backends, overrides: RwLock::new(HashMap::new()), + backend_overrides: RwLock::new(HashMap::new()), image_overrides: RwLock::new(HashMap::new()), + backend_image_overrides: RwLock::new(HashMap::new()), global_image_override: RwLock::new(None), event_tx, prepared_sessions: RwLock::new(HashSet::new()), + synced_sessions: RwLock::new(HashSet::new()), + sync_failures: RwLock::new(HashMap::new()), building_flag: std::sync::atomic::AtomicBool::new(false), build_complete: tokio::sync::Notify::new(), } @@ -523,15 +731,22 @@ impl SandboxRouter { /// Create a router with a custom sandbox backend (useful for testing). pub fn with_backend(config: SandboxConfig, backend: Arc) -> Self { + let mut backends = HashMap::new(); + backends.insert(backend.backend_name().to_string(), Arc::clone(&backend)); let (event_tx, _) = tokio::sync::broadcast::channel(32); Self { config, - backend, + default_backend: backend, + backends, overrides: RwLock::new(HashMap::new()), + backend_overrides: RwLock::new(HashMap::new()), image_overrides: RwLock::new(HashMap::new()), + backend_image_overrides: RwLock::new(HashMap::new()), global_image_override: RwLock::new(None), event_tx, prepared_sessions: RwLock::new(HashSet::new()), + synced_sessions: RwLock::new(HashSet::new()), + sync_failures: RwLock::new(HashMap::new()), building_flag: std::sync::atomic::AtomicBool::new(false), build_complete: tokio::sync::Notify::new(), } @@ -550,10 +765,15 @@ impl SandboxRouter { /// Mark a session as preparing for sandbox first-run work. /// Returns `true` only the first time for a session key. pub async fn mark_preparing_once(&self, session_key: &str) -> bool { - self.prepared_sessions + let inserted = self + .prepared_sessions .write() .await - .insert(session_key.to_string()) + .insert(session_key.to_string()); + if inserted { + self.clear_synced_session(session_key).await; + } + inserted } /// Clear preparation marker for a session (used on cleanup or prepare failure). @@ -561,12 +781,60 @@ impl SandboxRouter { self.prepared_sessions.write().await.remove(session_key); } + /// Mark a session as having completed workspace sync-in. + pub async fn mark_synced(&self, session_key: &str) { + self.sync_failures.write().await.remove(session_key); + self.synced_sessions + .write() + .await + .insert(session_key.to_string()); + } + + /// Mark a session as unblocked after first-run preparation failed. + pub async fn mark_sync_failed(&self, session_key: &str, error: String) { + self.sync_failures + .write() + .await + .insert(session_key.to_string(), error); + self.synced_sessions + .write() + .await + .insert(session_key.to_string()); + } + + /// Check whether workspace sync has completed for a session. + pub async fn is_synced(&self, session_key: &str) -> bool { + self.synced_sessions.read().await.contains(session_key) + } + + /// Return the first-run preparation failure for a session, if any. + pub async fn sync_failure(&self, session_key: &str) -> Option { + self.sync_failures.read().await.get(session_key).cloned() + } + + /// Clear sync marker for a session (used on cleanup). + pub async fn clear_synced_session(&self, session_key: &str) { + self.synced_sessions.write().await.remove(session_key); + self.sync_failures.write().await.remove(session_key); + } + + /// Clear runtime initialization markers for a session. + /// + /// Backend and image changes invalidate the prepared/synced state. The + /// next exec must run `ensure_ready` and, for isolated backends, sync the + /// workspace for the newly selected runtime. + pub async fn clear_runtime_state(&self, session_key: &str) { + self.clear_prepared_session(session_key).await; + self.clear_synced_session(session_key).await; + } + /// Check whether a session should run sandboxed. - /// Returns `false` when no real container runtime is available, regardless of - /// config mode or per-session overrides. Otherwise, per-session override takes - /// priority, then falls back to global mode. + /// Returns `false` when the session's resolved backend is not real, regardless + /// of config mode or per-session overrides. Otherwise, per-session override + /// takes priority, then falls back to global mode. pub async fn is_sandboxed(&self, session_key: &str) -> bool { - if !self.backend.is_real() { + let backend = self.resolve_backend(session_key).await; + if !backend.is_real() { return false; } if let Some(&override_val) = self.overrides.read().await.get(session_key) { @@ -612,18 +880,105 @@ impl SandboxRouter { } /// Clean up sandbox resources for a session. + /// + /// For isolated backends, syncs workspace changes back to the host + /// before destroying the sandbox. pub async fn cleanup_session(&self, session_key: &str) -> Result<()> { let id = self.sandbox_id_for(session_key); - self.backend.cleanup(&id).await?; + let backend = self.resolve_backend(session_key).await; + + // Sync workspace changes back to host for isolated backends. + if backend.is_isolated() + && let Some(host_workspace) = super::sync::resolve_sync_workspace(&self.config, &id) + { + let sandbox_workspace = backend.workspace_dir_for(&id).await; + if let Err(e) = + super::sync::sync_out(&*backend, &id, &host_workspace, &sandbox_workspace).await + { + warn!( + session = session_key, + %id, + error = %e, + "workspace sync-out failed, changes in sandbox may be lost" + ); + } + } + + backend.cleanup(&id).await?; self.remove_override(session_key).await; + self.remove_backend_override(session_key).await; self.remove_image_override(session_key).await; self.clear_prepared_session(session_key).await; + self.clear_synced_session(session_key).await; Ok(()) } - /// Access the sandbox backend. + /// Access the default sandbox backend. pub fn backend(&self) -> &Arc { - &self.backend + &self.default_backend + } + + /// Resolve the sandbox backend for a session. + /// + /// Priority (highest to lowest): + /// 1. Per-session backend override (`backend_overrides[session_key]`) + /// 2. Default backend + pub async fn resolve_backend(&self, session_key: &str) -> Arc { + if let Some(name) = self.backend_overrides.read().await.get(session_key) { + if let Some(backend) = self.backends.get(name.as_str()) { + return Arc::clone(backend); + } + warn!( + session = session_key, + backend = name.as_str(), + "per-session backend override references unknown backend, using default" + ); + } + Arc::clone(&self.default_backend) + } + + /// Register an additional sandbox backend. + /// + /// The backend is keyed by its `backend_name()`. If a backend with the + /// same name already exists it is replaced. + pub fn register_backend(&mut self, backend: Arc) { + let name = backend.backend_name().to_string(); + debug!(backend = name.as_str(), "registered sandbox backend"); + self.backends.insert(name, backend); + } + + /// List the names of all available backends. + pub fn available_backends(&self) -> Vec<&str> { + self.backends.keys().map(String::as_str).collect() + } + + /// List all available backend instances. + pub fn available_backend_instances(&self) -> Vec> { + self.backends.values().cloned().collect() + } + + /// Set a per-session backend override. + /// + /// Returns `Err` if `backend_name` is not registered. + pub async fn set_backend_override(&self, session_key: &str, backend_name: &str) -> Result<()> { + if !self.backends.contains_key(backend_name) { + return Err(Error::message(format!( + "unknown sandbox backend: {backend_name:?} (available: {:?})", + self.available_backends() + ))); + } + self.backend_overrides + .write() + .await + .insert(session_key.to_string(), backend_name.to_string()); + self.clear_runtime_state(session_key).await; + Ok(()) + } + + /// Remove a per-session backend override (revert to default). + pub async fn remove_backend_override(&self, session_key: &str) { + self.backend_overrides.write().await.remove(session_key); + self.clear_runtime_state(session_key).await; } /// Access the global sandbox mode. @@ -636,9 +991,9 @@ impl SandboxRouter { &self.config } - /// Human-readable name of the sandbox backend (e.g. "docker", "apple-container"). + /// Human-readable name of the default sandbox backend (e.g. "docker", "apple-container"). pub fn backend_name(&self) -> &'static str { - self.backend.backend_name() + self.default_backend.backend_name() } /// Set a per-session image override. @@ -647,11 +1002,13 @@ impl SandboxRouter { .write() .await .insert(session_key.to_string(), image); + self.clear_runtime_state(session_key).await; } /// Remove a per-session image override. pub async fn remove_image_override(&self, session_key: &str) { self.image_overrides.write().await.remove(session_key); + self.clear_runtime_state(session_key).await; } /// Set a runtime override for the global default image. @@ -660,6 +1017,26 @@ impl SandboxRouter { *self.global_image_override.write().await = image; } + /// Set a runtime image override for a specific backend. + /// + /// Background pre-builds are backend-specific: a Docker image tag is not a + /// Firecracker rootfs path, and vice versa. Keep those outputs scoped so a + /// session routed to a secondary backend gets the image/rootfs built for + /// that backend. + pub async fn set_backend_image(&self, backend_name: &str, image: String) -> Result<()> { + if !self.backends.contains_key(backend_name) { + return Err(Error::message(format!( + "unknown sandbox backend: {backend_name:?} (available: {:?})", + self.available_backends() + ))); + } + self.backend_image_overrides + .write() + .await + .insert(backend_name.to_string(), image); + Ok(()) + } + /// If a background image build is in progress, wait for it to finish /// (with a generous timeout) so that callers get the pre-built image /// instead of the bare base image. @@ -678,19 +1055,33 @@ impl SandboxRouter { .await; } - /// Get the current effective default image WITHOUT waiting for a build - /// to finish. Used by request paths that must not block on the initial - /// sandbox image build. - pub async fn resolve_default_image_nowait(&self) -> String { - if let Some(ref img) = *self.global_image_override.read().await { - return img.clone(); - } + async fn config_default_image(&self) -> String { self.config .image .clone() .unwrap_or_else(|| DEFAULT_SANDBOX_IMAGE.to_string()) } + /// Get the current effective default image for a backend WITHOUT waiting + /// for a build to finish. + pub async fn resolve_default_image_for_backend_nowait(&self, backend_name: &str) -> String { + if let Some(img) = self.backend_image_overrides.read().await.get(backend_name) { + return img.clone(); + } + if let Some(ref img) = *self.global_image_override.read().await { + return img.clone(); + } + self.config_default_image().await + } + + /// Get the current effective default image WITHOUT waiting for a build + /// to finish. Used by request paths that must not block on the initial + /// sandbox image build. + pub async fn resolve_default_image_nowait(&self) -> String { + self.resolve_default_image_for_backend_nowait(self.backend_name()) + .await + } + /// Resolve the container image without waiting for any background image /// build. This must stay cheap: callers use it from RPC and tool paths /// where blocking on sandbox provisioning would stall user-visible work. @@ -698,6 +1089,19 @@ impl SandboxRouter { &self, session_key: &str, skill_image: Option<&str>, + ) -> String { + let backend = self.resolve_backend(session_key).await; + self.resolve_image_for_backend_nowait(session_key, skill_image, backend.backend_name()) + .await + } + + /// Resolve the container image for a session/backend without waiting for + /// a background image build. + pub async fn resolve_image_for_backend_nowait( + &self, + session_key: &str, + skill_image: Option<&str>, + backend_name: &str, ) -> String { if let Some(img) = skill_image { return img.to_string(); @@ -714,19 +1118,20 @@ impl SandboxRouter { "sandbox image build in progress, resolving image without waiting" ); } - self.resolve_default_image_nowait().await + self.resolve_default_image_for_backend_nowait(backend_name) + .await + } + + /// Get the current effective default image for a backend. + pub async fn default_image_for_backend(&self, backend_name: &str) -> String { + self.wait_for_build_if_needed().await; + self.resolve_default_image_for_backend_nowait(backend_name) + .await } /// Get the current effective default image (runtime override > config > hardcoded). pub async fn default_image(&self) -> String { - self.wait_for_build_if_needed().await; - if let Some(ref img) = *self.global_image_override.read().await { - return img.clone(); - } - self.config - .image - .clone() - .unwrap_or_else(|| DEFAULT_SANDBOX_IMAGE.to_string()) + self.default_image_for_backend(self.backend_name()).await } /// Resolve the container image for a session. @@ -734,16 +1139,29 @@ impl SandboxRouter { /// Priority (highest to lowest): /// 1. `skill_image` — from a skill's Dockerfile cache /// 2. Per-session override (`session.sandbox_image`) - /// 3. Runtime global override (`set_global_image`) - /// 4. Global config (`config.tools.exec.sandbox.image`) - /// 5. Default constant (`DEFAULT_SANDBOX_IMAGE`) + /// 3. Runtime backend override (`set_backend_image`) + /// 4. Runtime global override (`set_global_image`) + /// 5. Global config (`config.tools.exec.sandbox.image`) + /// 6. Default constant (`DEFAULT_SANDBOX_IMAGE`) pub async fn resolve_image(&self, session_key: &str, skill_image: Option<&str>) -> String { + let backend = self.resolve_backend(session_key).await; + self.resolve_image_for_backend(session_key, skill_image, backend.backend_name()) + .await + } + + /// Resolve the container image for a session/backend. + pub async fn resolve_image_for_backend( + &self, + session_key: &str, + skill_image: Option<&str>, + backend_name: &str, + ) -> String { if let Some(img) = skill_image { return img.to_string(); } if let Some(img) = self.image_overrides.read().await.get(session_key) { return img.clone(); } - self.default_image().await + self.default_image_for_backend(backend_name).await } } diff --git a/crates/tools/src/sandbox/sync.rs b/crates/tools/src/sandbox/sync.rs new file mode 100644 index 0000000000..5ae77ce26a --- /dev/null +++ b/crates/tools/src/sandbox/sync.rs @@ -0,0 +1,1087 @@ +//! Workspace synchronization for isolated sandbox backends. +//! +//! Isolated backends (Vercel, Daytona, Firecracker) run in their own +//! filesystem — unlike bind-mount backends (Docker, Podman), the host +//! workspace is not directly accessible. This module handles: +//! +//! - **sync-in**: Upload host workspace contents to the sandbox on first run. +//! - **sync-out**: Download workspace changes from the sandbox on cleanup. +//! +//! Uses tar-based transfer: the host workspace is packed into a gzipped +//! tarball, uploaded to the sandbox, and extracted there. The reverse for +//! sync-out. + +use std::{ + io::{self, Cursor}, + path::{Component, Path, PathBuf}, +}; + +use flate2::{Compression, write::GzEncoder}; + +use tracing::{debug, warn}; + +use crate::{ + error::{Error, Result}, + exec::ExecOpts, + sandbox::{ + file_system::SandboxReadResult, + types::{Sandbox, SandboxId}, + }, +}; + +/// Maximum tarball size for sync read operations (100 MB). +const MAX_SYNC_BYTES: u64 = 100 * 1024 * 1024; + +/// Upload host workspace contents to an isolated sandbox. +/// +/// Creates a gzipped tarball of the host workspace directory and extracts +/// it in the sandbox's workspace directory. Skips if the host directory +/// doesn't exist or is empty. +pub async fn sync_in( + backend: &dyn Sandbox, + id: &SandboxId, + host_workspace: &Path, + sandbox_workspace: &str, +) -> Result<()> { + if !host_workspace.exists() { + debug!(%id, host = %host_workspace.display(), "sync-in: host workspace does not exist, skipping"); + return Ok(()); + } + + if is_dir_empty(host_workspace) { + debug!(%id, host = %host_workspace.display(), "sync-in: host workspace is empty, skipping"); + return Ok(()); + } + + let tar_bytes = create_tar_gz(host_workspace).await?; + if tar_bytes.is_empty() { + debug!(%id, "sync-in: tar produced empty output, skipping"); + return Ok(()); + } + + debug!( + %id, + host = %host_workspace.display(), + sandbox = sandbox_workspace, + tar_size = tar_bytes.len(), + "sync-in: uploading workspace" + ); + + let tar_path = "/tmp/moltis-sync-in.tar.gz"; + let sandbox_workspace = shell_single_quote(sandbox_workspace); + backend.write_file(id, tar_path, &tar_bytes).await?; + + let cmd = format!( + "mkdir -p {sandbox_workspace} && tar -xzf {tar_path} -C {sandbox_workspace} && rm -f {tar_path}" + ); + let opts = ExecOpts { + timeout: std::time::Duration::from_secs(120), + ..Default::default() + }; + let result = backend.exec(id, &cmd, &opts).await?; + if result.exit_code != 0 { + return Err(Error::message(format!( + "sync-in: extraction failed (exit {}): {}", + result.exit_code, + result.stderr.trim() + ))); + } + + debug!(%id, "sync-in: workspace uploaded successfully"); + Ok(()) +} + +/// Download workspace changes from an isolated sandbox back to host. +/// +/// Creates a gzipped tarball of the sandbox workspace and extracts it +/// to the host directory. Skips if the sandbox workspace is empty. +pub async fn sync_out( + backend: &dyn Sandbox, + id: &SandboxId, + host_workspace: &Path, + sandbox_workspace: &str, +) -> Result<()> { + let opts = ExecOpts { + timeout: std::time::Duration::from_secs(120), + ..Default::default() + }; + + // Check if sandbox workspace has content. + let sandbox_workspace_shell = shell_single_quote(sandbox_workspace); + let check_cmd = format!( + "if [ -d {sandbox_workspace_shell} ] && [ \"$(ls -A {sandbox_workspace_shell} 2>/dev/null)\" ]; then echo non-empty; fi" + ); + let check = backend.exec(id, &check_cmd, &opts).await?; + if !check.stdout.contains("non-empty") { + debug!(%id, "sync-out: sandbox workspace empty, skipping"); + return Ok(()); + } + + debug!( + %id, + sandbox = sandbox_workspace, + host = %host_workspace.display(), + "sync-out: downloading workspace changes" + ); + + // Create tarball in sandbox. + let tar_path = "/tmp/moltis-sync-out.tar.gz"; + let tar_cmd = format!("tar -czf {tar_path} -C {sandbox_workspace_shell} ."); + let tar_result = backend.exec(id, &tar_cmd, &opts).await?; + if tar_result.exit_code != 0 { + return Err(Error::message(format!( + "sync-out: tar creation failed (exit {}): {}", + tar_result.exit_code, + tar_result.stderr.trim() + ))); + } + + // Read tarball from sandbox. + let read_result = backend.read_file(id, tar_path, MAX_SYNC_BYTES).await?; + let tar_bytes = match read_result { + SandboxReadResult::Ok(bytes) => bytes, + SandboxReadResult::NotFound => { + debug!(%id, "sync-out: tarball not found after creation, skipping"); + return Ok(()); + }, + SandboxReadResult::PermissionDenied => { + return Err(Error::message( + "sync-out: permission denied reading tarball", + )); + }, + SandboxReadResult::TooLarge(size) => { + warn!(%id, size, "sync-out: workspace tarball exceeds size limit"); + return Err(Error::message(format!( + "sync-out: workspace too large ({size} bytes exceeds {} byte limit)", + MAX_SYNC_BYTES + ))); + }, + SandboxReadResult::NotRegularFile => { + return Err(Error::message( + "sync-out: tarball path is not a regular file", + )); + }, + }; + + if tar_bytes.is_empty() { + debug!(%id, "sync-out: empty tarball read, skipping"); + return Ok(()); + } + + // Extract on host. + std::fs::create_dir_all(host_workspace) + .map_err(|e| Error::message(format!("sync-out: failed to create host dir: {e}")))?; + extract_tar_gz(host_workspace, &tar_bytes).await?; + + debug!(%id, tar_size = tar_bytes.len(), "sync-out: workspace downloaded successfully"); + Ok(()) +} + +/// Resolve the host workspace path for sync operations. +/// +/// For isolated backends, always returns a path — even when home persistence +/// is disabled — because workspace sync is essential for remote backends to +/// function. Falls back to a dedicated sync directory under `data_dir()`. +pub fn resolve_sync_workspace( + config: &super::types::SandboxConfig, + id: &SandboxId, +) -> Option { + use super::{ + paths::{detected_container_cli, sandbox_home_persistence_host_dir}, + types::sanitize_path_component, + }; + + let cli = detected_container_cli(config); + // If home persistence is configured, use that directory. + if let Some(path) = sandbox_home_persistence_host_dir(config, cli, id) { + return Some(path); + } + // Fallback: dedicated sync directory for isolated backends. + Some( + moltis_config::data_dir() + .join("sandbox") + .join("sync") + .join(sanitize_path_component(&id.key)), + ) +} + +/// Check if a directory is empty or contains no entries. +fn is_dir_empty(dir: &Path) -> bool { + dir.read_dir() + .map(|mut entries| entries.next().is_none()) + .unwrap_or(true) +} + +/// Create a gzipped tarball of a directory, returning the raw bytes. +async fn create_tar_gz(dir: &Path) -> Result> { + let dir = dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + let encoder = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(encoder); + archive.follow_symlinks(false); + archive + .append_dir_all(".", &dir) + .map_err(|e| Error::message(format!("sync: failed to build tar archive: {e}")))?; + let encoder = archive + .into_inner() + .map_err(|e| Error::message(format!("sync: failed to finish tar archive: {e}")))?; + encoder + .finish() + .map_err(|e| Error::message(format!("sync: failed to gzip tar archive: {e}"))) + }) + .await + .map_err(|e| Error::message(format!("sync: tar creation task failed: {e}")))? +} + +async fn extract_tar_gz(dir: &Path, tar_bytes: &[u8]) -> Result<()> { + std::fs::create_dir_all(dir) + .map_err(|e| Error::message(format!("sync: failed to create extract dir: {e}")))?; + + let decoder = flate2::read::GzDecoder::new(Cursor::new(tar_bytes)); + let mut archive = tar::Archive::new(decoder); + let entries = archive + .entries() + .map_err(|e| Error::message(format!("sync: failed to read tar entries: {e}")))?; + + for entry in entries { + let mut entry = + entry.map_err(|e| Error::message(format!("sync: failed to read tar entry: {e}")))?; + let path = entry + .path() + .map_err(|e| Error::message(format!("sync: invalid tar path: {e}")))?; + let path = path.into_owned(); + let relative_path = match validate_tar_path(&path) { + Ok(Some(path)) => path, + Ok(None) => continue, + Err(e) => { + warn!( + path = %path.display(), + error = %e, + "sync: skipping tar entry with unsafe path" + ); + continue; + }, + }; + + match entry.header().entry_type() { + tar::EntryType::Directory => { + if let Err(e) = ensure_directory(dir, &relative_path) { + warn!( + path = %relative_path.display(), + error = %e, + "sync: skipping directory entry with unsafe parent path" + ); + continue; + } + }, + tar::EntryType::Regular => { + if let Err(e) = ensure_parent_directory(dir, &relative_path) { + warn!( + path = %relative_path.display(), + error = %e, + "sync: skipping regular file with unsafe parent path" + ); + continue; + } + let target = dir.join(&relative_path); + if let Err(e) = reject_existing_symlink(&target) { + warn!( + path = %relative_path.display(), + target = %target.display(), + error = %e, + "sync: skipping regular file that would overwrite a symlink" + ); + continue; + } + let mut file = std::fs::File::create(&target).map_err(|e| { + Error::message(format!( + "sync: failed to create extracted file '{}': {e}", + target.display() + )) + })?; + io::copy(&mut entry, &mut file).map_err(|e| { + Error::message(format!( + "sync: failed to write extracted file '{}': {e}", + target.display() + )) + })?; + }, + tar::EntryType::Symlink => { + let link_target = entry + .link_name() + .map_err(|e| Error::message(format!("sync: invalid symlink target: {e}")))? + .ok_or_else(|| { + Error::message(format!( + "sync: symlink '{}' is missing a target", + path.display() + )) + })? + .into_owned(); + if !is_safe_symlink_target(&relative_path, &link_target) { + continue; + } + if let Err(e) = ensure_parent_directory(dir, &relative_path) { + warn!( + path = %relative_path.display(), + error = %e, + "sync: skipping symlink with unsafe parent path" + ); + continue; + } + let target = dir.join(&relative_path); + replace_existing_symlink_path(&target)?; + create_symlink(&link_target, &target)?; + }, + tar::EntryType::Link => { + let link_target = entry + .link_name() + .map_err(|e| Error::message(format!("sync: invalid hardlink target: {e}")))? + .ok_or_else(|| { + Error::message(format!( + "sync: hardlink '{}' is missing a target", + path.display() + )) + })? + .into_owned(); + extract_hardlink(dir, &relative_path, &link_target)?; + }, + other => { + warn!( + entry_type = ?other, + path = %path.display(), + "sync: skipping unsupported tar entry type" + ); + }, + } + } + + Ok(()) +} + +fn validate_tar_path(path: &Path) -> Result> { + let mut relative = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => relative.push(part), + Component::CurDir => {}, + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(Error::message(format!( + "sync: refusing unsafe tar path '{}'", + path.display() + ))); + }, + } + } + + if relative.as_os_str().is_empty() { + Ok(None) + } else { + Ok(Some(relative)) + } +} + +fn ensure_directory(root: &Path, relative_path: &Path) -> Result<()> { + let mut current = root.to_path_buf(); + for component in relative_path.components() { + let Component::Normal(part) = component else { + return Err(Error::message(format!( + "sync: refusing unsafe directory path '{}'", + relative_path.display() + ))); + }; + current.push(part); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(Error::message(format!( + "sync: refusing to extract through symlink '{}'", + current.display() + ))); + }, + Ok(metadata) if metadata.is_dir() => {}, + Ok(_) => { + return Err(Error::message(format!( + "sync: refusing to replace non-directory '{}'", + current.display() + ))); + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + std::fs::create_dir(¤t).map_err(|e| { + Error::message(format!( + "sync: failed to create directory '{}': {e}", + current.display() + )) + })?; + }, + Err(e) => { + return Err(Error::message(format!( + "sync: failed to inspect directory '{}': {e}", + current.display() + ))); + }, + } + } + Ok(()) +} + +fn ensure_parent_directory(root: &Path, relative_path: &Path) -> Result<()> { + if let Some(parent) = relative_path.parent() + && !parent.as_os_str().is_empty() + { + ensure_directory(root, parent)?; + } + Ok(()) +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn is_safe_symlink_target(link_path: &Path, link_target: &Path) -> bool { + let mut resolved = link_path + .parent() + .unwrap_or_else(|| Path::new("")) + .to_path_buf(); + for component in link_target.components() { + match component { + Component::Normal(part) => resolved.push(part), + Component::CurDir => {}, + Component::ParentDir => { + if !resolved.pop() { + warn!( + link = %link_path.display(), + target = %link_target.display(), + "sync: skipping symlink with escaping target" + ); + return false; + } + }, + Component::RootDir | Component::Prefix(_) => { + warn!( + link = %link_path.display(), + target = %link_target.display(), + "sync: skipping symlink with unsafe target" + ); + return false; + }, + } + } + true +} + +fn replace_existing_symlink_path(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + std::fs::remove_file(path).map_err(|e| { + Error::message(format!( + "sync: failed to replace existing path '{}': {e}", + path.display() + )) + }) + }, + Ok(metadata) if metadata.is_dir() => Err(Error::message(format!( + "sync: refusing to replace directory '{}' with symlink", + path.display() + ))), + Ok(_) => Err(Error::message(format!( + "sync: refusing to replace special file '{}' with symlink", + path.display() + ))), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(Error::message(format!( + "sync: failed to inspect '{}': {e}", + path.display() + ))), + } +} + +fn extract_hardlink(root: &Path, relative_path: &Path, link_target: &Path) -> Result<()> { + let relative_link_target = match validate_tar_path(link_target) { + Ok(Some(target)) => target, + Ok(None) => { + warn!( + path = %relative_path.display(), + target = %link_target.display(), + "sync: skipping hardlink with empty target" + ); + return Ok(()); + }, + Err(e) => { + warn!( + path = %relative_path.display(), + target = %link_target.display(), + error = %e, + "sync: skipping hardlink with unsafe target" + ); + return Ok(()); + }, + }; + + if relative_link_target.as_os_str().is_empty() { + warn!( + path = %relative_path.display(), + target = %link_target.display(), + "sync: skipping hardlink with empty target" + ); + return Ok(()); + } + + if let Err(e) = ensure_parent_directory(root, relative_path) { + warn!( + path = %relative_path.display(), + target = %relative_link_target.display(), + error = %e, + "sync: skipping hardlink with unsafe parent path" + ); + return Ok(()); + } + let source = root.join(&relative_link_target); + let target = root.join(relative_path); + if let Err(e) = reject_existing_symlink(&source) { + warn!( + path = %relative_path.display(), + target = %relative_link_target.display(), + error = %e, + "sync: skipping hardlink whose source is a symlink" + ); + return Ok(()); + } + if let Err(e) = reject_existing_symlink(&target) { + warn!( + path = %relative_path.display(), + target = %relative_link_target.display(), + error = %e, + "sync: skipping hardlink that would overwrite a symlink" + ); + return Ok(()); + } + + match std::fs::symlink_metadata(&source) { + Ok(metadata) if metadata.is_file() => { + std::fs::copy(&source, &target).map_err(|e| { + Error::message(format!( + "sync: failed to copy hardlink '{}' from '{}': {e}", + target.display(), + source.display() + )) + })?; + Ok(()) + }, + Ok(metadata) if metadata.is_dir() => { + warn!( + path = %relative_path.display(), + target = %relative_link_target.display(), + "sync: skipping hardlink to directory" + ); + Ok(()) + }, + Ok(_) => { + warn!( + path = %relative_path.display(), + target = %relative_link_target.display(), + "sync: skipping hardlink to special file" + ); + Ok(()) + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + warn!( + path = %relative_path.display(), + target = %relative_link_target.display(), + "sync: skipping hardlink whose target has not been extracted" + ); + Ok(()) + }, + Err(e) => Err(Error::message(format!( + "sync: failed to inspect hardlink target '{}': {e}", + source.display() + ))), + } +} + +#[cfg(unix)] +fn create_symlink(link_target: &Path, target: &Path) -> Result<()> { + std::os::unix::fs::symlink(link_target, target).map_err(|e| { + Error::message(format!( + "sync: failed to create symlink '{}' -> '{}': {e}", + target.display(), + link_target.display() + )) + }) +} + +#[cfg(not(unix))] +fn create_symlink(link_target: &Path, target: &Path) -> Result<()> { + warn!( + link = %target.display(), + target = %link_target.display(), + "sync: skipping symlink extraction on unsupported platform" + ); + Ok(()) +} + +fn reject_existing_symlink(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::message(format!( + "sync: refusing to overwrite symlink '{}'", + path.display() + ))), + Ok(_) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(Error::message(format!( + "sync: failed to inspect '{}': {e}", + path.display() + ))), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn tar_gz_with_two_files(first_path: &str, second_path: &str) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + + let first = b"first"; + let mut first_header = tar::Header::new_gnu(); + first_header.set_size(first.len() as u64); + first_header.set_mode(0o644); + first_header.set_cksum(); + archive + .append_data(&mut first_header, first_path, &first[..]) + .unwrap(); + + let second = b"second"; + let mut second_header = tar::Header::new_gnu(); + second_header.set_size(second.len() as u64); + second_header.set_mode(0o644); + second_header.set_cksum(); + archive + .append_data(&mut second_header, second_path, &second[..]) + .unwrap(); + + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + fn tar_gz_with_raw_file_path_and_safe_file(path: &[u8], content: &[u8]) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + + let mut unsafe_header = tar::Header::new_gnu(); + unsafe_header.as_mut_bytes()[..path.len()].copy_from_slice(path); + unsafe_header.set_size(content.len() as u64); + unsafe_header.set_mode(0o644); + unsafe_header.set_cksum(); + archive.append(&unsafe_header, content).unwrap(); + + let mut safe_header = tar::Header::new_gnu(); + safe_header.set_size(4); + safe_header.set_mode(0o644); + safe_header.set_cksum(); + archive + .append_data(&mut safe_header, "safe.txt", &b"safe"[..]) + .unwrap(); + + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + #[cfg(unix)] + fn tar_gz_with_symlink(path: &str, target: &str) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + archive.append_link(&mut header, path, target).unwrap(); + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + fn tar_gz_with_hardlink(path: &str, target: &str) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Link); + header.set_size(0); + header.set_mode(0o644); + header.set_cksum(); + archive.append_link(&mut header, path, target).unwrap(); + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + fn tar_gz_with_directory_and_hardlink(dir_path: &str, hardlink_path: &str) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + + let mut dir_header = tar::Header::new_gnu(); + dir_header.set_entry_type(tar::EntryType::Directory); + dir_header.set_size(0); + dir_header.set_mode(0o755); + dir_header.set_cksum(); + archive + .append_data(&mut dir_header, dir_path, io::empty()) + .unwrap(); + + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Link); + link_header.set_size(0); + link_header.set_mode(0o644); + link_header.set_cksum(); + archive + .append_link(&mut link_header, hardlink_path, dir_path) + .unwrap(); + + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + fn tar_gz_with_directory_and_safe_file(dir_path: &str) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + + let mut dir_header = tar::Header::new_gnu(); + dir_header.set_entry_type(tar::EntryType::Directory); + dir_header.set_size(0); + dir_header.set_mode(0o755); + dir_header.set_cksum(); + archive + .append_data(&mut dir_header, dir_path, io::empty()) + .unwrap(); + + let mut safe_header = tar::Header::new_gnu(); + safe_header.set_size(4); + safe_header.set_mode(0o644); + safe_header.set_cksum(); + archive + .append_data(&mut safe_header, "safe.txt", &b"safe"[..]) + .unwrap(); + + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + fn tar_gz_with_file_and_hardlink(file_path: &str, hardlink_path: &str) -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + + let content = b"shared content"; + let mut file_header = tar::Header::new_gnu(); + file_header.set_size(content.len() as u64); + file_header.set_mode(0o644); + file_header.set_cksum(); + archive + .append_data(&mut file_header, file_path, content.as_slice()) + .unwrap(); + + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Link); + link_header.set_size(0); + link_header.set_mode(0o644); + link_header.set_cksum(); + archive + .append_link(&mut link_header, hardlink_path, file_path) + .unwrap(); + + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + fn tar_gz_with_unsupported_entry_between_files() -> Vec { + let enc = GzEncoder::new(Vec::new(), Compression::fast()); + let mut archive = tar::Builder::new(enc); + + let mut first_header = tar::Header::new_gnu(); + first_header.set_size(5); + first_header.set_mode(0o644); + first_header.set_cksum(); + archive + .append_data(&mut first_header, "before.txt", &b"start"[..]) + .unwrap(); + + let mut fifo_header = tar::Header::new_gnu(); + fifo_header.set_entry_type(tar::EntryType::Fifo); + fifo_header.set_path("pipe").unwrap(); + fifo_header.set_size(0); + fifo_header.set_mode(0o644); + fifo_header.set_cksum(); + archive.append(&fifo_header, io::empty()).unwrap(); + + let mut second_header = tar::Header::new_gnu(); + second_header.set_size(3); + second_header.set_mode(0o644); + second_header.set_cksum(); + archive + .append_data(&mut second_header, "after.txt", &b"end"[..]) + .unwrap(); + + archive.into_inner().and_then(|enc| enc.finish()).unwrap() + } + + #[test] + fn test_is_dir_empty_nonexistent() { + assert!(is_dir_empty(Path::new("/nonexistent/path/xyz"))); + } + + #[test] + fn test_is_dir_empty_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + assert!(is_dir_empty(dir.path())); + } + + #[test] + fn test_is_dir_empty_with_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello").unwrap(); + assert!(!is_dir_empty(dir.path())); + } + + #[test] + fn test_shell_single_quote_escapes_embedded_quotes() { + assert_eq!( + shell_single_quote("/home/daytona/work' && touch /tmp/pwned && echo '"), + "'/home/daytona/work'\\'' && touch /tmp/pwned && echo '\\'''" + ); + } + + #[tokio::test] + async fn test_create_tar_gz_with_content() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("file.txt"), "content").unwrap(); + let bytes = create_tar_gz(dir.path()).await.unwrap(); + assert!(!bytes.is_empty()); + } + + #[tokio::test] + async fn test_create_and_extract_roundtrip() { + let src = tempfile::tempdir().unwrap(); + std::fs::write(src.path().join("hello.txt"), "world").unwrap(); + std::fs::create_dir(src.path().join("subdir")).unwrap(); + std::fs::write(src.path().join("subdir/nested.txt"), "nested content").unwrap(); + + let tar_bytes = create_tar_gz(src.path()).await.unwrap(); + + let dst = tempfile::tempdir().unwrap(); + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert_eq!( + std::fs::read_to_string(dst.path().join("hello.txt")).unwrap(), + "world" + ); + assert_eq!( + std::fs::read_to_string(dst.path().join("subdir/nested.txt")).unwrap(), + "nested content" + ); + } + + #[tokio::test] + async fn test_extract_skips_parent_traversal() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_raw_file_path_and_safe_file(b"../escape.txt", b"nope"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("../escape.txt").exists()); + assert_eq!( + std::fs::read_to_string(dst.path().join("safe.txt")).unwrap(), + "safe" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_existing_symlink_target_and_continues() { + let dst = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let outside_file = outside.path().join("target.txt"); + std::fs::write(&outside_file, "original").unwrap(); + std::os::unix::fs::symlink(&outside_file, dst.path().join("link.txt")).unwrap(); + + let tar_bytes = tar_gz_with_two_files("link.txt", "safe.txt"); + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert_eq!(std::fs::read_to_string(outside_file).unwrap(), "original"); + assert_eq!( + std::fs::read_to_string(dst.path().join("safe.txt")).unwrap(), + "second" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_directory_under_symlink_parent_and_continues() { + let dst = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), dst.path().join("lib64")).unwrap(); + + let tar_bytes = tar_gz_with_directory_and_safe_file("lib64/python3.11"); + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!outside.path().join("python3.11").exists()); + assert_eq!( + std::fs::read_to_string(dst.path().join("safe.txt")).unwrap(), + "safe" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_regular_file_under_symlink_parent_and_continues() { + let dst = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), dst.path().join("lib64")).unwrap(); + + let tar_bytes = tar_gz_with_two_files("lib64/python3.11/foo.py", "safe.txt"); + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!outside.path().join("python3.11/foo.py").exists()); + assert_eq!( + std::fs::read_to_string(dst.path().join("safe.txt")).unwrap(), + "second" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_preserves_relative_symlink() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_symlink("bin/tool", "../lib/tool"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert_eq!( + std::fs::read_link(dst.path().join("bin/tool")).unwrap(), + PathBuf::from("../lib/tool") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_absolute_symlink_target() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_symlink("bin/tool", "/etc/passwd"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("bin/tool").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_escaping_symlink_target() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_symlink("bin/tool", "../../escape"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("bin/tool").exists()); + } + + #[tokio::test] + async fn test_extract_skips_unsupported_tar_entry_type() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_unsupported_entry_between_files(); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert_eq!( + std::fs::read_to_string(dst.path().join("before.txt")).unwrap(), + "start" + ); + assert_eq!( + std::fs::read_to_string(dst.path().join("after.txt")).unwrap(), + "end" + ); + } + + #[tokio::test] + async fn test_extract_copies_hardlink_to_regular_file() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = + tar_gz_with_file_and_hardlink("store/content.txt", "node_modules/pkg/file.txt"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert_eq!( + std::fs::read_to_string(dst.path().join("node_modules/pkg/file.txt")).unwrap(), + "shared content" + ); + } + + #[tokio::test] + async fn test_extract_skips_missing_hardlink_target() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_hardlink("node_modules/pkg/file.txt", "store/missing.txt"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("node_modules/pkg/file.txt").exists()); + } + + #[tokio::test] + async fn test_extract_skips_hardlink_to_directory() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_directory_and_hardlink("store/dir", "node_modules/pkg/dir"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(dst.path().join("store/dir").is_dir()); + assert!(!dst.path().join("node_modules/pkg/dir").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_hardlink_from_existing_symlink_source() { + let dst = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let outside_file = outside.path().join("target.txt"); + std::fs::write(&outside_file, "original").unwrap(); + std::fs::create_dir_all(dst.path().join("store")).unwrap(); + std::os::unix::fs::symlink(&outside_file, dst.path().join("store/link")).unwrap(); + + let tar_bytes = tar_gz_with_hardlink("node_modules/pkg/file.txt", "store/link"); + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("node_modules/pkg/file.txt").exists()); + assert_eq!(std::fs::read_to_string(outside_file).unwrap(), "original"); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_extract_skips_hardlink_to_existing_symlink_target() { + let dst = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let outside_file = outside.path().join("target.txt"); + std::fs::write(&outside_file, "original").unwrap(); + std::fs::create_dir_all(dst.path().join("node_modules/pkg")).unwrap(); + std::os::unix::fs::symlink(&outside_file, dst.path().join("node_modules/pkg/file.txt")) + .unwrap(); + + let tar_bytes = + tar_gz_with_file_and_hardlink("store/content.txt", "node_modules/pkg/file.txt"); + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert_eq!(std::fs::read_to_string(outside_file).unwrap(), "original"); + assert_eq!( + std::fs::read_to_string(dst.path().join("store/content.txt")).unwrap(), + "shared content" + ); + } + + #[tokio::test] + async fn test_extract_skips_escaping_hardlink_target() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_hardlink("node_modules/pkg/file.txt", "../escape.txt"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("node_modules/pkg/file.txt").exists()); + } + + #[tokio::test] + async fn test_extract_skips_absolute_hardlink_target() { + let dst = tempfile::tempdir().unwrap(); + let tar_bytes = tar_gz_with_hardlink("node_modules/pkg/file.txt", "/etc/passwd"); + + extract_tar_gz(dst.path(), &tar_bytes).await.unwrap(); + + assert!(!dst.path().join("node_modules/pkg/file.txt").exists()); + } +} diff --git a/crates/tools/src/sandbox/tests/docker_router.rs b/crates/tools/src/sandbox/tests/docker_router.rs index d8050e3a40..ca85584ae5 100644 --- a/crates/tools/src/sandbox/tests/docker_router.rs +++ b/crates/tools/src/sandbox/tests/docker_router.rs @@ -782,6 +782,57 @@ async fn test_sandbox_router_global_image_override() { assert_eq!(img, DEFAULT_SANDBOX_IMAGE); } +#[tokio::test] +async fn test_sandbox_router_backend_image_override_is_scoped() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let mut router = SandboxRouter::new(config); + router.register_backend(Arc::new(RestrictedHostSandbox::new( + SandboxConfig::default(), + ))); + + router.set_global_image(Some("global:built".into())).await; + router + .set_backend_image("docker", "docker:built".into()) + .await + .unwrap(); + router + .set_backend_image("restricted-host", "restricted:built".into()) + .await + .unwrap(); + + assert_eq!( + router + .resolve_image_for_backend_nowait("session:abc", None, "docker") + .await, + "docker:built" + ); + assert_eq!( + router + .resolve_image_for_backend_nowait("session:abc", None, "restricted-host") + .await, + "restricted:built" + ); + + router + .set_image_override("session:abc", "session:built".into()) + .await; + assert_eq!( + router + .resolve_image_for_backend_nowait("session:abc", None, "restricted-host") + .await, + "session:built" + ); + assert_eq!( + router + .resolve_image_for_backend_nowait("session:abc", Some("skill:built"), "docker") + .await, + "skill:built" + ); +} + // ── Sandbox escape regression tests (issue #923) ─────────────────────────── #[test] @@ -984,3 +1035,164 @@ async fn test_podman_build_image_exists_in_store() { .output() .await; } + +// ── Multi-backend router tests ────────────────────────────────────── + +#[test] +fn test_router_available_backends_contains_default() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let router = SandboxRouter::new(config); + let backends = router.available_backends(); + assert!( + backends.contains(&"docker"), + "default backend must be listed" + ); +} + +#[test] +fn test_router_register_backend_adds_to_available() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let mut router = SandboxRouter::new(config); + assert!(!router.available_backends().contains(&"restricted-host")); + + router.register_backend(Arc::new(RestrictedHostSandbox::new( + SandboxConfig::default(), + ))); + let backends = router.available_backends(); + assert!(backends.contains(&"docker")); + assert!(backends.contains(&"restricted-host")); +} + +#[tokio::test] +async fn test_resolve_backend_returns_default_without_override() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let router = SandboxRouter::new(config); + let backend = router.resolve_backend("session:abc").await; + assert_eq!(backend.backend_name(), "docker"); +} + +#[tokio::test] +async fn test_resolve_backend_returns_overridden_backend() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let mut router = SandboxRouter::new(config); + router.register_backend(Arc::new(RestrictedHostSandbox::new( + SandboxConfig::default(), + ))); + + router + .set_backend_override("session:abc", "restricted-host") + .await + .unwrap(); + + let backend = router.resolve_backend("session:abc").await; + assert_eq!(backend.backend_name(), "restricted-host"); + + // Other sessions still get the default. + let default_backend = router.resolve_backend("session:other").await; + assert_eq!(default_backend.backend_name(), "docker"); +} + +#[tokio::test] +async fn test_set_backend_override_clears_runtime_state() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let mut router = SandboxRouter::new(config); + router.register_backend(Arc::new(RestrictedHostSandbox::new( + SandboxConfig::default(), + ))); + + assert!(router.mark_preparing_once("session:abc").await); + router.mark_synced("session:abc").await; + assert!(!router.mark_preparing_once("session:abc").await); + assert!(router.is_synced("session:abc").await); + + router + .set_backend_override("session:abc", "restricted-host") + .await + .unwrap(); + + assert!(router.mark_preparing_once("session:abc").await); + assert!(!router.is_synced("session:abc").await); +} + +#[tokio::test] +async fn test_set_backend_override_rejects_unknown_backend() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let router = SandboxRouter::new(config); + let result = router + .set_backend_override("session:abc", "nonexistent") + .await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_remove_backend_override_reverts_to_default() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let mut router = SandboxRouter::new(config); + router.register_backend(Arc::new(RestrictedHostSandbox::new( + SandboxConfig::default(), + ))); + + router + .set_backend_override("session:abc", "restricted-host") + .await + .unwrap(); + assert_eq!( + router.resolve_backend("session:abc").await.backend_name(), + "restricted-host" + ); + + router.remove_backend_override("session:abc").await; + assert_eq!( + router.resolve_backend("session:abc").await.backend_name(), + "docker" + ); +} + +#[tokio::test] +async fn test_cleanup_session_clears_backend_override() { + let config = SandboxConfig { + backend: "docker".into(), + ..Default::default() + }; + let mut router = SandboxRouter::new(config); + router.register_backend(Arc::new(RestrictedHostSandbox::new( + SandboxConfig::default(), + ))); + + router + .set_backend_override("session:abc", "restricted-host") + .await + .unwrap(); + + // cleanup_session should clear the backend override (along with other overrides). + // Note: this will call cleanup on docker (the resolved backend at call time), + // which is a no-op for containers that don't exist — that's fine for testing. + let _ = router.cleanup_session("session:abc").await; + + // After cleanup, should revert to default. + assert_eq!( + router.resolve_backend("session:abc").await.backend_name(), + "docker" + ); +} diff --git a/crates/tools/src/sandbox/types.rs b/crates/tools/src/sandbox/types.rs index ced79c75eb..31ec90cb5f 100644 --- a/crates/tools/src/sandbox/types.rs +++ b/crates/tools/src/sandbox/types.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use { async_trait::async_trait, + secrecy::Secret, serde::{Deserialize, Serialize}, }; @@ -62,6 +63,44 @@ impl std::fmt::Display for SandboxMode { } } +/// Known sandbox backend identifiers. +/// +/// Used in the API/gon layer for type-safe backend references. The config +/// schema uses a plain `String` for flexibility (TOML compatibility), but +/// this enum ensures wire-format consistency. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SandboxBackendId { + Docker, + Podman, + AppleContainer, + Cgroup, + RestrictedHost, + Wasm, + Vercel, + Daytona, + Firecracker, + None, +} + +impl SandboxBackendId { + /// Parse from backend_name() output. + pub fn from_name(name: &str) -> Self { + match name { + "docker" => Self::Docker, + "podman" => Self::Podman, + "apple-container" => Self::AppleContainer, + "cgroup" => Self::Cgroup, + "restricted-host" => Self::RestrictedHost, + "wasm" => Self::Wasm, + "vercel" => Self::Vercel, + "daytona" => Self::Daytona, + "firecracker" => Self::Firecracker, + _ => Self::None, + } + } +} + /// Scope determines container lifecycle boundaries. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -149,7 +188,7 @@ pub struct ResourceLimits { pub use moltis_network_filter::NetworkPolicy; /// Configuration for sandbox behavior. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct SandboxConfig { pub mode: SandboxMode, @@ -188,6 +227,46 @@ pub struct SandboxConfig { pub wasm_epoch_interval_ms: Option, /// Per-tool WASM limits (fuel/memory). Falls back to built-in defaults when absent. pub wasm_tool_limits: Option, + + // ── Vercel sandbox configuration ──────────────────────────────────── + /// Vercel API token (`VERCEL_TOKEN` or `VERCEL_OIDC_TOKEN`). + pub vercel_token: Option>, + /// Vercel project ID. + pub vercel_project_id: Option, + /// Vercel team ID. + pub vercel_team_id: Option, + /// Vercel sandbox runtime (e.g. "node24", "node22", "python3.13"). + pub vercel_runtime: Option, + /// Vercel sandbox timeout in milliseconds. + pub vercel_timeout_ms: Option, + /// Vercel sandbox vCPU count. + pub vercel_vcpus: Option, + /// Vercel snapshot ID for fast cold starts. + pub vercel_snapshot_id: Option, + + // ── Daytona sandbox configuration ─────────────────────────────────── + /// Daytona API key (`DAYTONA_API_KEY`). + pub daytona_api_key: Option>, + /// Daytona API URL (default: `https://app.daytona.io/api`). + pub daytona_api_url: Option, + /// Daytona target region/environment. + pub daytona_target: Option, + /// Custom image for Daytona sandbox creation. + pub daytona_image: Option, + + // ── Firecracker sandbox configuration (Linux only) ────────────────── + /// Path to the `firecracker` binary. + pub firecracker_bin: Option, + /// Path to the uncompressed Linux kernel (`vmlinux`). + pub firecracker_kernel: Option, + /// Path to the base ext4 rootfs image. + pub firecracker_rootfs: Option, + /// Path to the SSH private key for VM access. + pub firecracker_ssh_key: Option, + /// Number of vCPUs per Firecracker VM. + pub firecracker_vcpus: Option, + /// Memory in MiB per Firecracker VM. + pub firecracker_memory_mb: Option, } impl Default for SandboxConfig { @@ -212,6 +291,23 @@ impl Default for SandboxConfig { wasm_fuel_limit: None, wasm_epoch_interval_ms: None, wasm_tool_limits: None, + vercel_token: None, + vercel_project_id: None, + vercel_team_id: None, + vercel_runtime: None, + vercel_timeout_ms: None, + vercel_vcpus: None, + vercel_snapshot_id: None, + daytona_api_key: None, + daytona_api_url: None, + daytona_target: None, + daytona_image: None, + firecracker_bin: None, + firecracker_kernel: None, + firecracker_rootfs: None, + firecracker_ssh_key: None, + firecracker_vcpus: None, + firecracker_memory_mb: None, } } } @@ -272,6 +368,23 @@ impl From<&moltis_config::schema::SandboxConfig> for SandboxConfig { wasm_fuel_limit: cfg.wasm_fuel_limit, wasm_epoch_interval_ms: cfg.wasm_epoch_interval_ms, wasm_tool_limits: cfg.wasm_tool_limits.as_ref().map(WasmToolLimits::from), + vercel_token: cfg.vercel_token.clone(), + vercel_project_id: cfg.vercel_project_id.clone(), + vercel_team_id: cfg.vercel_team_id.clone(), + vercel_runtime: cfg.vercel_runtime.clone(), + vercel_timeout_ms: cfg.vercel_timeout_ms, + vercel_vcpus: cfg.vercel_vcpus, + vercel_snapshot_id: cfg.vercel_snapshot_id.clone(), + daytona_api_key: cfg.daytona_api_key.clone(), + daytona_api_url: cfg.daytona_api_url.clone(), + daytona_target: cfg.daytona_target.clone(), + daytona_image: cfg.daytona_image.clone(), + firecracker_bin: cfg.firecracker_bin.as_deref().map(PathBuf::from), + firecracker_kernel: cfg.firecracker_kernel.as_deref().map(PathBuf::from), + firecracker_rootfs: cfg.firecracker_rootfs.as_deref().map(PathBuf::from), + firecracker_ssh_key: cfg.firecracker_ssh_key.as_deref().map(PathBuf::from), + firecracker_vcpus: cfg.firecracker_vcpus, + firecracker_memory_mb: cfg.firecracker_memory_mb, } } } @@ -364,6 +477,67 @@ pub trait Sandbox: Send + Sync { false } + /// The default workspace/home directory inside this backend. + /// + /// Used by workspace sync to determine where to extract files. + /// Defaults to `/home/sandbox`. Remote backends override this + /// (e.g. Vercel returns `/vercel/sandbox`). + fn workspace_dir(&self) -> &str { + SANDBOX_HOME_DIR + } + + /// Workspace directory for a specific prepared session. + /// + /// Most backends use a fixed directory and can rely on the default. + /// Backends whose API returns a per-session project directory override + /// this so workspace sync uses the same path as command execution. + async fn workspace_dir_for(&self, _id: &SandboxId) -> String { + self.workspace_dir().to_string() + } + + /// Whether this backend manages an isolated filesystem that requires + /// workspace sync (copy-in on setup, patch extraction on cleanup). + /// + /// Defaults to `false`. Local bind-mount backends (Docker, Podman, Apple + /// Container) mount the host workspace directly. Remote/VM backends + /// (Vercel, Daytona, Firecracker) return `true` — the workspace must be + /// synced in via git bundles and changes extracted back via patches. + fn is_isolated(&self) -> bool { + false + } + + /// Install packages inside the sandbox. + /// + /// Default implementation uses `apt-get` (Ubuntu/Debian). Backends with + /// different package managers (e.g. Vercel/Amazon Linux uses `dnf`) + /// override this method. + /// + /// Called once per session after `ensure_ready()` for isolated backends + /// that don't have packages pre-baked into the image. + async fn provision_packages(&self, id: &SandboxId, packages: &[String]) -> Result<()> { + if packages.is_empty() { + return Ok(()); + } + let pkg_list = packages.join(" "); + let cmd = format!( + "apt-get update -qq && apt-get install -y -qq --no-install-recommends {pkg_list}" + ); + let opts = ExecOpts { + timeout: std::time::Duration::from_secs(600), + ..Default::default() + }; + let result = self.exec(id, &cmd, &opts).await?; + if result.exit_code != 0 { + tracing::warn!( + %id, + exit_code = result.exit_code, + stderr = result.stderr.trim(), + "package provisioning failed (non-fatal)" + ); + } + Ok(()) + } + /// Pre-build a container image with packages baked in. /// Returns `None` for backends that don't support image building. async fn build_image( @@ -426,3 +600,55 @@ pub(crate) fn sanitize_path_component(input: &str) -> String { out } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use secrecy::{ExposeSecret, Secret}; + + use crate::sandbox::SandboxConfig; + + #[test] + fn sandbox_config_debug_redacts_remote_backend_credentials() { + let config = SandboxConfig { + vercel_token: Some(Secret::new("vercel-secret-value".into())), + daytona_api_key: Some(Secret::new("daytona-secret-value".into())), + ..SandboxConfig::default() + }; + + let debug = format!("{config:?}"); + + assert!(!debug.contains("vercel-secret-value")); + assert!(!debug.contains("daytona-secret-value")); + assert!(debug.contains("vercel_token")); + assert!(debug.contains("daytona_api_key")); + } + + #[test] + fn sandbox_config_deserializes_remote_backend_credentials_as_secrets() { + let config: SandboxConfig = serde_json::from_str( + r#"{ + "vercel_token": "vercel-secret-value", + "daytona_api_key": "daytona-secret-value" + }"#, + ) + .unwrap(); + + assert_eq!( + config + .vercel_token + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("vercel-secret-value") + ); + assert_eq!( + config + .daytona_api_key + .as_ref() + .map(ExposeSecret::expose_secret) + .map(String::as_str), + Some("daytona-secret-value") + ); + } +} diff --git a/crates/tools/src/sandbox/vercel.rs b/crates/tools/src/sandbox/vercel.rs new file mode 100644 index 0000000000..9ea70426d6 --- /dev/null +++ b/crates/tools/src/sandbox/vercel.rs @@ -0,0 +1,1322 @@ +//! Vercel Sandbox backend — Firecracker microVM via the Vercel API. +//! +//! Each session gets an ephemeral Vercel sandbox. Commands run via the +//! REST API, files transfer via gzipped tar uploads and raw reads. The +//! sandbox is stopped on cleanup. +//! +//! Requires `VERCEL_TOKEN` (or `VERCEL_OIDC_TOKEN`) and a Vercel project. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use { + async_trait::async_trait, + flate2::{Compression, write::GzEncoder}, + secrecy::{ExposeSecret, Secret}, + tokio::sync::{RwLock, Semaphore}, + tracing::{debug, info, warn}, +}; + +use crate::{ + error::{Error, Result}, + exec::{ExecOpts, ExecResult}, + sandbox::{ + file_system::SandboxReadResult, + types::{Sandbox, SandboxConfig, SandboxId}, + }, +}; + +/// Base URL for Vercel API. +const VERCEL_API_BASE: &str = "https://vercel.com/api"; + +/// Default sandbox workspace directory inside Vercel sandboxes. +const VERCEL_WORKSPACE: &str = "/vercel/sandbox"; + +/// Generic workspace path used by the shared sandbox tool contract. +const GENERIC_WORKSPACE: &str = "/home/sandbox"; +const GENERIC_WORKSPACE_PREFIX: &str = "/home/sandbox/"; + +/// Default timeout for sandbox creation (5 minutes). +const DEFAULT_TIMEOUT_MS: u64 = 300_000; + +/// State of a live Vercel sandbox session. +struct VercelSession { + sandbox_id: String, +} + +#[derive(Debug, Default)] +struct VercelCommandEvents { + command_id: Option, + exit_code: Option, + stdout: String, + stderr: String, +} + +/// Vercel Sandbox backend configuration. +#[derive(Debug, Clone)] +pub struct VercelSandboxConfig { + pub token: Secret, + pub project_id: Option, + pub team_id: Option, + pub runtime: String, + pub timeout_ms: u64, + pub vcpus: u32, + pub snapshot_id: Option, +} + +impl Default for VercelSandboxConfig { + fn default() -> Self { + Self { + token: Secret::new(String::new()), + project_id: None, + team_id: None, + runtime: "node24".into(), + timeout_ms: DEFAULT_TIMEOUT_MS, + vcpus: 2, + snapshot_id: None, + } + } +} + +/// Vercel Sandbox backend. +pub struct VercelSandbox { + #[allow(dead_code)] + config: SandboxConfig, + vercel: VercelSandboxConfig, + client: reqwest::Client, + active: RwLock>, + creation_permits: RwLock>>, +} + +impl VercelSandbox { + pub fn new(config: SandboxConfig, vercel: VercelSandboxConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .unwrap_or_default(); + Self { + config, + vercel, + client, + active: RwLock::new(HashMap::new()), + creation_permits: RwLock::new(HashMap::new()), + } + } + + async fn creation_permit(&self, id: &SandboxId) -> Arc { + if let Some(permit) = self.creation_permits.read().await.get(&id.key).cloned() { + return permit; + } + let mut permits = self.creation_permits.write().await; + permits + .entry(id.key.clone()) + .or_insert_with(|| Arc::new(Semaphore::new(1))) + .clone() + } + + async fn existing_creation_permit(&self, id: &SandboxId) -> Option> { + self.creation_permits.read().await.get(&id.key).cloned() + } + + fn translate_working_dir(working_dir: Option<&str>) -> String { + match working_dir { + Some(path) if path == GENERIC_WORKSPACE => VERCEL_WORKSPACE.to_string(), + Some(path) if path.starts_with(GENERIC_WORKSPACE_PREFIX) => { + format!("{VERCEL_WORKSPACE}{}", &path[GENERIC_WORKSPACE.len()..]) + }, + Some(path) => path.to_string(), + None => VERCEL_WORKSPACE.to_string(), + } + } + + fn env_object(env: &[(String, String)]) -> serde_json::Map { + env.iter() + .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone()))) + .collect() + } + + fn non_empty_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + } + + fn sandbox_id_from_create_response(data: &serde_json::Value) -> Option { + Self::non_empty_id(data["sandbox"]["id"].as_str()) + .or_else(|| Self::non_empty_id(data["sandbox"]["sandboxId"].as_str())) + .or_else(|| Self::non_empty_id(data["sandbox"]["sandbox_id"].as_str())) + .or_else(|| Self::non_empty_id(data["id"].as_str())) + .or_else(|| Self::non_empty_id(data["sandboxId"].as_str())) + .or_else(|| Self::non_empty_id(data["sandbox_id"].as_str())) + } + + fn sandbox_id_from_location(location: Option<&reqwest::header::HeaderValue>) -> Option { + let location = location.and_then(|value| value.to_str().ok())?; + let path = location.split('?').next().unwrap_or(location); + Self::non_empty_id(path.rsplit('/').next()) + } + + fn snapshot_id_from_response(data: &serde_json::Value) -> Option { + Self::non_empty_id(data["snapshot"]["id"].as_str()) + .or_else(|| Self::non_empty_id(data["snapshot"]["snapshotId"].as_str())) + .or_else(|| Self::non_empty_id(data["snapshot"]["snapshot_id"].as_str())) + .or_else(|| Self::non_empty_id(data["id"].as_str())) + .or_else(|| Self::non_empty_id(data["snapshotId"].as_str())) + .or_else(|| Self::non_empty_id(data["snapshot_id"].as_str())) + } + + fn parse_command_events(text: &str, max_output_bytes: usize) -> VercelCommandEvents { + let mut events = VercelCommandEvents::default(); + for line in text.lines().filter(|line| !line.is_empty()) { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + + if let Some(code) = value["command"]["exitCode"] + .as_i64() + .or_else(|| value["command"]["exit_code"].as_i64()) + .or_else(|| value["exitCode"].as_i64()) + .or_else(|| value["exit_code"].as_i64()) + { + events.exit_code = Some(code as i32); + } + + if events.command_id.is_none() { + events.command_id = value["command"]["id"] + .as_str() + .or_else(|| value["command"]["commandId"].as_str()) + .or_else(|| value["command"]["command_id"].as_str()) + .or_else(|| value["id"].as_str()) + .or_else(|| value["commandId"].as_str()) + .or_else(|| value["command_id"].as_str()) + .and_then(|id| Self::non_empty_id(Some(id))); + } + + Self::append_inline_command_output(&value, &mut events.stdout, &mut events.stderr); + } + + events + .stdout + .truncate(events.stdout.floor_char_boundary(max_output_bytes)); + events + .stderr + .truncate(events.stderr.floor_char_boundary(max_output_bytes)); + events + } + + fn append_inline_command_output( + value: &serde_json::Value, + stdout: &mut String, + stderr: &mut String, + ) { + for candidate in [ + &value["stdout"], + &value["command"]["stdout"], + &value["result"]["stdout"], + &value["command"]["result"]["stdout"], + ] { + if let Some(text) = candidate.as_str() { + stdout.push_str(text); + } + } + + for candidate in [ + &value["stderr"], + &value["command"]["stderr"], + &value["result"]["stderr"], + &value["command"]["result"]["stderr"], + ] { + if let Some(text) = candidate.as_str() { + stderr.push_str(text); + } + } + + let stream = value["stream"] + .as_str() + .or_else(|| value["command"]["stream"].as_str()) + .unwrap_or("stdout"); + for candidate in [ + &value["data"], + &value["output"], + &value["result"], + &value["command"]["data"], + &value["command"]["output"], + &value["command"]["result"], + ] { + if let Some(text) = candidate.as_str() { + match stream { + "stderr" => stderr.push_str(text), + _ => stdout.push_str(text), + } + } + } + } + + fn command_output_from_logs( + events: VercelCommandEvents, + logs: Result<(String, String)>, + sandbox_id: &str, + ) -> (String, String) { + match logs { + Ok(logs) if !logs.0.is_empty() || !logs.1.is_empty() => logs, + Ok(_) => (events.stdout, events.stderr), + Err(e) => { + warn!( + vercel_id = sandbox_id, + error = %e, + "vercel: failed to fetch command logs, using inline output" + ); + (events.stdout, events.stderr) + }, + } + } + + /// Build an authenticated request with team scoping. + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let mut url = format!("{VERCEL_API_BASE}{path}"); + if let Some(ref team_id) = self.vercel.team_id { + url.push_str(&format!( + "{}teamId={team_id}", + if url.contains('?') { + "&" + } else { + "?" + } + )); + } + self.client + .request(method, &url) + .bearer_auth(self.vercel.token.expose_secret()) + } + + /// Create a Vercel sandbox, returning the sandbox ID. + async fn create_sandbox(&self) -> Result { + let project_id = self.vercel.project_id.as_deref().ok_or_else(|| { + Error::message( + "vercel: project_id is required (set VERCEL_PROJECT_ID or configure in settings)", + ) + })?; + + let mut body = serde_json::json!({ + "projectId": project_id, + "runtime": self.vercel.runtime, + "timeout": self.vercel.timeout_ms, + "resources": { "vcpus": self.vercel.vcpus }, + }); + + if let Some(ref snapshot_id) = self.vercel.snapshot_id { + body["source"] = serde_json::json!({ + "type": "snapshot", + "snapshotId": snapshot_id, + }); + } + + let resp = self + .request(reqwest::Method::POST, "/v1/sandboxes") + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("vercel: failed to create sandbox: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "vercel: create sandbox failed (HTTP {status}): {text}" + ))); + } + + let location = resp.headers().get(reqwest::header::LOCATION).cloned(); + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| Error::message(format!("vercel: invalid create response: {e}")))?; + + match Self::sandbox_id_from_create_response(&data) + .or_else(|| Self::sandbox_id_from_location(location.as_ref())) + { + Some(id) => Ok(id), + None => { + warn!( + "vercel: sandbox create response contained no parseable sandbox ID; \ + the VM will run until its timeout" + ); + Err(Error::message( + "vercel: missing sandbox.id in create response", + )) + }, + } + } + + /// Wait for a sandbox to reach "running" status. + async fn wait_for_running(&self, sandbox_id: &str) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + loop { + let resp = self + .request(reqwest::Method::GET, &format!("/v1/sandboxes/{sandbox_id}")) + .send() + .await + .map_err(|e| Error::message(format!("vercel: failed to get sandbox: {e}")))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "vercel: get sandbox failed: {text}" + ))); + } + + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| Error::message(format!("vercel: invalid get response: {e}")))?; + + let status = data["sandbox"]["status"].as_str().unwrap_or("unknown"); + match status { + "running" => return Ok(()), + "failed" | "aborted" | "stopped" => { + return Err(Error::message(format!( + "vercel: sandbox entered terminal state: {status}" + ))); + }, + _ => { + if tokio::time::Instant::now() >= deadline { + return Err(Error::message(format!( + "vercel: sandbox did not reach running state within 120s (current: {status})" + ))); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }, + } + } + } + + async fn prepare_created_sandbox(&self, id: &SandboxId, sandbox_id: &str) -> Result<()> { + debug!(%id, vercel_id = sandbox_id, "vercel: sandbox created, waiting for running state"); + + if let Err(e) = self.wait_for_running(sandbox_id).await { + self.stop_after_setup_failure(sandbox_id, "wait_for_running") + .await; + return Err(e); + } + if let Err(e) = self.mkdir(sandbox_id, VERCEL_WORKSPACE).await { + self.stop_after_setup_failure(sandbox_id, "mkdir").await; + return Err(e); + } + + Ok(()) + } + + /// Run a command and wait for completion via NDJSON streaming. + async fn run_command( + &self, + sandbox_id: &str, + command: &str, + opts: &ExecOpts, + ) -> Result { + let cwd = Self::translate_working_dir(opts.working_dir.as_ref().and_then(|p| p.to_str())); + let mut body = serde_json::json!({ + "command": "sh", + "args": ["-c", command], + "cwd": cwd, + "wait": true, + }); + if !opts.env.is_empty() { + body["env"] = serde_json::Value::Object(Self::env_object(&opts.env)); + } + + let resp = self + .request( + reqwest::Method::POST, + &format!("/v1/sandboxes/{sandbox_id}/cmd"), + ) + .timeout(opts.timeout + Duration::from_secs(5)) + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("vercel: command request failed: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "vercel: command failed (HTTP {status}): {text}" + ))); + } + + // Response is NDJSON: first line = started, last line = finished. + let text = resp + .text() + .await + .map_err(|e| Error::message(format!("vercel: failed to read command response: {e}")))?; + + let events = Self::parse_command_events(&text, opts.max_output_bytes); + let exit_code = events.exit_code.unwrap_or(-1); + + let (stdout, stderr) = if let Some(cmd_id) = events.command_id.clone() { + let logs = self.fetch_command_logs(sandbox_id, &cmd_id, opts).await; + Self::command_output_from_logs(events, logs, sandbox_id) + } else { + (events.stdout, events.stderr) + }; + + Ok(ExecResult { + stdout, + stderr, + exit_code, + }) + } + + /// Fetch stdout/stderr logs for a completed command. + async fn fetch_command_logs( + &self, + sandbox_id: &str, + cmd_id: &str, + opts: &ExecOpts, + ) -> Result<(String, String)> { + let resp = self + .request( + reqwest::Method::GET, + &format!("/v1/sandboxes/{sandbox_id}/cmd/{cmd_id}/logs"), + ) + .timeout(opts.timeout + Duration::from_secs(5)) + .send() + .await + .map_err(|e| Error::message(format!("vercel: failed to fetch logs: {e}")))?; + + if !resp.status().is_success() { + return Ok((String::new(), String::new())); + } + + let text = resp.text().await.unwrap_or_default(); + let mut stdout = String::new(); + let mut stderr = String::new(); + + for line in text.lines().filter(|l| !l.is_empty()) { + if let Ok(v) = serde_json::from_str::(line) { + let stream = v["stream"].as_str().unwrap_or(""); + let data = v["data"].as_str().unwrap_or(""); + match stream { + "stdout" => stdout.push_str(data), + "stderr" => stderr.push_str(data), + _ => {}, + } + } + } + + stdout.truncate(stdout.floor_char_boundary(opts.max_output_bytes)); + stderr.truncate(stderr.floor_char_boundary(opts.max_output_bytes)); + + Ok((stdout, stderr)) + } + + /// Write files to the sandbox using gzipped tar. + async fn write_files_tar(&self, sandbox_id: &str, files: &[(&str, &[u8])]) -> Result<()> { + let gz_bytes = { + let buf = Vec::new(); + let enc = GzEncoder::new(buf, Compression::fast()); + let mut ar = tar::Builder::new(enc); + + for &(path, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + ar.append_data(&mut header, path.trim_start_matches('/'), content) + .map_err(|e| Error::message(format!("vercel: tar append failed: {e}")))?; + } + + ar.into_inner() + .and_then(|enc| enc.finish()) + .map_err(|e| Error::message(format!("vercel: tar finalize failed: {e}")))? + }; + + let resp = self + .request( + reqwest::Method::POST, + &format!("/v1/sandboxes/{sandbox_id}/fs/write"), + ) + .header("Content-Type", "application/gzip") + .header("X-Cwd", "/") + .body(gz_bytes) + .send() + .await + .map_err(|e| Error::message(format!("vercel: file write request failed: {e}")))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!("vercel: file write failed: {text}"))); + } + + Ok(()) + } + + /// Read a file from the sandbox. + async fn read_file_raw(&self, sandbox_id: &str, path: &str) -> Result>> { + let body = serde_json::json!({ + "path": path, + "cwd": "/", + }); + + let resp = self + .request( + reqwest::Method::POST, + &format!("/v1/sandboxes/{sandbox_id}/fs/read"), + ) + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("vercel: file read request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!("vercel: file read failed: {text}"))); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| Error::message(format!("vercel: failed to read file bytes: {e}")))?; + + Ok(Some(bytes.to_vec())) + } + + /// Create a directory in the sandbox. + async fn mkdir(&self, sandbox_id: &str, path: &str) -> Result<()> { + let body = serde_json::json!({ "path": path }); + + let resp = self + .request( + reqwest::Method::POST, + &format!("/v1/sandboxes/{sandbox_id}/fs/mkdir"), + ) + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("vercel: mkdir request failed: {e}")))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!("vercel: mkdir failed: {text}"))); + } + + Ok(()) + } + + /// Stop a sandbox. + async fn stop_sandbox(&self, sandbox_id: &str) -> Result<()> { + let resp = self + .request( + reqwest::Method::POST, + &format!("/v1/sandboxes/{sandbox_id}/stop"), + ) + .send() + .await + .map_err(|e| Error::message(format!("vercel: stop request failed: {e}")))?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + if !text.contains("already stopped") && !text.contains("not running") { + return Err(Error::message(format!( + "vercel: stop sandbox failed: {text}" + ))); + } + } + + Ok(()) + } + + async fn stop_after_setup_failure(&self, sandbox_id: &str, step: &str) { + if let Err(e) = self.stop_sandbox(sandbox_id).await { + warn!( + vercel_id = sandbox_id, + step, + error = %e, + "vercel: failed to stop sandbox after setup failure" + ); + } + } + + /// Get the sandbox ID for a session, or None. + async fn session_sandbox_id(&self, id: &SandboxId) -> Option { + self.active + .read() + .await + .get(&id.key) + .map(|s| s.sandbox_id.clone()) + } +} + +#[async_trait] +impl Sandbox for VercelSandbox { + fn backend_name(&self) -> &'static str { + "vercel" + } + + fn is_real(&self) -> bool { + true + } + + fn provides_fs_isolation(&self) -> bool { + true + } + + fn is_isolated(&self) -> bool { + true + } + + fn workspace_dir(&self) -> &str { + "/vercel/sandbox" + } + + /// Vercel sandboxes run Amazon Linux 2023 which uses `dnf`, not `apt-get`. + async fn provision_packages(&self, id: &SandboxId, packages: &[String]) -> Result<()> { + if packages.is_empty() { + return Ok(()); + } + // Map common Debian package names to Amazon Linux equivalents. + let mapped: Vec<&str> = packages + .iter() + .filter_map(|p| debian_to_amzn_package(p)) + .collect(); + if mapped.is_empty() { + return Ok(()); + } + let pkg_list = mapped.join(" "); + let cmd = format!("sudo dnf install -y -q {pkg_list}"); + let opts = ExecOpts { + timeout: Duration::from_secs(600), + ..Default::default() + }; + let result = self.exec(id, &cmd, &opts).await?; + if result.exit_code != 0 { + tracing::warn!( + %id, + exit_code = result.exit_code, + stderr = result.stderr.trim(), + "vercel: package provisioning failed (non-fatal)" + ); + } + Ok(()) + } + + /// Build a Vercel snapshot with packages pre-installed. + /// + /// Creates a temporary sandbox, installs packages via dnf, takes a + /// snapshot, and stops the sandbox. The snapshot ID is returned as + /// the "tag" — future `ensure_ready()` calls create sandboxes from + /// this snapshot, skipping package installation entirely. + async fn build_image( + &self, + _base: &str, + packages: &[String], + ) -> Result> { + if packages.is_empty() { + return Ok(None); + } + + // If a snapshot is already configured, skip building. + if self.vercel.snapshot_id.is_some() { + return Ok(None); + } + + info!("vercel: building snapshot with packages pre-installed"); + + let sandbox_id = self.create_sandbox().await?; + if let Err(e) = self.wait_for_running(&sandbox_id).await { + self.stop_after_setup_failure(&sandbox_id, "wait_for_running") + .await; + return Err(e); + } + + // Install packages using the Vercel-specific provisioning. + let mapped: Vec<&str> = packages + .iter() + .filter_map(|p| debian_to_amzn_package(p)) + .collect(); + if !mapped.is_empty() { + let pkg_list = mapped.join(" "); + let cmd = format!("sudo dnf install -y -q {pkg_list}"); + let opts = ExecOpts { + timeout: Duration::from_secs(600), + ..Default::default() + }; + let result = self.run_command(&sandbox_id, &cmd, &opts).await; + if let Ok(r) = result + && r.exit_code != 0 + { + warn!( + exit_code = r.exit_code, + "vercel: package install for snapshot failed (continuing)" + ); + } + } + + // Take a snapshot. + let resp = match self + .request( + reqwest::Method::POST, + &format!("/v1/sandboxes/{sandbox_id}/snapshot"), + ) + .json(&serde_json::json!({})) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + let _ = self.stop_sandbox(&sandbox_id).await; + return Err(Error::message(format!( + "vercel: snapshot request failed: {e}" + ))); + }, + }; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + warn!("vercel: snapshot failed: {text}"); + let _ = self.stop_sandbox(&sandbox_id).await; + return Ok(None); + } + + let data: serde_json::Value = match resp.json().await { + Ok(data) => data, + Err(e) => { + let _ = self.stop_sandbox(&sandbox_id).await; + return Err(Error::message(format!( + "vercel: invalid snapshot response: {e}" + ))); + }, + }; + + let Some(snapshot_id) = Self::snapshot_id_from_response(&data) else { + let _ = self.stop_sandbox(&sandbox_id).await; + warn!("vercel: snapshot response missing snapshot id"); + return Ok(None); + }; + + info!(snapshot_id, "vercel: snapshot created with packages"); + + if snapshot_id.is_empty() { + let _ = self.stop_sandbox(&sandbox_id).await; + return Ok(None); + } + + if let Err(e) = self.stop_sandbox(&sandbox_id).await { + warn!(error = %e, "vercel: sandbox stop failed after snapshot creation"); + } + + Ok(Some(super::types::BuildImageResult { + tag: snapshot_id, + built: true, + })) + } + + async fn ensure_ready(&self, id: &SandboxId, image_override: Option<&str>) -> Result<()> { + if self.session_sandbox_id(id).await.is_some() { + return Ok(()); + } + let permit = self.creation_permit(id).await; + let _permit = permit + .acquire_owned() + .await + .map_err(|e| Error::message(format!("vercel: sandbox creation permit closed: {e}")))?; + if self.session_sandbox_id(id).await.is_some() { + return Ok(()); + } + + info!(%id, runtime = self.vercel.runtime, "vercel: creating sandbox"); + + // Use snapshot if available (from build_image() or config). + let effective_snapshot = image_override + .filter(|s| !s.is_empty()) + .or(self.vercel.snapshot_id.as_deref()); + + let sandbox_id = if let Some(snap_id) = effective_snapshot { + // Create from snapshot — packages already installed, much faster. + debug!(%id, snapshot = snap_id, "vercel: creating from snapshot"); + let mut body = serde_json::json!({ + "source": { "type": "snapshot", "snapshotId": snap_id }, + "runtime": self.vercel.runtime, + "timeout": self.vercel.timeout_ms, + "resources": { "vcpus": self.vercel.vcpus }, + }); + if let Some(ref project_id) = self.vercel.project_id { + body["projectId"] = serde_json::Value::String(project_id.clone()); + } + let resp = self + .request(reqwest::Method::POST, "/v1/sandboxes") + .json(&body) + .send() + .await + .map_err(|e| Error::message(format!("vercel: failed to create sandbox: {e}")))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::message(format!( + "vercel: create from snapshot failed (HTTP {status}): {text}" + ))); + } + let location = resp.headers().get(reqwest::header::LOCATION).cloned(); + let text = resp.text().await.map_err(|e| { + Error::message(format!("vercel: failed to read create response: {e}")) + })?; + let data: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| Error::message(format!("vercel: invalid create response: {e}")))?; + match Self::sandbox_id_from_create_response(&data) + .or_else(|| Self::sandbox_id_from_location(location.as_ref())) + { + Some(sandbox_id) => sandbox_id, + None => { + warn!( + %id, + "vercel: sandbox created from snapshot but response contained no \ + parseable sandbox ID; the VM will run until its timeout" + ); + return Err(Error::message( + "vercel: missing sandbox.id in snapshot-create response", + )); + }, + } + } else { + self.create_sandbox().await? + }; + + self.prepare_created_sandbox(id, &sandbox_id).await?; + + info!(%id, vercel_id = sandbox_id, "vercel: sandbox ready"); + + self.active + .write() + .await + .insert(id.key.clone(), VercelSession { sandbox_id }); + + Ok(()) + } + + async fn exec(&self, id: &SandboxId, command: &str, opts: &ExecOpts) -> Result { + let sandbox_id = self + .session_sandbox_id(id) + .await + .ok_or_else(|| Error::message(format!("vercel: no active sandbox for {id}")))?; + + self.run_command(&sandbox_id, command, opts).await + } + + async fn read_file( + &self, + id: &SandboxId, + file_path: &str, + max_bytes: u64, + ) -> Result { + let sandbox_id = self + .session_sandbox_id(id) + .await + .ok_or_else(|| Error::message(format!("vercel: no active sandbox for {id}")))?; + + match self.read_file_raw(&sandbox_id, file_path).await? { + None => Ok(SandboxReadResult::NotFound), + Some(bytes) => { + if bytes.len() as u64 > max_bytes { + Ok(SandboxReadResult::TooLarge(bytes.len() as u64)) + } else { + Ok(SandboxReadResult::Ok(bytes)) + } + }, + } + } + + async fn write_file( + &self, + id: &SandboxId, + file_path: &str, + content: &[u8], + ) -> Result> { + let sandbox_id = self + .session_sandbox_id(id) + .await + .ok_or_else(|| Error::message(format!("vercel: no active sandbox for {id}")))?; + + self.write_files_tar(&sandbox_id, &[(file_path, content)]) + .await?; + + Ok(None) + } + + async fn cleanup(&self, id: &SandboxId) -> Result<()> { + let permit = self.existing_creation_permit(id).await; + let _permit = match permit { + Some(permit) => Some(permit.acquire_owned().await.map_err(|e| { + Error::message(format!("vercel: sandbox creation permit closed: {e}")) + })?), + None => None, + }; + let session = self.active.write().await.remove(&id.key); + self.creation_permits.write().await.remove(&id.key); + if let Some(session) = session { + debug!(%id, vercel_id = session.sandbox_id, "vercel: stopping sandbox"); + if let Err(e) = self.stop_sandbox(&session.sandbox_id).await { + warn!(%id, error = %e, "vercel: sandbox stop failed during cleanup"); + } + } + Ok(()) + } +} + +/// Map a Debian/Ubuntu package name to its Amazon Linux 2023 equivalent. +/// Returns `None` for packages that have no equivalent or are unavailable. +fn debian_to_amzn_package(debian_name: &str) -> Option<&str> { + // Direct matches (same name on both distros). + const DIRECT: &[&str] = &[ + "curl", + "wget", + "git", + "jq", + "rsync", + "tar", + "zip", + "unzip", + "bzip2", + "xz", + "zstd", + "lz4", + "cmake", + "autoconf", + "automake", + "libtool", + "make", + "gcc", + "gcc-c++", + "clang", + "tmux", + "sqlite", + "vim", + "ImageMagick", + "ffmpeg", + "pandoc", + "gnupg2", + ]; + + // Debian → Amazon Linux name mappings. + match debian_name { + // Direct matches + p if DIRECT.contains(&p) => Some(p), + // Common renames + "build-essential" => Some("gcc gcc-c++ make"), + "ca-certificates" => Some("ca-certificates"), + "python3" | "python3-dev" => Some("python3"), + "python3-pip" => Some("python3-pip"), + "python3-venv" => Some("python3"), + "python-is-python3" => None, // not needed on AL2023 + "nodejs" => None, // already available on Vercel's node runtime + "ruby" | "ruby-dev" => Some("ruby"), + "golang-go" => Some("golang"), + "default-jdk" => Some("java-17-amazon-corretto-devel"), + "openssh-client" => Some("openssh-clients"), + "iproute2" => Some("iproute"), + "net-tools" => Some("net-tools"), + "imagemagick" => Some("ImageMagick"), + "graphicsmagick" => Some("GraphicsMagick"), + "sqlite3" => Some("sqlite"), + "postgresql-client" => Some("postgresql15"), + "shellcheck" => Some("ShellCheck"), + "p7zip" | "p7zip-full" => Some("p7zip"), + // Skip packages that don't exist on Amazon Linux + "dnsutils" | "netcat-openbsd" | "csvtool" | "datamash" | "miller" | "antiword" | "khal" + | "vdirsyncer" | "isync" | "notmuch" | "aerc" | "mutt" | "neomutt" | "php-cli" + | "php-mbstring" | "php-xml" | "php-curl" | "perl" | "maven" | "ninja-build" => None, + // For anything else, try the name directly (dnf will skip unknown ones) + _ => None, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_vercel_sandbox_backend_name() { + let sandbox = VercelSandbox::new(SandboxConfig::default(), VercelSandboxConfig::default()); + assert_eq!(sandbox.backend_name(), "vercel"); + assert!(sandbox.is_real()); + assert!(sandbox.provides_fs_isolation()); + assert!(sandbox.is_isolated()); + } + + #[test] + fn test_vercel_config_defaults() { + let config = VercelSandboxConfig::default(); + assert_eq!(config.runtime, "node24"); + assert_eq!(config.vcpus, 2); + assert_eq!(config.timeout_ms, 300_000); + assert!(config.project_id.is_none()); + assert!(config.team_id.is_none()); + assert!(config.snapshot_id.is_none()); + } + + #[test] + fn test_translate_working_dir_preserves_workspace_subdirectory() { + assert_eq!( + VercelSandbox::translate_working_dir(Some("/home/sandbox/myproject/src")), + "/vercel/sandbox/myproject/src" + ); + assert_eq!( + VercelSandbox::translate_working_dir(Some("/home/sandbox")), + "/vercel/sandbox" + ); + assert_eq!( + VercelSandbox::translate_working_dir(Some("/tmp/build")), + "/tmp/build" + ); + assert_eq!( + VercelSandbox::translate_working_dir(None), + "/vercel/sandbox" + ); + } + + #[test] + fn test_env_object_includes_exec_env() { + let env = VercelSandbox::env_object(&[ + ("API_TOKEN".to_string(), "secret-value".to_string()), + ("SESSION_ID".to_string(), "abc123".to_string()), + ]); + + assert_eq!( + env.get("API_TOKEN").and_then(serde_json::Value::as_str), + Some("secret-value") + ); + assert_eq!( + env.get("SESSION_ID").and_then(serde_json::Value::as_str), + Some("abc123") + ); + } + + #[test] + fn test_sandbox_id_from_create_response_accepts_known_shapes() { + assert_eq!( + VercelSandbox::sandbox_id_from_create_response(&serde_json::json!({ + "sandbox": { "id": "sb_nested" }, + })) + .as_deref(), + Some("sb_nested") + ); + assert_eq!( + VercelSandbox::sandbox_id_from_create_response(&serde_json::json!({ + "sandboxId": "sb_top", + })) + .as_deref(), + Some("sb_top") + ); + assert_eq!( + VercelSandbox::sandbox_id_from_create_response(&serde_json::json!({ + "sandbox": { "sandbox_id": "sb_snake" }, + })) + .as_deref(), + Some("sb_snake") + ); + assert_eq!( + VercelSandbox::sandbox_id_from_create_response(&serde_json::json!({ + "sandbox": { "id": "" }, + })), + None + ); + } + + #[test] + fn test_sandbox_id_from_location_header() { + let location = + reqwest::header::HeaderValue::from_static("/api/v1/sandboxes/sb_from_location?x=1"); + assert_eq!( + VercelSandbox::sandbox_id_from_location(Some(&location)).as_deref(), + Some("sb_from_location") + ); + + let uuid_location = reqwest::header::HeaderValue::from_static( + "/api/v1/sandboxes/6f9619ff-8b86-d011-b42d-00cf4fc964ff", + ); + assert_eq!( + VercelSandbox::sandbox_id_from_location(Some(&uuid_location)).as_deref(), + Some("6f9619ff-8b86-d011-b42d-00cf4fc964ff") + ); + + let invalid = reqwest::header::HeaderValue::from_static("/api/v1/sandboxes/"); + assert_eq!( + VercelSandbox::sandbox_id_from_location(Some(&invalid)), + None + ); + } + + #[test] + fn test_snapshot_id_from_response_accepts_known_shapes() { + assert_eq!( + VercelSandbox::snapshot_id_from_response(&serde_json::json!({ + "snapshot": { "id": "snap_nested" }, + })) + .as_deref(), + Some("snap_nested") + ); + assert_eq!( + VercelSandbox::snapshot_id_from_response(&serde_json::json!({ + "snapshot": { "snapshotId": "snap_camel" }, + })) + .as_deref(), + Some("snap_camel") + ); + assert_eq!( + VercelSandbox::snapshot_id_from_response(&serde_json::json!({ + "snapshot_id": "snap_snake", + })) + .as_deref(), + Some("snap_snake") + ); + assert_eq!( + VercelSandbox::snapshot_id_from_response(&serde_json::json!({ + "snapshot": { "snapshotId": "" }, + })), + None + ); + } + + #[test] + fn test_parse_command_events_uses_inline_output_without_command_id() { + let events = + VercelSandbox::parse_command_events(r#"{"stream":"stdout","data":"hello\n"}"#, 1024); + + assert_eq!(events.command_id, None); + assert_eq!(events.exit_code, None); + assert_eq!(events.stdout, "hello\n"); + assert_eq!(events.stderr, ""); + } + + #[test] + fn test_parse_command_events_accepts_result_output_and_exit_code() { + let events = VercelSandbox::parse_command_events( + r#"{"exitCode":7,"result":{"stdout":"ok","stderr":"bad"}}"#, + 1024, + ); + + assert_eq!(events.command_id, None); + assert_eq!(events.exit_code, Some(7)); + assert_eq!(events.stdout, "ok"); + assert_eq!(events.stderr, "bad"); + } + + #[test] + fn test_parse_command_events_accepts_command_id_and_truncates() { + let events = VercelSandbox::parse_command_events( + r#"{"command":{"commandId":"cmd_123","exit_code":0},"output":"abcdef"}"#, + 3, + ); + + assert_eq!(events.command_id.as_deref(), Some("cmd_123")); + assert_eq!(events.exit_code, Some(0)); + assert_eq!(events.stdout, "abc"); + } + + #[test] + fn test_command_output_from_logs_prefers_non_empty_logs() { + let events = VercelCommandEvents { + stdout: "inline".into(), + stderr: "inline-err".into(), + ..Default::default() + }; + + let output = VercelSandbox::command_output_from_logs( + events, + Ok(("logs".into(), String::new())), + "sb_test", + ); + + assert_eq!(output, ("logs".into(), String::new())); + } + + #[test] + fn test_command_output_from_logs_falls_back_on_empty_logs() { + let events = VercelCommandEvents { + stdout: "inline".into(), + stderr: "inline-err".into(), + ..Default::default() + }; + + let output = VercelSandbox::command_output_from_logs( + events, + Ok((String::new(), String::new())), + "sb_test", + ); + + assert_eq!(output, ("inline".into(), "inline-err".into())); + } + + #[test] + fn test_command_output_from_logs_falls_back_on_fetch_error() { + let events = VercelCommandEvents { + stdout: "inline".into(), + stderr: "inline-err".into(), + ..Default::default() + }; + + let output = VercelSandbox::command_output_from_logs( + events, + Err(Error::message("logs unavailable")), + "sb_test", + ); + + assert_eq!(output, ("inline".into(), "inline-err".into())); + } + + #[test] + fn test_gzip_tar_roundtrip() { + let files: Vec<(&str, &[u8])> = vec![ + ("/tmp/test.txt", b"hello world"), + ("/tmp/dir/nested.txt", b"nested content"), + ]; + + let gz_bytes = { + let buf = Vec::new(); + let enc = GzEncoder::new(buf, Compression::fast()); + let mut ar = tar::Builder::new(enc); + + for &(path, content) in &files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + ar.append_data(&mut header, path.trim_start_matches('/'), content) + .unwrap(); + } + + ar.into_inner().and_then(|enc| enc.finish()).unwrap() + }; + + // Verify it's valid gzip by decompressing. + use {flate2::read::GzDecoder, std::io::Read}; + let mut decoder = GzDecoder::new(&gz_bytes[..]); + let mut tar_bytes = Vec::new(); + decoder.read_to_end(&mut tar_bytes).unwrap(); + + let mut archive = tar::Archive::new(&tar_bytes[..]); + let entries: Vec<_> = archive.entries().unwrap().collect(); + assert_eq!(entries.len(), 2); + } + + #[tokio::test] + async fn test_no_active_sandbox_returns_error() { + let sandbox = VercelSandbox::new(SandboxConfig::default(), VercelSandboxConfig::default()); + let id = SandboxId { + scope: crate::sandbox::types::SandboxScope::Session, + key: "test".into(), + }; + let opts = ExecOpts::default(); + let result = sandbox.exec(&id, "echo hello", &opts).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no active sandbox") + ); + } +} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 5fa1f434aa..4654d6c3e0 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -22,6 +22,7 @@ moltis-media = { workspace = true } moltis-skills = { workspace = true } moltis-tools = { workspace = true } portable-pty = { workspace = true } +secrecy = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sysinfo = { workspace = true } diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 0240b6ce1d..cdc12d9bcf 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -11,6 +11,7 @@ use { }, moltis_httpd::AppState, moltis_tools::image_cache::ImageBuilder, + secrecy::{ExposeSecret, Secret}, tracing::warn, }; @@ -53,12 +54,39 @@ fn api_error_response(status: StatusCode, code: &str, error: impl Into) (status, Json(api_error(code, error))).into_response() } +fn configured_secret(secret: &Option>) -> bool { + secret + .as_ref() + .is_some_and(|secret| !secret.expose_secret().is_empty()) +} + #[derive(serde::Deserialize)] pub struct SandboxSharedHomeUpdateRequest { enabled: bool, path: Option, } +#[derive(serde::Deserialize)] +pub struct RemoteBackendUpdateRequest { + /// Which backend: "vercel" or "daytona". + backend: String, + config: RemoteBackendConfigUpdate, +} + +#[derive(Default, serde::Deserialize)] +struct RemoteBackendConfigUpdate { + backend: Option, + token: Option>, + api_key: Option>, + project_id: Option>, + team_id: Option>, + runtime: Option, + timeout_ms: Option, + vcpus: Option, + api_url: Option, + target: Option>, +} + fn shared_home_config_payload(config: &moltis_config::MoltisConfig) -> serde_json::Value { let runtime_cfg = moltis_tools::sandbox::SandboxConfig::from(&config.tools.exec.sandbox); let mode = match config.tools.exec.sandbox.home_persistence { @@ -694,7 +722,10 @@ pub async fn api_skills_search_handler( // ── Images ─────────────────────────────────────────────────────────────────── pub async fn api_cached_images_handler() -> impl IntoResponse { - let builder = moltis_tools::image_cache::DockerImageBuilder::new(); + let config = moltis_config::discover_and_load(); + let builder = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &config.tools.exec.sandbox.backend, + ); let (cached, sandbox) = tokio::join!( builder.list_cached(), moltis_tools::sandbox::list_sandbox_images(), @@ -741,7 +772,10 @@ pub async fn api_delete_cached_image_handler(Path(tag): Path) -> impl In let result = if tag.contains("-sandbox:") { moltis_tools::sandbox::remove_sandbox_image(&tag).await } else { - let builder = moltis_tools::image_cache::DockerImageBuilder::new(); + let cfg = moltis_config::discover_and_load(); + let builder = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &cfg.tools.exec.sandbox.backend, + ); let full_tag = if tag.starts_with("moltis-cache/") { tag } else { @@ -760,7 +794,10 @@ pub async fn api_delete_cached_image_handler(Path(tag): Path) -> impl In } pub async fn api_prune_cached_images_handler() -> impl IntoResponse { - let builder = moltis_tools::image_cache::DockerImageBuilder::new(); + let config = moltis_config::discover_and_load(); + let builder = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &config.tools.exec.sandbox.backend, + ); let (tool_result, sandbox_result) = tokio::join!( builder.prune_all(), moltis_tools::sandbox::clean_sandbox_images(), @@ -816,7 +853,11 @@ pub async fn api_check_packages_handler(Json(body): Json) -> .collect(); let script = checks.join("\n"); - let cli = moltis_tools::sandbox::container_cli(); + let config = moltis_config::discover_and_load(); + let cli = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &config.tools.exec.sandbox.backend, + ) + .cli_name(); let output = tokio::process::Command::new(cli) .args(["run", "--rm", "--entrypoint", "sh", &base, "-c", &script]) .stdout(std::process::Stdio::piped()) @@ -919,6 +960,185 @@ pub async fn api_set_shared_home_handler( } } +// ── Available sandbox backends ──────────────────────────────────────────────── + +/// Returns which sandbox backends are available/configured on this instance. +/// Used by the UI to populate backend selectors. +pub async fn api_available_backends_handler() -> impl IntoResponse { + let config = moltis_config::discover_and_load(); + let sb = &config.tools.exec.sandbox; + + let mut backends: Vec = Vec::new(); + + // Local container backends. + if moltis_tools::sandbox::is_cli_available("docker") { + backends.push(serde_json::json!({ + "id": "docker", + "label": "Docker", + "kind": "local", + "available": true, + })); + } + if moltis_tools::sandbox::is_cli_available("podman") { + backends.push(serde_json::json!({ + "id": "podman", + "label": "Podman", + "kind": "local", + "available": true, + })); + } + #[cfg(target_os = "macos")] + if moltis_tools::sandbox::is_cli_available("container") { + backends.push(serde_json::json!({ + "id": "apple-container", + "label": "Apple Container (VM)", + "kind": "local", + "available": true, + })); + } + #[cfg(target_os = "linux")] + if moltis_tools::sandbox::firecracker_bin_available(sb.firecracker_bin.as_deref()) { + backends.push(serde_json::json!({ + "id": "firecracker", + "label": "Firecracker (microVM)", + "kind": "local", + "available": true, + })); + } + + // Remote backends. + let has_vercel = configured_secret(&sb.vercel_token); + if has_vercel { + backends.push(serde_json::json!({ + "id": "vercel", + "label": "Vercel Sandbox (Firecracker)", + "kind": "remote", + "available": true, + })); + } + + let has_daytona = configured_secret(&sb.daytona_api_key); + if has_daytona { + backends.push(serde_json::json!({ + "id": "daytona", + "label": "Daytona (Cloud)", + "kind": "remote", + "available": true, + })); + } + + // Always include restricted-host as fallback. + backends.push(serde_json::json!({ + "id": "restricted-host", + "label": "Restricted Host (no isolation)", + "kind": "local", + "available": true, + })); + + Json(serde_json::json!({ + "backends": backends, + "default": sb.backend, + })) +} + +// ── Remote sandbox backend configuration ────────────────────────────────────── + +fn remote_backends_payload(config: &moltis_config::MoltisConfig) -> serde_json::Value { + let sb = &config.tools.exec.sandbox; + let vercel_configured = configured_secret(&sb.vercel_token); + let vercel_from_env = + std::env::var("VERCEL_TOKEN").is_ok() || std::env::var("VERCEL_OIDC_TOKEN").is_ok(); + let daytona_configured = configured_secret(&sb.daytona_api_key); + let daytona_from_env = std::env::var("DAYTONA_API_KEY").is_ok(); + serde_json::json!({ + "backend": sb.backend, + "vercel": { + "configured": vercel_configured, + "from_env": vercel_from_env, + "project_id": sb.vercel_project_id, + "team_id": sb.vercel_team_id, + "runtime": sb.vercel_runtime.as_deref().unwrap_or("node24"), + "timeout_ms": sb.vercel_timeout_ms.unwrap_or(300_000), + "vcpus": sb.vercel_vcpus.unwrap_or(2), + }, + "daytona": { + "configured": daytona_configured, + "from_env": daytona_from_env, + "api_url": sb.daytona_api_url.as_deref().unwrap_or("https://app.daytona.io/api"), + "target": sb.daytona_target, + }, + }) +} + +pub async fn api_get_remote_backends_handler() -> impl IntoResponse { + let config = moltis_config::discover_and_load(); + Json(remote_backends_payload(&config)) +} + +pub async fn api_set_remote_backend_handler( + Json(body): Json, +) -> impl IntoResponse { + let update_result = moltis_config::update_config(|cfg| { + let sb = &mut cfg.tools.exec.sandbox; + // Allow changing the default backend (auto/docker/podman/apple-container/vercel/daytona). + if let Some(v) = body.config.backend.as_deref() { + sb.backend = v.to_string(); + } + match body.backend.as_str() { + "vercel" => { + if let Some(v) = body.config.token.clone() { + sb.vercel_token = Some(v); + } + if let Some(v) = body.config.project_id.clone() { + sb.vercel_project_id = v; + } + if let Some(v) = body.config.team_id.clone() { + sb.vercel_team_id = v; + } + if let Some(v) = body.config.runtime.as_deref() { + sb.vercel_runtime = Some(v.to_string()); + } + if let Some(v) = body.config.timeout_ms { + sb.vercel_timeout_ms = Some(v); + } + if let Some(v) = body.config.vcpus { + sb.vercel_vcpus = Some(v as u32); + } + }, + "daytona" => { + if let Some(v) = body.config.api_key.clone() { + sb.daytona_api_key = Some(v); + } + if let Some(v) = body.config.api_url.as_deref() { + sb.daytona_api_url = Some(v.to_string()); + } + if let Some(v) = body.config.target.clone() { + sb.daytona_target = v; + } + }, + _ => {}, + } + }); + + match update_result { + Ok(saved_path) => { + let config = moltis_config::discover_and_load(); + Json(serde_json::json!({ + "ok": true, + "restart_required": true, + "config_path": saved_path.display().to_string(), + "config": remote_backends_payload(&config), + })) + .into_response() + }, + Err(e) => api_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "remote_backend_save_failed", + e.to_string(), + ), + } +} + pub async fn api_build_image_handler(Json(body): Json) -> impl IntoResponse { let name = body .get("name") @@ -995,16 +1215,43 @@ WORKDIR /home/sandbox\n" ); } - let builder = moltis_tools::image_cache::DockerImageBuilder::new(); + let config = moltis_config::discover_and_load(); + let builder = moltis_tools::image_cache::DockerImageBuilder::for_backend( + &config.tools.exec.sandbox.backend, + ); + tracing::debug!( + name, + cli = builder.cli_name(), + "starting image build via API" + ); let result = builder.ensure_image(name, &dockerfile_path, &tmp_dir).await; let _ = std::fs::remove_dir_all(&tmp_dir); match result { - Ok(tag) => Json(serde_json::json!({ "tag": tag })).into_response(), - Err(e) => api_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - SANDBOX_IMAGE_BUILD_FAILED, - e.to_string(), - ), + Ok(tag) => { + tracing::info!(name, tag, "image build succeeded via API"); + Json(serde_json::json!({ "tag": tag })).into_response() + }, + Err(e) => { + let detail = e.to_string(); + tracing::warn!(name, error = %detail, "image build failed via API"); + let message = if detail.contains("Cannot connect") + || detail.contains("connect to the Docker daemon") + || detail.contains("No such file or directory") + || detail.contains("failed to run docker") + || detail.contains("failed to run podman") + { + format!( + "Docker/Podman daemon is not available. Image building requires a running container runtime. Detail: {detail}" + ) + } else { + detail + }; + api_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + SANDBOX_IMAGE_BUILD_FAILED, + message, + ) + }, } } diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index e809b2436b..87191b2e26 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -106,6 +106,14 @@ fn build_api_routes() -> Router { "/api/sandbox/containers/{name}", axum::routing::delete(api::api_remove_container_handler), ) + .route( + "/api/sandbox/available-backends", + get(api::api_available_backends_handler), + ) + .route( + "/api/sandbox/remote-backends", + get(api::api_get_remote_backends_handler).put(api::api_set_remote_backend_handler), + ) .route("/api/sandbox/disk-usage", get(api::api_disk_usage_handler)) .route( "/api/sandbox/daemon/restart", diff --git a/crates/web/src/templates.rs b/crates/web/src/templates.rs index b406ec699a..25704d3e06 100644 --- a/crates/web/src/templates.rs +++ b/crates/web/src/templates.rs @@ -101,10 +101,11 @@ pub(crate) struct GonData { #[derive(serde::Serialize)] struct SandboxGonInfo { - backend: String, + backend: moltis_tools::sandbox::SandboxBackendId, os: &'static str, default_image: String, image_building: bool, + available_backends: Vec, } /// Memory snapshot included in gon data and tick broadcasts. @@ -468,8 +469,9 @@ pub(crate) async fn build_gon_data(gw: &GatewayState) -> GonData { ); let sandbox = if let Some(ref router) = gw.sandbox_router { + use moltis_tools::sandbox::SandboxBackendId; SandboxGonInfo { - backend: router.backend_name().to_owned(), + backend: SandboxBackendId::from_name(router.backend_name()), os: std::env::consts::OS, // Use resolve_default_image_nowait() to avoid blocking on a // sandbox image build — default_image() waits up to 10 minutes @@ -479,13 +481,20 @@ pub(crate) async fn build_gon_data(gw: &GatewayState) -> GonData { image_building: router .building_flag .load(std::sync::atomic::Ordering::Relaxed), + available_backends: router + .available_backends() + .into_iter() + .map(SandboxBackendId::from_name) + .collect(), } } else { + use moltis_tools::sandbox::SandboxBackendId; SandboxGonInfo { - backend: "none".to_owned(), + backend: SandboxBackendId::None, os: std::env::consts::OS, default_image: moltis_tools::sandbox::DEFAULT_SANDBOX_IMAGE.to_owned(), image_building: false, + available_backends: vec![SandboxBackendId::None], } }; diff --git a/crates/web/src/terminal/handlers.rs b/crates/web/src/terminal/handlers.rs index 3dc7d8d2e6..558b35372e 100644 --- a/crates/web/src/terminal/handlers.rs +++ b/crates/web/src/terminal/handlers.rs @@ -241,6 +241,9 @@ pub async fn api_terminal_ws_upgrade_handler( } let requested_window = query.window; - ws.on_upgrade(move |socket| handle_terminal_ws_connection(socket, addr, requested_window)) - .into_response() + let container_target = query.container; + ws.on_upgrade(move |socket| { + handle_terminal_ws_connection(socket, addr, requested_window, container_target) + }) + .into_response() } diff --git a/crates/web/src/terminal/pty.rs b/crates/web/src/terminal/pty.rs index ab26677c8c..e13c70fe03 100644 --- a/crates/web/src/terminal/pty.rs +++ b/crates/web/src/terminal/pty.rs @@ -15,6 +15,29 @@ use super::tmux::{ // ── Command builder ────────────────────────────────────────────────────────── +/// Build a command that opens a shell in the specified container via docker/podman exec. +fn container_terminal_command_builder(container_name: &str) -> CommandBuilder { + let config = moltis_config::discover_and_load(); + let cli: &str = match config.tools.exec.sandbox.backend.as_str() { + "apple-container" => "container", + "docker" => "docker", + "podman" => "podman", + _ => { + // Auto-detect: prefer `container` on macOS, then docker, then podman. + if cfg!(target_os = "macos") && moltis_tools::sandbox::is_cli_available("container") { + "container" + } else if moltis_tools::sandbox::is_cli_available("docker") { + "docker" + } else { + "podman" + } + }, + }; + let mut cmd = CommandBuilder::new(cli); + cmd.args(["exec", "-it", container_name, "bash"]); + cmd +} + fn host_terminal_command_builder(use_tmux_persistence: bool) -> CommandBuilder { if use_tmux_persistence { let mut cmd = CommandBuilder::new("tmux"); @@ -60,8 +83,12 @@ pub(crate) fn spawn_host_terminal_runtime( rows: u16, use_tmux_persistence: bool, tmux_window_target: Option<&str>, + container_target: Option<&str>, ) -> TerminalResult { - if use_tmux_persistence { + // If targeting a container, skip tmux and spawn docker/container exec directly. + let effective_tmux = use_tmux_persistence && container_target.is_none(); + + if effective_tmux { host_terminal_ensure_tmux_session()?; if let Some(target) = tmux_window_target { host_terminal_tmux_select_window(target)?; @@ -79,13 +106,17 @@ pub(crate) fn spawn_host_terminal_runtime( .map_err(|err| format!("failed to allocate host PTY: {err}"))?; let portable_pty::PtyPair { master, slave } = pair; - let cmd = host_terminal_command_builder(use_tmux_persistence); + let cmd = if let Some(container) = container_target { + container_terminal_command_builder(container) + } else { + host_terminal_command_builder(effective_tmux) + }; let child = slave .spawn_command(cmd) - .map_err(|err| format!("failed to spawn host shell: {err}"))?; + .map_err(|err| format!("failed to spawn terminal: {err}"))?; drop(slave); - if use_tmux_persistence { + if effective_tmux { host_terminal_apply_tmux_profile(); } diff --git a/crates/web/src/terminal/types.rs b/crates/web/src/terminal/types.rs index 0f07b8b5d8..e196043304 100644 --- a/crates/web/src/terminal/types.rs +++ b/crates/web/src/terminal/types.rs @@ -20,6 +20,8 @@ pub(crate) const TERMINAL_DISABLED: &str = "TERMINAL_DISABLED"; #[derive(Debug, Clone, Default, serde::Deserialize)] pub struct HostTerminalWsQuery { pub(crate) window: Option, + /// Optional container name to exec into instead of host shell. + pub(crate) container: Option, } #[derive(Debug, Clone, serde::Serialize)] diff --git a/crates/web/src/terminal/websocket.rs b/crates/web/src/terminal/websocket.rs index 80e793744a..a1a08adab4 100644 --- a/crates/web/src/terminal/websocket.rs +++ b/crates/web/src/terminal/websocket.rs @@ -79,6 +79,7 @@ pub(crate) async fn handle_terminal_ws_connection( socket: WebSocket, remote_addr: SocketAddr, requested_window: Option, + container_target: Option, ) { let conn_id = uuid::Uuid::new_v4().to_string(); info!(conn_id = %conn_id, remote = %remote_addr, "terminal ws: new connection"); @@ -156,6 +157,7 @@ pub(crate) async fn handle_terminal_ws_connection( current_rows, persistence_available, current_window_target.as_deref(), + container_target.as_deref(), ) { Ok(runtime) => runtime, Err(err) => { @@ -370,6 +372,7 @@ pub(crate) async fn handle_terminal_ws_connection( current_rows, persistence_available, current_window_target.as_deref(), + container_target.as_deref(), ) { Ok(next_runtime) => { runtime = next_runtime; diff --git a/crates/web/ui/e2e/specs/remote-sandbox-backends.spec.js b/crates/web/ui/e2e/specs/remote-sandbox-backends.spec.js new file mode 100644 index 0000000000..72c01db7c4 --- /dev/null +++ b/crates/web/ui/e2e/specs/remote-sandbox-backends.spec.js @@ -0,0 +1,266 @@ +const { expect, test } = require("../base-test"); +const { navigateAndWait, watchPageErrors } = require("../helpers"); + +test.describe("Remote sandbox backend configuration", () => { + test.beforeEach(async ({ page }) => { + // Mock the GET endpoint to simulate no backends configured initially. + await page.route("**/api/sandbox/remote-backends", (route, request) => { + if (request.method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + vercel: { configured: false, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }), + }); + } + return route.continue(); + }); + }); + + test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }).catch(() => {}); + }); + + test("remote backends section is visible on sandbox settings page", async ({ page }) => { + const pageErrors = watchPageErrors(page); + await navigateAndWait(page, "/settings/sandboxes"); + + await expect(page.getByText("Remote sandbox backends")).toBeVisible(); + await expect(page.getByText("Vercel Sandbox")).toBeVisible(); + await expect(page.getByText("Daytona")).toBeVisible(); + expect(pageErrors).toEqual([]); + }); + + test("shows not-configured badges when no credentials are set", async ({ page }) => { + const pageErrors = watchPageErrors(page); + await navigateAndWait(page, "/settings/sandboxes"); + + const badges = page.getByText("not configured"); + await expect(badges.first()).toBeVisible(); + expect(pageErrors).toEqual([]); + }); + + test("saving Vercel token shows success message and configured badge", async ({ page }) => { + const pageErrors = watchPageErrors(page); + let savedBody = null; + + await page.route("**/api/sandbox/remote-backends", (route, request) => { + if (request.method() === "PUT") { + savedBody = request.postDataJSON(); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + restart_required: true, + config_path: "/test/moltis.toml", + config: { + vercel: { configured: true, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }, + }), + }); + } + if (request.method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + vercel: { configured: false, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }), + }); + } + return route.continue(); + }); + + await navigateAndWait(page, "/settings/sandboxes"); + + // Fill Vercel token + const tokenInput = page.locator('input[placeholder*="Vercel token"]'); + await tokenInput.fill("ver_test_token_12345"); + + // Click save + const saveBtn = page.getByRole("button", { name: "Save Vercel", exact: true }); + await expect(saveBtn).toBeEnabled(); + await saveBtn.click(); + + // Verify success message + await expect(page.getByText("vercel configuration saved")).toBeVisible({ timeout: 5000 }); + + // Verify configured badge appears + await expect(page.getByText("configured").first()).toBeVisible(); + + // Verify the request was sent correctly + expect(savedBody).not.toBeNull(); + expect(savedBody.backend).toBe("vercel"); + expect(savedBody.config.token).toBe("ver_test_token_12345"); + + expect(pageErrors).toEqual([]); + }); + + test("saving Daytona API key shows success message", async ({ page }) => { + const pageErrors = watchPageErrors(page); + let savedBody = null; + + await page.route("**/api/sandbox/remote-backends", (route, request) => { + if (request.method() === "PUT") { + savedBody = request.postDataJSON(); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + restart_required: true, + config_path: "/test/moltis.toml", + config: { + vercel: { configured: false, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: true, api_url: "https://app.daytona.io/api" }, + }, + }), + }); + } + if (request.method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + vercel: { configured: false, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }), + }); + } + return route.continue(); + }); + + await navigateAndWait(page, "/settings/sandboxes"); + + // Fill Daytona API key + const keyInput = page.locator('input[placeholder*="Daytona API key"]'); + await keyInput.fill("dyt_test_key_67890"); + + // Click save + const saveBtn = page.getByRole("button", { name: "Save Daytona", exact: true }); + await expect(saveBtn).toBeEnabled(); + await saveBtn.click(); + + // Verify success message + await expect(page.getByText("daytona configuration saved")).toBeVisible({ timeout: 5000 }); + + // Verify request + expect(savedBody).not.toBeNull(); + expect(savedBody.backend).toBe("daytona"); + expect(savedBody.config.api_key).toBe("dyt_test_key_67890"); + + expect(pageErrors).toEqual([]); + }); + + test("save button is disabled when token field is empty", async ({ page }) => { + const pageErrors = watchPageErrors(page); + await navigateAndWait(page, "/settings/sandboxes"); + + // Daytona save button should be disabled without API key + const daytonaSave = page.getByRole("button", { name: "Save Daytona", exact: true }); + await expect(daytonaSave).toBeDisabled(); + + expect(pageErrors).toEqual([]); + }); + + test("API error displays error message", async ({ page }) => { + const pageErrors = watchPageErrors(page); + + await page.route("**/api/sandbox/remote-backends", (route, request) => { + if (request.method() === "PUT") { + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ code: "save_failed", error: "Permission denied" }), + }); + } + if (request.method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + vercel: { configured: false, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }), + }); + } + return route.continue(); + }); + + await navigateAndWait(page, "/settings/sandboxes"); + + const tokenInput = page.locator('input[placeholder*="Vercel token"]'); + await tokenInput.fill("ver_will_fail"); + + const saveBtn = page.getByRole("button", { name: "Save Vercel", exact: true }); + await saveBtn.click(); + + // Verify error message is shown + await expect(page.locator(".alert-error-text")).toBeVisible({ timeout: 5000 }); + + expect(pageErrors).toEqual([]); + }); + + test("Vercel project ID and team ID are sent with save", async ({ page }) => { + const pageErrors = watchPageErrors(page); + let savedBody = null; + + await page.route("**/api/sandbox/remote-backends", (route, request) => { + if (request.method() === "PUT") { + savedBody = request.postDataJSON(); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + restart_required: true, + config_path: "/test/moltis.toml", + config: { + vercel: { + configured: true, + project_id: "prj_123", + team_id: "team_456", + runtime: "node24", + timeout_ms: 300000, + vcpus: 2, + }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }, + }), + }); + } + if (request.method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + vercel: { configured: false, runtime: "node24", timeout_ms: 300000, vcpus: 2 }, + daytona: { configured: false, api_url: "https://app.daytona.io/api" }, + }), + }); + } + return route.continue(); + }); + + await navigateAndWait(page, "/settings/sandboxes"); + + // Fill all Vercel fields + await page.locator('input[placeholder*="Vercel token"]').fill("ver_abc"); + await page.locator('input[placeholder*="Project ID"]').fill("prj_123"); + await page.locator('input[placeholder*="Team ID"]').fill("team_456"); + + await page.getByRole("button", { name: "Save Vercel", exact: true }).click(); + await expect(page.getByText("vercel configuration saved")).toBeVisible({ timeout: 5000 }); + + expect(savedBody.config.token).toBe("ver_abc"); + expect(savedBody.config.project_id).toBe("prj_123"); + expect(savedBody.config.team_id).toBe("team_456"); + + expect(pageErrors).toEqual([]); + }); +}); diff --git a/crates/web/ui/e2e/specs/remote-sandbox-live.spec.js b/crates/web/ui/e2e/specs/remote-sandbox-live.spec.js new file mode 100644 index 0000000000..3310a53092 --- /dev/null +++ b/crates/web/ui/e2e/specs/remote-sandbox-live.spec.js @@ -0,0 +1,278 @@ +/** + * Live integration tests for remote sandbox backends. + * + * These tests create real sandboxes via the Vercel/Daytona APIs and require + * credentials in the environment. They are skipped when credentials are absent. + * + * Required secrets (GitHub Actions): + * VERCEL_TOKEN — Vercel access token (ver_...) + * VERCEL_TEAM_ID — Vercel team ID (team_...) + * VERCEL_PROJECT_ID — Vercel project ID (prj_...) + * DAYTONA_API_KEY — Daytona API key (optional, for Daytona tests) + * + * Run locally: + * VERCEL_TOKEN=ver_xxx VERCEL_TEAM_ID=team_xxx VERCEL_PROJECT_ID=prj_xxx npx playwright test e2e/specs/remote-sandbox-live.spec.js + */ + +const { test, expect } = require("../base-test"); + +const VERCEL_TOKEN = process.env.VERCEL_TOKEN; +const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID; +const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID; +const DAYTONA_API_KEY = process.env.DAYTONA_API_KEY; + +const VERCEL_API = "https://vercel.com/api"; + +test.describe("Vercel Sandbox live integration", () => { + test.skip( + !(VERCEL_TOKEN && VERCEL_PROJECT_ID), + "VERCEL_TOKEN or VERCEL_PROJECT_ID not set — skipping live Vercel tests", + ); + + let sandboxId = null; + + test.afterEach(async () => { + // Clean up: stop the sandbox if one was created. + if (sandboxId) { + const url = `${VERCEL_API}/v1/sandboxes/${sandboxId}/stop${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + }).catch(() => {}); + sandboxId = null; + } + }); + + test("create sandbox, run command, and stop", async () => { + // Create a sandbox. + const createUrl = `${VERCEL_API}/v1/sandboxes${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const createResp = await fetch(createUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + projectId: VERCEL_PROJECT_ID, + runtime: "node24", + timeout: 60000, + resources: { vcpus: 1 }, + }), + }); + + expect(createResp.status).toBe(200); + const createData = await createResp.json(); + sandboxId = createData.sandbox?.id; + expect(sandboxId).toBeTruthy(); + + // Wait for sandbox to reach running state. + const deadline = Date.now() + 60000; + let status = ""; + while (Date.now() < deadline) { + const getUrl = `${VERCEL_API}/v1/sandboxes/${sandboxId}${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const getResp = await fetch(getUrl, { + headers: { Authorization: `Bearer ${VERCEL_TOKEN}` }, + }); + const getData = await getResp.json(); + status = getData.sandbox?.status; + if (status === "running") break; + if (status === "failed" || status === "stopped") { + throw new Error(`Sandbox entered terminal state: ${status}`); + } + await new Promise((r) => setTimeout(r, 1000)); + } + expect(status).toBe("running"); + + // Execute a command. + const cmdUrl = `${VERCEL_API}/v1/sandboxes/${sandboxId}/cmd${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const cmdResp = await fetch(cmdUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + command: "echo", + args: ["-n", "hello-moltis"], + cwd: "/vercel/sandbox", + wait: true, + }), + }); + + expect(cmdResp.status).toBe(200); + const cmdText = await cmdResp.text(); + // NDJSON response — last line has the exit code. + const lines = cmdText.trim().split("\n").filter(Boolean); + const lastLine = JSON.parse(lines[lines.length - 1]); + expect(lastLine.command.exitCode).toBe(0); + + // Fetch command logs to verify output. + const cmdId = lastLine.command.id; + const logsUrl = `${VERCEL_API}/v1/sandboxes/${sandboxId}/cmd/${cmdId}/logs${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const logsResp = await fetch(logsUrl, { + headers: { Authorization: `Bearer ${VERCEL_TOKEN}` }, + }); + const logsText = await logsResp.text(); + expect(logsText).toContain("hello-moltis"); + + // Stop the sandbox. + const stopUrl = `${VERCEL_API}/v1/sandboxes/${sandboxId}/stop${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const stopResp = await fetch(stopUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + }); + expect(stopResp.status).toBe(200); + sandboxId = null; // already stopped + }); + + test("write and read file in sandbox", async () => { + // Create sandbox. + const createUrl = `${VERCEL_API}/v1/sandboxes${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const createResp = await fetch(createUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + projectId: VERCEL_PROJECT_ID, + runtime: "node24", + timeout: 60000, + resources: { vcpus: 1 }, + }), + }); + const createData = await createResp.json(); + sandboxId = createData.sandbox?.id; + expect(sandboxId).toBeTruthy(); + + // Wait for running. + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + const getResp = await fetch( + `${VERCEL_API}/v1/sandboxes/${sandboxId}${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`, + { headers: { Authorization: `Bearer ${VERCEL_TOKEN}` } }, + ); + const getData = await getResp.json(); + if (getData.sandbox?.status === "running") break; + await new Promise((r) => setTimeout(r, 1000)); + } + + // Write a file using the command API (simpler than gzipped tar for test). + const writeCmd = `${VERCEL_API}/v1/sandboxes/${sandboxId}/cmd${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const writeResp = await fetch(writeCmd, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + command: "sh", + args: ["-c", "echo 'moltis-test-content' > /tmp/test-file.txt"], + cwd: "/vercel/sandbox", + wait: true, + }), + }); + expect(writeResp.status).toBe(200); + + // Read the file back. + const readUrl = `${VERCEL_API}/v1/sandboxes/${sandboxId}/fs/read${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`; + const readResp = await fetch(readUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path: "/tmp/test-file.txt" }), + }); + expect(readResp.status).toBe(200); + const content = await readResp.text(); + expect(content.trim()).toBe("moltis-test-content"); + }); +}); + +test.describe("Daytona Sandbox live integration", () => { + test.skip(!DAYTONA_API_KEY, "DAYTONA_API_KEY not set — skipping live Daytona tests"); + + const DAYTONA_API = process.env.DAYTONA_API_URL || "https://app.daytona.io/api"; + let sandboxId = null; + + test.afterEach(async () => { + if (sandboxId) { + await fetch(`${DAYTONA_API}/sandbox/${sandboxId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${DAYTONA_API_KEY}`, + "X-Daytona-Source": "moltis-e2e", + }, + }).catch(() => {}); + sandboxId = null; + } + }); + + test("create sandbox and execute command", { timeout: 120_000 }, async () => { + // Create a sandbox (retry on transient 5xx errors from Daytona). + let createResp; + for (let attempt = 0; attempt < 3; attempt++) { + createResp = await fetch(`${DAYTONA_API}/sandbox`, { + method: "POST", + headers: { + Authorization: `Bearer ${DAYTONA_API_KEY}`, + "Content-Type": "application/json", + "X-Daytona-Source": "moltis-e2e", + }, + body: JSON.stringify({}), + }); + if (createResp.status < 500) break; + await new Promise((r) => setTimeout(r, 3000)); + } + + expect(createResp.status).toBeLessThan(300); + const createData = await createResp.json(); + sandboxId = createData.id; + expect(sandboxId).toBeTruthy(); + + // Wait for sandbox toolbox to become available (may take a few seconds after creation). + const execDeadline = Date.now() + 60000; + let execResp; + while (Date.now() < execDeadline) { + execResp = await fetch(`${DAYTONA_API}/toolbox/${sandboxId}/toolbox/process/execute`, { + method: "POST", + headers: { + Authorization: `Bearer ${DAYTONA_API_KEY}`, + "Content-Type": "application/json", + "X-Daytona-Source": "moltis-e2e", + }, + body: JSON.stringify({ + command: "echo hello-from-daytona", + cwd: "/home/daytona", + timeout: 30, + }), + }); + if (execResp.status === 200) break; + // 404 or 503 means toolbox not ready yet. + await new Promise((r) => setTimeout(r, 2000)); + } + + expect(execResp.status).toBe(200); + const execData = await execResp.json(); + expect(execData.exitCode).toBe(0); + expect(execData.result).toContain("hello-from-daytona"); + + // Delete sandbox. + const deleteResp = await fetch(`${DAYTONA_API}/sandbox/${sandboxId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${DAYTONA_API_KEY}`, + "X-Daytona-Source": "moltis-e2e", + }, + }); + expect(deleteResp.status).toBeLessThan(300); + sandboxId = null; + }); +}); diff --git a/crates/web/ui/playwright.config.cjs b/crates/web/ui/playwright.config.cjs index 58790ed55f..7d425775da 100644 --- a/crates/web/ui/playwright.config.cjs +++ b/crates/web/ui/playwright.config.cjs @@ -64,6 +64,7 @@ function defaultSpecFiles() { /openai-live\.spec\.js$/, /ollama-qwen-live\.spec\.js$/, /oauth\.spec\.js$/, + /remote-sandbox-live\.spec\.js$/, ]; return readdirSync(path.join(__dirname, "e2e/specs")) .filter((file) => file.endsWith(".spec.js") && !ignored.some((pattern) => pattern.test(file))) @@ -165,6 +166,7 @@ const defaultProjectIgnore = [ /openai-live\.spec/, /ollama-qwen-live\.spec/, /oauth\.spec/, + /remote-sandbox-live\.spec/, ]; const defaultProjects = (() => { if (skipDefaultProjects || !includeProject("default")) return []; @@ -281,6 +283,13 @@ if (ollamaQwenLiveEnabled && includeProject("ollama-qwen-live")) { }); } +if (includeProject("remote-sandbox-live")) { + projects.push({ + name: "remote-sandbox-live", + testMatch: /remote-sandbox-live\.spec/, + }); +} + function gatewayServer({ baseURL: serverBaseURL, name, port: serverPort }) { return { command: "./e2e/start-gateway.sh", diff --git a/crates/web/ui/src/pages/ImagesPage.tsx b/crates/web/ui/src/pages/ImagesPage.tsx index 8864ed3dee..8798cbb7d1 100644 --- a/crates/web/ui/src/pages/ImagesPage.tsx +++ b/crates/web/ui/src/pages/ImagesPage.tsx @@ -4,9 +4,11 @@ import { signal } from "@preact/signals"; import type { VNode } from "preact"; import { render } from "preact"; import { useEffect } from "preact/hooks"; +import { TabBar } from "../components/forms/Tabs"; import { localizedApiErrorMessage } from "../helpers"; import { updateNavCount } from "../nav-counts"; import { sandboxInfo } from "../signals"; +import type { SandboxGonInfo } from "../types/gon"; // ── Types ──────────────────────────────────────────────────── @@ -36,10 +38,7 @@ interface DiskUsageInfo { images_size_bytes: number; } -interface SandboxInfoValue { - backend: string; - os: string; - default_image?: string; +interface SandboxInfoValue extends SandboxGonInfo { shared_home_enabled?: boolean; shared_home_dir?: string; } @@ -51,6 +50,24 @@ interface SharedHomeConfig { configured_path?: string; } +interface RemoteBackendsConfig { + vercel: { + configured: boolean; + from_env?: boolean; + project_id?: string; + team_id?: string; + runtime: string; + timeout_ms: number; + vcpus: number; + }; + daytona: { + configured: boolean; + from_env?: boolean; + api_url: string; + target?: string; + }; +} + // ── Signals ────────────────────────────────────────────────── const defaultImage = signal(""); @@ -78,8 +95,25 @@ const sharedHomeLoading = signal(false); const sharedHomeSaving = signal(false); const sharedHomeMsg = signal(""); const sharedHomeErr = signal(""); +const remoteConfig = signal(null); +const remoteLoading = signal(false); +const remoteSaving = signal(""); +const remoteMsg = signal(""); +const remoteErr = signal(""); +const vercelToken = signal(""); +const vercelProjectId = signal(""); +const vercelTeamId = signal(""); +const daytonaApiKey = signal(""); +const daytonaApiUrl = signal(""); +const activeTab = signal("general"); +const SANDBOX_TABS = [ + { id: "general", label: "General" }, + { id: "vercel", label: "Vercel" }, + { id: "daytona", label: "Daytona" }, + { id: "containers", label: "Containers & Images" }, +]; const SANDBOX_DISABLED_HINT = - "Sandboxes are disabled on cloud deploys without a container runtime. Install on a VM with Docker or Apple Container to enable this feature."; + "No local container runtime detected. Install Docker, configure a remote backend (Vercel or Daytona), or deploy on a VM with Docker to enable sandboxes."; function sandboxRuntimeAvailable(): boolean { return ((sandboxInfo.value as SandboxInfoValue | null)?.backend || "none") !== "none"; @@ -370,6 +404,57 @@ function saveSharedHomeConfig(): void { }); } +function fetchRemoteBackends(): void { + remoteLoading.value = true; + remoteErr.value = ""; + fetch("/api/sandbox/remote-backends") + .then(async (r) => { + if (!r.ok) throw new Error(await responseErrorMessage(r, "Failed to load remote backend config.")); + return r.json() as Promise; + }) + .then((data) => { + remoteConfig.value = data; + vercelProjectId.value = data.vercel?.project_id || ""; + vercelTeamId.value = data.vercel?.team_id || ""; + daytonaApiUrl.value = data.daytona?.api_url || "https://app.daytona.io/api"; + }) + .catch((e: Error) => { + remoteErr.value = e.message; + }) + .finally(() => { + remoteLoading.value = false; + }); +} + +function saveRemoteBackend(backend: string, config: Record): void { + remoteSaving.value = backend; + remoteErr.value = ""; + remoteMsg.value = ""; + fetch("/api/sandbox/remote-backends", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ backend, config }), + }) + .then(async (r) => { + if (!r.ok) throw new Error(await responseErrorMessage(r, "Failed to save remote backend config.")); + return r.json(); + }) + .then((data) => { + if (data?.config) remoteConfig.value = data.config; + if (backend !== "_global") { + remoteMsg.value = `${backend} configuration saved. Restart Moltis to apply.`; + vercelToken.value = ""; + daytonaApiKey.value = ""; + } + }) + .catch((e: Error) => { + remoteErr.value = e.message; + }) + .finally(() => { + remoteSaving.value = ""; + }); +} + function formatBytes(bytes: number | null | undefined): string { if (bytes == null) return "\u2014"; if (bytes < 1024) return `${bytes} B`; @@ -540,15 +625,6 @@ function RunningContainersSection(): VNode { ); } -const BACKEND_LABELS: Record = { - "apple-container": "Apple Container (VM-isolated)", - docker: "Docker", - cgroup: "cgroup (systemd-run)", - "restricted-host": "Restricted Host (env + rlimits)", - wasm: "Wasmtime (WASM-isolated)", - none: "None (host execution)", -}; - function backendRecommendation(info: SandboxInfoValue | null): { level: string; text: string; link?: string } | null { if (!info) return null; const os = info.os; @@ -599,34 +675,84 @@ function backendRecommendation(info: SandboxInfoValue | null): { level: string; return null; } +interface AvailableBackendInfo { + id: string; + label: string; + kind: string; + available: boolean; +} +const availableBackendsList = signal([]); +const defaultBackendId = signal("auto"); +const backendSaving = signal(false); + +function fetchAvailableBackends(): void { + fetch("/api/sandbox/available-backends") + .then((r) => r.json()) + .then((data) => { + availableBackendsList.value = data.backends || []; + defaultBackendId.value = data.default || "auto"; + }) + .catch(() => {}); +} + function SandboxBanner(): VNode | null { const info = sandboxInfo.value as SandboxInfoValue | null; if (!info) return null; - const label = BACKEND_LABELS[info.backend] || info.backend; + const backends = availableBackendsList.value; const rec = backendRecommendation(info); - const badgeColor = - info.backend === "none" - ? "var(--error)" - : info.backend === "apple-container" - ? "var(--accent)" - : info.backend === "wasm" - ? "var(--success)" - : info.backend === "restricted-host" - ? "var(--warning, var(--muted))" - : "var(--muted)"; + function changeDefault(backendId: string): void { + backendSaving.value = true; + saveRemoteBackend("_global", { backend: backendId }); + defaultBackendId.value = backendId; + setTimeout(() => { + backendSaving.value = false; + }, 1500); + } return (
-
- - Container backend: - - {label} - - -
+

+ Available backends +

+

+ Backends available for sandbox execution. Select one per session in the chat panel, or set a default below. +

+ + {backends.length > 0 ? ( +
+ {backends.map((b) => { + const isDefault = + b.id === defaultBackendId.value || (defaultBackendId.value === "auto" && b.id === info.backend); + return ( + + ); + })} +
+ ) : ( +
+ Loading backends... +
+ )} + {rec && (
@@ -824,22 +950,251 @@ function ImagesPage(): VNode { fetchContainers(); fetchDiskUsage(); fetchSharedHomeConfig(); + fetchRemoteBackends(); + fetchAvailableBackends(); }, []); - const sbInfo = sandboxInfo.value as SandboxInfoValue | null; + return ( +
+
+

+ Sandboxes +

+ {!sandboxRuntimeAvailable() && ( +
+ Warning: + {SANDBOX_DISABLED_HINT} +
+ )} +
+ { + activeTab.value = id; + }} + className="flex border-b border-[var(--border)] text-xs px-4" + /> +
+ {activeTab.value === "general" && } + {activeTab.value === "vercel" && } + {activeTab.value === "daytona" && } + {activeTab.value === "containers" && } +
+
+ ); +} + +function GeneralTabContent(): VNode { + return ( + <> + + + + + ); +} + +function VercelTabContent(): VNode { + const cfg = remoteConfig.value; + + function saveVercel(): void { + const config: Record = {}; + if (vercelToken.value.trim()) config.token = vercelToken.value.trim(); + if (vercelProjectId.value.trim()) config.project_id = vercelProjectId.value.trim(); + if (vercelTeamId.value.trim()) config.team_id = vercelTeamId.value.trim(); + saveRemoteBackend("vercel", config); + } + + return ( +
+
+

Vercel Sandbox

+ {cfg?.vercel?.configured ? ( + + configured + + ) : ( + + not configured + + )} +
+

+ Firecracker microVMs via the Vercel API. Each session gets an ephemeral isolated VM with millisecond boot times. +

+
+ { + vercelToken.value = (e.target as HTMLInputElement).value; + }} + /> + {cfg?.vercel?.from_env && ( +
+ Token managed by environment variable. Remove VERCEL_TOKEN from env to configure here. +
+ )} +
+ { + vercelProjectId.value = (e.target as HTMLInputElement).value; + }} + /> + { + vercelTeamId.value = (e.target as HTMLInputElement).value; + }} + /> +
+ +
+ {remoteMsg.value && remoteMsg.value.includes("vercel") && ( +
+ {remoteMsg.value} +
+ )} + {remoteErr.value && ( +
+ {remoteErr.value} +
+ )} +
+ ); +} + +function DaytonaTabContent(): VNode { + const cfg = remoteConfig.value; + + function saveDaytona(): void { + const config: Record = {}; + if (daytonaApiKey.value.trim()) config.api_key = daytonaApiKey.value.trim(); + if (daytonaApiUrl.value.trim()) config.api_url = daytonaApiUrl.value.trim(); + saveRemoteBackend("daytona", config); + } return ( -
- {!sandboxRuntimeAvailable() && ( -
- Warning: - {SANDBOX_DISABLED_HINT} +
+
+

Daytona

+ {cfg?.daytona?.configured ? ( + + configured + + ) : ( + + not configured + + )} +
+

+ Open-source cloud sandboxes. Self-hostable on your own infrastructure (Proxmox, bare-metal) or use the managed + Daytona service. +

+
+ { + daytonaApiKey.value = (e.target as HTMLInputElement).value; + }} + /> + {cfg?.daytona?.from_env && ( +
+ Token managed by environment variable. Remove DAYTONA_API_KEY from env to configure here. +
+ )} + { + daytonaApiUrl.value = (e.target as HTMLInputElement).value; + }} + /> + +
+ {remoteMsg.value && remoteMsg.value.includes("daytona") && ( +
+ {remoteMsg.value} +
+ )} + {remoteErr.value && ( +
+ {remoteErr.value}
)} +
+ ); +} + +function ContainersTabContent(): VNode { + const sbInfo = sandboxInfo.value as SandboxInfoValue | null; + + return ( + <>
-

Sandboxes

-

- Container images cached by moltis for sandbox execution. You can delete individual images or prune all. Build - custom images from a base with apt packages. - {sbInfo?.backend === "apple-container" && ( - <> -
-
- Apple Container provides VM-isolated execution but does not support building images. Docker (or OrbStack) is - required alongside Apple Container to build and cache custom images. Sandboxed commands run via Apple - Container; image builds use Docker. - - )} -

- - + {sbInfo?.backend === "apple-container" && ( +

+ Apple Container provides VM-isolated execution but does not support building images. Docker (or OrbStack) is + required alongside Apple Container to build and cache custom images. +

+ )} - - {/* Cached images list */}
@@ -964,7 +1308,7 @@ function ImagesPage(): VNode {
))}
-
+ ); } @@ -988,6 +1332,18 @@ export function initImages(container: HTMLElement): void { sharedHomeSaving.value = false; sharedHomeMsg.value = ""; sharedHomeErr.value = ""; + activeTab.value = "general"; + availableBackendsList.value = []; + remoteConfig.value = null; + remoteLoading.value = false; + remoteSaving.value = ""; + remoteMsg.value = ""; + remoteErr.value = ""; + vercelToken.value = ""; + vercelProjectId.value = ""; + vercelTeamId.value = ""; + daytonaApiKey.value = ""; + daytonaApiUrl.value = ""; render(, container); } diff --git a/crates/web/ui/src/pages/TerminalPage.tsx b/crates/web/ui/src/pages/TerminalPage.tsx index 5f71d653df..34b518e534 100644 --- a/crates/web/ui/src/pages/TerminalPage.tsx +++ b/crates/web/ui/src/pages/TerminalPage.tsx @@ -134,6 +134,8 @@ let FitAddonCtorRef: FitAddonCtorType | null = null; let oscHandlerDisposables: { dispose: () => void }[] = []; let terminalAvailable = false; +let selectedContainer: string | null = null; // null = host, "container-name" = exec into container +let targetSelectorEl: HTMLSelectElement | null = null; let lastSentCols = 0; let lastSentRows = 0; let tmuxInstallCommand = ""; @@ -776,8 +778,11 @@ function connectTerminalSocket(): void { lastSentRows = 0; const proto = location.protocol === "https:" ? "wss:" : "ws:"; let wsUrl = `${proto}//${location.host}/api/terminal/ws`; + const params: string[] = []; const tw = pendingWindowId || activeWindowId; - if (tmuxPersistenceEnabled && tw) wsUrl += `?window=${encodeURIComponent(tw)}`; + if (tmuxPersistenceEnabled && tw) params.push(`window=${encodeURIComponent(tw)}`); + if (selectedContainer) params.push(`container=${encodeURIComponent(selectedContainer)}`); + if (params.length > 0) wsUrl += `?${params.join("&")}`; socket = new WebSocket(wsUrl); setStatus("Connecting terminal websocket..."); socket.onopen = () => setStatus("Terminal websocket connected.", "ok"); @@ -839,6 +844,30 @@ function bindEvents(): void { }); } +// ── Target selector (host vs container) ────────────────────── + +function populateTargetSelector(): void { + if (!targetSelectorEl) return; + // Keep existing "Host" option, add running containers. + fetch("/api/sandbox/containers") + .then((r) => r.json()) + .then((data) => { + const containers: { name: string; state: string; backend: string }[] = data.containers || []; + const running = containers.filter((c) => c.state === "running"); + // Remove old container options (keep first "Host" option). + while (targetSelectorEl!.options.length > 1) { + targetSelectorEl!.remove(1); + } + for (const c of running) { + const opt = document.createElement("option"); + opt.value = c.name; + opt.textContent = `\u{1F4E6} ${c.name}`; + targetSelectorEl!.appendChild(opt); + } + }) + .catch(() => {}); +} + // Static HTML template for terminal page layout. No user input is interpolated. function buildTerminalHtml(): string { return [ @@ -855,6 +884,9 @@ function buildTerminalHtml(): string { '', "
", '
', + '", '
', '', "
", @@ -897,6 +929,16 @@ export async function initTerminal(container: HTMLElement): Promise { restartBtn = container.querySelector("#terminalRestart"); installTmuxBtn = container.querySelector("#terminalInstallTmux"); copyInstallBtn = container.querySelector("#terminalCopyInstall"); + targetSelectorEl = container.querySelector("#terminalTarget"); + + // Populate container selector and bind change event. + if (targetSelectorEl) { + targetSelectorEl.addEventListener("change", () => { + selectedContainer = targetSelectorEl!.value || null; + connectTerminalSocket(); + }); + populateTargetSelector(); + } setStatus("Initializing terminal..."); setControlsEnabled(false); diff --git a/crates/web/ui/src/sandbox.ts b/crates/web/ui/src/sandbox.ts index ed45cfde80..ff1af2833a 100644 --- a/crates/web/ui/src/sandbox.ts +++ b/crates/web/ui/src/sandbox.ts @@ -235,33 +235,92 @@ function populateImageDropdown(): void { if (!dropdown) return; dropdown.textContent = ""; - // Default option - addImageOption(dropdown, DEFAULT_IMAGE, !S.sessionSandboxImage); - - // Fetch cached images + // Fetch available backends and images in parallel. + interface AvailableBackend { + id: string; + label: string; + kind: string; + } + interface BackendsResponse { + backends?: AvailableBackend[]; + default?: string; + } interface CachedImage { tag: string; skill_name?: string; size?: string; } - interface CachedImagesResponse { images?: CachedImage[]; } - fetch("/api/images/cached") - .then((r) => r.json()) - .then((data: CachedImagesResponse) => { - const images = data.images || []; - for (const img of images) { - const isCurrent = S.sessionSandboxImage === img.tag; - addImageOption(dropdown, img.tag, isCurrent, `${img.skill_name} (${img.size})`); + Promise.all([ + fetch("/api/sandbox/available-backends") + .then((r) => r.json()) + .catch(() => ({ backends: [] })), + fetch("/api/images/cached") + .then((r) => r.json()) + .catch(() => ({ images: [] })), + ]).then(([backendsData, imagesData]: [BackendsResponse, CachedImagesResponse]) => { + const backends = backendsData.backends || []; + const images = imagesData.images || []; + + // Backend section header. + if (backends.length > 0) { + const header = document.createElement("div"); + header.className = "px-3 py-1 text-[10px] font-medium text-[var(--muted)] uppercase tracking-wider"; + header.textContent = "Backend"; + dropdown.appendChild(header); + + for (const b of backends) { + const isCurrent = S.sessionSandboxBackend === b.id; + const opt = document.createElement("div"); + opt.className = + "px-3 py-1.5 text-xs cursor-pointer hover:bg-[var(--surface2)] transition-colors flex items-center gap-2"; + if (isCurrent) { + opt.style.color = "var(--accent, #f59e0b)"; + opt.style.fontWeight = "600"; + } + const kindBadge = b.kind === "remote" ? " \u2601" : ""; + opt.textContent = `${b.label}${kindBadge}`; + opt.addEventListener("click", (e: MouseEvent): void => { + e.stopPropagation(); + selectBackend(b.id); + }); + dropdown.appendChild(opt); } - requestAnimationFrame(positionImageDropdown); - }) - .catch(() => { - // Silently ignore fetch errors for image list - }); + } + + // Image section (only relevant for container backends). + const divider = document.createElement("div"); + divider.className = "border-t border-[var(--border)] my-1"; + dropdown.appendChild(divider); + + const imgHeader = document.createElement("div"); + imgHeader.className = "px-3 py-1 text-[10px] font-medium text-[var(--muted)] uppercase tracking-wider"; + imgHeader.textContent = "Image"; + dropdown.appendChild(imgHeader); + + addImageOption(dropdown, DEFAULT_IMAGE, !S.sessionSandboxImage); + for (const img of images) { + const isCurrent = S.sessionSandboxImage === img.tag; + addImageOption(dropdown, img.tag, isCurrent, `${img.skill_name} (${img.size})`); + } + + requestAnimationFrame(positionImageDropdown); + }); +} + +function selectBackend(backendId: string): void { + sendRpc("sessions.patch", { + key: S.activeSessionKey, + sandboxBackend: backendId, + }); + S.setSessionSandboxBackend(backendId); + const dropdown = S.sandboxImageDropdown; + if (dropdown) { + dropdown.classList.add("hidden"); + } } function addImageOption(dropdown: HTMLElement, tag: string, isActive: boolean, subtitle?: string): void { diff --git a/crates/web/ui/src/sessions/session-switch.ts b/crates/web/ui/src/sessions/session-switch.ts index 92141698ad..a6582682bf 100644 --- a/crates/web/ui/src/sessions/session-switch.ts +++ b/crates/web/ui/src/sessions/session-switch.ts @@ -114,6 +114,7 @@ export function restoreSessionState(entry: SessionMeta, projectId?: string): voi } updateSandboxUI(entry.sandbox_enabled !== false); updateSandboxImageUI(entry.sandbox_image || null); + S.setSessionSandboxBackend(entry.sandbox_backend || null); const sandboxRuntimeAvailable = ((S.sandboxInfo as SandboxInfoPayload | null)?.backend || "none") !== "none"; const effectiveSandboxRoute = entry.sandbox_enabled !== false && sandboxRuntimeAvailable; S.setSessionExecMode(effectiveSandboxRoute ? "sandbox" : "host"); diff --git a/crates/web/ui/src/state.ts b/crates/web/ui/src/state.ts index d53b803d71..08faaa6869 100644 --- a/crates/web/ui/src/state.ts +++ b/crates/web/ui/src/state.ts @@ -61,6 +61,7 @@ export let sandboxToggleBtn: HTMLButtonElement | null = null; export let sandboxLabel: HTMLElement | null = null; export let sessionSandboxEnabled = true; export let sessionSandboxImage: string | null = null; +export let sessionSandboxBackend: string | null = null; export let sandboxImageBtn: HTMLButtonElement | null = null; export let sandboxImageDropdown: HTMLElement | null = null; export let sandboxImageLabel: HTMLElement | null = null; @@ -246,6 +247,9 @@ export function setSessionSandboxEnabled(v: boolean): void { export function setSessionSandboxImage(v: string | null): void { sessionSandboxImage = v; } +export function setSessionSandboxBackend(v: string | null): void { + sessionSandboxBackend = v; +} export function setSandboxImageBtn(v: HTMLButtonElement | null): void { sandboxImageBtn = v; } diff --git a/crates/web/ui/src/types/gon.ts b/crates/web/ui/src/types/gon.ts index 10cd1393f7..e2ab6e50f7 100644 --- a/crates/web/ui/src/types/gon.ts +++ b/crates/web/ui/src/types/gon.ts @@ -46,11 +46,25 @@ export interface MemSnapshot { // ── Sandbox gon info ──────────────────────────────────────── +/** Known sandbox backend identifiers. */ +export type SandboxBackendId = + | "docker" + | "podman" + | "apple-container" + | "cgroup" + | "restricted-host" + | "wasm" + | "vercel" + | "daytona" + | "firecracker" + | "none"; + export interface SandboxGonInfo { - backend: string; + backend: SandboxBackendId; os: string; default_image: string; image_building: boolean; + available_backends: SandboxBackendId[]; } // ── Identity ──────────────────────────────────────────────── diff --git a/crates/web/ui/src/types/session.ts b/crates/web/ui/src/types/session.ts index 169df55589..6d6fe86284 100644 --- a/crates/web/ui/src/types/session.ts +++ b/crates/web/ui/src/types/session.ts @@ -26,6 +26,7 @@ export interface SessionMeta { projectId?: string; sandbox_enabled?: boolean; sandbox_image?: string | null; + sandbox_backend?: string | null; worktree_branch?: string; channelBinding?: ChannelBinding | null; activeChannel?: string; diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fca154013c..c3af436c44 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -76,6 +76,7 @@ - [Docker](docker.md) - [Cloud Deploy](cloud-deploy.md) +- [Remote Sandboxes](sandbox-remote.md) - [VPS Deployment](deploy-vps.md) --- diff --git a/docs/src/cloud-deploy.md b/docs/src/cloud-deploy.md index b4bafd1bd1..cda8243f82 100644 --- a/docs/src/cloud-deploy.md +++ b/docs/src/cloud-deploy.md @@ -28,12 +28,13 @@ Only keep Moltis TLS enabled when your proxy talks HTTPS to Moltis (or uses TCP TLS passthrough). In that case, set `MOLTIS_ALLOW_TLS_BEHIND_PROXY=true`. ``` -```admonish warning -**Sandbox limitation**: Most cloud providers do not support Docker-in-Docker. -The sandboxed command execution feature (where the LLM runs shell commands -inside isolated containers) will not work on these platforms. The agent will -still function for chat, tool calls that don't require shell execution, and -MCP server connections. +```admonish tip +**Sandbox on cloud deploys**: Most cloud providers do not support +Docker-in-Docker. To enable sandboxed command execution, configure a +[remote sandbox backend](sandbox-remote.md) — set `VERCEL_TOKEN` for Vercel +Firecracker microVMs, or `DAYTONA_API_KEY` for Daytona cloud sandboxes +(including self-hosted). Moltis auto-detects these when no local Docker is +available. ``` ### `MOLTIS_DEPLOY_PLATFORM` diff --git a/docs/src/sandbox-remote.md b/docs/src/sandbox-remote.md new file mode 100644 index 0000000000..44ef5cb92b --- /dev/null +++ b/docs/src/sandbox-remote.md @@ -0,0 +1,186 @@ +# Remote Sandbox Backends + +When Docker is unavailable (cloud deploys, restricted environments), moltis can +use remote sandbox backends to provide isolated command execution via cloud APIs. + +## Available Backends + +| Backend | Provider | Isolation | Package Manager | +|---------|----------|-----------|-----------------| +| **Vercel Sandbox** | Vercel (managed) | Firecracker microVM | `dnf` (Amazon Linux 2023) | +| **Daytona** | Daytona (managed or self-hosted) | Cloud sandbox | `apt-get` (Ubuntu) | +| **Firecracker** | Self-hosted (Linux) | Local microVM | `apt-get` (Ubuntu) | + +## Vercel Sandbox + +Vercel Sandbox creates ephemeral Firecracker microVMs via the Vercel API. +Each session gets its own isolated VM with millisecond boot times. + +### Configuration + +Set environment variables: + +```bash +VERCEL_TOKEN=ver_your_token_here +VERCEL_TEAM_ID=team_your_team_id # optional but recommended +``` + +Or configure in `moltis.toml`: + +```toml +[tools.exec.sandbox] +backend = "vercel" # or leave "auto" for auto-detection + +# Optional: customize Vercel sandbox settings +vercel_runtime = "node24" # node24, node22, or python3.13 +vercel_timeout_ms = 300000 # 5 minutes +vercel_vcpus = 2 +``` + +### Getting Credentials + +1. **Token**: Go to [vercel.com/account/tokens](https://vercel.com/account/tokens) → Create +2. **Project ID** (required): Create a project at [vercel.com/new](https://vercel.com/new), then get the ID from Project Settings → General → "Project ID" +3. **Team ID** (optional but recommended): Go to your team's Settings → General → scroll to "Team ID" + +### How It Works + +- `backend = "auto"` detects `VERCEL_TOKEN` when no local Docker is available +- Each session creates an ephemeral Firecracker microVM +- Commands execute via the Vercel REST API +- Files transfer via gzipped tar upload / raw read +- On cleanup, the sandbox is stopped (resources freed immediately) +- Snapshots cache pre-installed packages for fast subsequent boots + +## Daytona + +Daytona provides cloud sandboxes via a REST API. You can use the managed +service at `app.daytona.io` or self-host Daytona on your own infrastructure +(e.g., Proxmox, bare-metal Linux, Kubernetes). + +### Configuration + +Set environment variables: + +```bash +DAYTONA_API_KEY=dyt_your_api_key_here +DAYTONA_API_URL=https://app.daytona.io/api # default, change for self-hosted +``` + +Or configure in `moltis.toml`: + +```toml +[tools.exec.sandbox] +backend = "daytona" # or leave "auto" for auto-detection + +# Daytona API settings +daytona_api_url = "https://app.daytona.io/api" # change for self-hosted +daytona_target = "us" # optional target region +``` + +### Self-Hosted Daytona + +If you run Daytona on your own infrastructure (Proxmox, bare-metal, etc.), +point the API URL to your instance: + +```toml +[tools.exec.sandbox] +daytona_api_url = "https://daytona.your-server.local/api" +``` + +Or via environment variable: + +```bash +DAYTONA_API_URL=https://daytona.your-server.local/api +``` + +This gives you full control over the sandbox infrastructure while still +using moltis's multi-backend routing and workspace sync. + +### Getting Credentials + +1. Sign up at [daytona.io](https://www.daytona.io) or deploy self-hosted +2. Generate an API key from the Daytona dashboard +3. Set `DAYTONA_API_KEY` in your environment + +### How It Works + +- `backend = "auto"` detects `DAYTONA_API_KEY` when no local Docker is available +- Each session creates an ephemeral cloud sandbox +- Commands execute via the toolbox REST API +- Files transfer via multipart upload / download +- On cleanup, the sandbox is deleted + +## Local Firecracker + +For Linux servers without Docker where you want VM-level isolation, the +Firecracker backend boots microVMs directly using the Firecracker hypervisor. + +### Requirements + +- Linux only (Firecracker requires KVM) +- `firecracker` binary installed +- Uncompressed Linux kernel (`vmlinux`) +- ext4 rootfs image with SSH server and `sandbox` user +- Root access or `CAP_NET_ADMIN` for TAP networking + +### Configuration + +```toml +[tools.exec.sandbox] +backend = "firecracker" + +firecracker_bin = "/usr/local/bin/firecracker" +firecracker_kernel = "/opt/moltis/vmlinux" +firecracker_rootfs = "/opt/moltis/rootfs.ext4" +firecracker_ssh_key = "/opt/moltis/ssh_key" +firecracker_vcpus = 2 +firecracker_memory_mb = 512 +``` + +### How It Works + +- Boots a Firecracker microVM in ~125ms +- Creates a dedicated TAP device per VM for networking +- Commands execute via SSH into the guest +- Pre-built rootfs caches packages (like Docker image building) +- On cleanup, the VM is shut down and TAP device removed + +## Auto-Detection + +When `backend = "auto"` (the default), moltis selects the sandbox backend +in this order: + +1. **Local**: Apple Container → Podman → Docker → (next) +2. **Remote**: Vercel (if `VERCEL_TOKEN` set) → Daytona (if `DAYTONA_API_KEY` set) +3. **Fallback**: Restricted Host (rlimits only, no isolation) + +## Multi-Backend Routing + +Multiple backends can be active simultaneously. Per-session backend selection +allows different sessions to use different backends: + +```json +{ "key": "session:heavy-compute", "sandboxBackend": "vercel" } +{ "key": "session:quick-test", "sandboxBackend": "docker" } +``` + +Configure backends in the **Settings → Sandboxes → Remote sandbox backends** +section of the web UI, or via environment variables and `moltis.toml`. + +## Web UI Configuration + +Navigate to **Settings → Sandboxes** and scroll to the "Remote sandbox backends" +section. Enter your API tokens and save — moltis will use them after restart. + +## Package Provisioning + +Remote sandboxes automatically install the same default packages configured for +local Docker sandboxes. The first session may take longer as packages are +installed, but subsequent sessions use cached images/snapshots: + +| Backend | Caching Strategy | +|---------|-----------------| +| Vercel | Snapshot after first provisioning (instant subsequent boots) | +| Daytona | Runtime provisioning on first session | +| Firecracker | Pre-built rootfs with packages baked in |